Compare commits

...

1 Commits

Author SHA1 Message Date
rain a6038be376 修改功,现在补齐页面 2026-07-11 21:36:26 +08:00
143 changed files with 7218 additions and 11912 deletions
+883
View File
@@ -0,0 +1,883 @@
# 已废弃:本文件保留仅用于追溯此前的账号闭环方案。
# 正式接口源为 PC.openapi2.json;代码、测试和后续开发不得再以本文件为依据。
openapi: 3.0.3
info:
title: 代代相传 PC 用户端 API
version: 1.1.0
description: |
本文档是当前 PC 用户端唯一接口契约。
范围仅包含账号认证、个人资料、安全设置、头像上传、验证码与行政区划。
家谱、社区、内容、会员、支付及平台运营能力不在本期范围内。
servers:
- url: https://{host}
variables:
host:
default: api.example.com
description: 由部署环境配置的 HTTPS API 域名
tags:
- name: 认证登录
- name: 验证中心
- name: 文件上传
- name: 行政区划
paths:
/genealogy/pc/auth/register:
post:
tags: [认证登录]
summary: PC 用户注册
security: []
parameters:
- $ref: '#/components/parameters/ClientIdHeader'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/RegisterRequest'
responses:
'200':
description: 注册结果;客户端不保存注册响应中的令牌
content:
application/json:
schema:
$ref: '#/components/schemas/LoginResponse'
/genealogy/pc/auth/login:
post:
tags: [认证登录]
summary: PC 密码登录
security: []
parameters:
- $ref: '#/components/parameters/ClientIdHeader'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/PasswordLoginRequest'
responses:
'200':
description: 登录成功并返回令牌
content:
application/json:
schema:
$ref: '#/components/schemas/LoginResponse'
/genealogy/pc/auth/login/sms:
post:
tags: [认证登录]
summary: PC 短信登录
security: []
parameters:
- $ref: '#/components/parameters/ClientIdHeader'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/SmsLoginRequest'
responses:
'200':
description: 登录成功并返回令牌
content:
application/json:
schema:
$ref: '#/components/schemas/LoginResponse'
/genealogy/pc/auth/sms/code:
post:
tags: [认证登录]
summary: 发送短信验证码
security: []
parameters:
- $ref: '#/components/parameters/ClientIdHeader'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/SmsCodeRequest'
responses:
'200':
description: 短信已受理
content:
application/json:
schema:
$ref: '#/components/schemas/VoidResponse'
/genealogy/pc/auth/profile:
get:
tags: [认证登录]
summary: 获取当前用户资料
security:
- BearerAuth: []
parameters:
- $ref: '#/components/parameters/ClientIdHeader'
responses:
'200':
description: 当前登录用户资料
content:
application/json:
schema:
$ref: '#/components/schemas/ProfileResponse'
put:
tags: [认证登录]
summary: 更新当前用户资料
security:
- BearerAuth: []
parameters:
- $ref: '#/components/parameters/ClientIdHeader'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ProfileUpdateRequest'
responses:
'200':
description: 更新后的用户资料
content:
application/json:
schema:
$ref: '#/components/schemas/ProfileResponse'
/genealogy/pc/auth/password:
put:
tags: [认证登录]
summary: 修改当前用户密码
security:
- BearerAuth: []
parameters:
- $ref: '#/components/parameters/ClientIdHeader'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/PasswordChangeRequest'
responses:
'200':
description: 密码修改成功
content:
application/json:
schema:
$ref: '#/components/schemas/VoidResponse'
/genealogy/pc/auth/password/reset:
put:
tags: [认证登录]
summary: 找回密码
security: []
parameters:
- $ref: '#/components/parameters/ClientIdHeader'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/PasswordResetRequest'
responses:
'200':
description: 密码重置成功
content:
application/json:
schema:
$ref: '#/components/schemas/VoidResponse'
/genealogy/pc/auth/phone:
put:
tags: [认证登录]
summary: 换绑手机号
security:
- BearerAuth: []
parameters:
- $ref: '#/components/parameters/ClientIdHeader'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/PhoneChangeRequest'
responses:
'200':
description: 换绑后的用户资料
content:
application/json:
schema:
$ref: '#/components/schemas/ProfileResponse'
/genealogy/pc/auth/account/deactivate:
post:
tags: [认证登录]
summary: 注销当前账号
security:
- BearerAuth: []
parameters:
- $ref: '#/components/parameters/ClientIdHeader'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/AccountDeactivateRequest'
responses:
'200':
description: 注销申请已受理
content:
application/json:
schema:
$ref: '#/components/schemas/VoidResponse'
/genealogy/pc/auth/logout:
delete:
tags: [认证登录]
summary: 退出登录
security:
- BearerAuth: []
parameters:
- $ref: '#/components/parameters/ClientIdHeader'
responses:
'200':
description: 服务端会话已退出
content:
application/json:
schema:
$ref: '#/components/schemas/VoidResponse'
/captcha/require:
get:
tags: [验证中心]
summary: 查询场景是否需要验证码
security: []
parameters:
- $ref: '#/components/parameters/ClientIdHeader'
- name: tenantId
in: query
schema:
type: string
default: '000000'
- name: clientId
in: query
schema:
type: string
- $ref: '#/components/parameters/SceneCode'
- name: subject
in: query
description: 手机号或其他待验证主体
schema:
type: string
responses:
'200':
description: 当前场景验证码策略
content:
application/json:
schema:
$ref: '#/components/schemas/CaptchaRequirementResponse'
/captcha/challenge:
post:
tags: [验证中心]
summary: 创建验证码挑战
security: []
parameters:
- $ref: '#/components/parameters/ClientIdHeader'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CaptchaChallengeRequest'
responses:
'200':
description: 验证码挑战
content:
application/json:
schema:
$ref: '#/components/schemas/CaptchaChallengeResponse'
/captcha/verify:
post:
tags: [验证中心]
summary: 校验验证码并换取业务票据
security: []
parameters:
- $ref: '#/components/parameters/ClientIdHeader'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CaptchaVerifyRequest'
responses:
'200':
description: 返回可提交给后续业务接口的 validToken
content:
application/json:
schema:
$ref: '#/components/schemas/CaptchaVerifyResponse'
/genealogy/pc/files/upload:
post:
tags: [文件上传]
summary: 上传个人头像文件
security:
- BearerAuth: []
parameters:
- $ref: '#/components/parameters/ClientIdHeader'
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [file]
properties:
file:
type: string
format: binary
responses:
'200':
description: 上传结果
content:
application/json:
schema:
$ref: '#/components/schemas/FileUploadResponse'
/genealogy/region/children:
get:
tags: [行政区划]
summary: 查询下级行政区划
security: []
parameters:
- $ref: '#/components/parameters/ClientIdHeader'
- name: parentCode
in: query
schema:
type: string
responses:
'200':
description: 下级地区列表
content:
application/json:
schema:
$ref: '#/components/schemas/RegionListResponse'
/genealogy/region/path/{regionCode}:
get:
tags: [行政区划]
summary: 查询地区完整路径
security: []
parameters:
- $ref: '#/components/parameters/ClientIdHeader'
- $ref: '#/components/parameters/RegionCode'
responses:
'200':
description: 根节点到目标地区的路径
content:
application/json:
schema:
$ref: '#/components/schemas/RegionListResponse'
/genealogy/region/search:
get:
tags: [行政区划]
summary: 搜索行政区划
security: []
parameters:
- $ref: '#/components/parameters/ClientIdHeader'
- name: keyword
in: query
required: true
schema:
type: string
- name: limit
in: query
schema:
type: integer
default: 20
maximum: 50
responses:
'200':
description: 匹配的地区列表
content:
application/json:
schema:
$ref: '#/components/schemas/RegionListResponse'
/genealogy/region/{regionCode}:
get:
tags: [行政区划]
summary: 查询行政区划详情
security: []
parameters:
- $ref: '#/components/parameters/ClientIdHeader'
- $ref: '#/components/parameters/RegionCode'
responses:
'200':
description: 地区详情
content:
application/json:
schema:
$ref: '#/components/schemas/RegionResponse'
components:
parameters:
ClientIdHeader:
name: clientid
in: header
required: true
description: PC 客户端标识
schema:
type: string
example: ced7e5f0498645c6ec642dcf450b036f
RegionCode:
name: regionCode
in: path
required: true
schema:
type: string
example: '110101'
SceneCode:
name: sceneCode
in: query
required: true
schema:
$ref: '#/components/schemas/CaptchaSceneCode'
schemas:
CaptchaSceneCode:
type: string
enum:
- WEB_H5_LOGIN
- WEB_H5_REGISTER
- WEB_H5_FORGOT_PASSWORD
- WEB_H5_CHANGE_PASSWORD
- WEB_H5_CHANGE_PHONE
RegisterRequest:
type: object
required: [grantType, tenantId, phone, password]
properties:
grantType:
type: string
enum: [password]
tenantId:
type: string
example: '000000'
clientId:
type: string
phone:
type: string
pattern: '^1[3-9]\\d{9}$'
password:
type: string
description: 32 位 MD5 值;传输层必须使用 HTTPS
pattern: '^[a-fA-F0-9]{32}$'
nickName:
type: string
maxLength: 32
registerSource:
type: string
enum: [PC]
validToken:
type: string
PasswordLoginRequest:
type: object
required: [grantType, tenantId, phone, password]
properties:
grantType:
type: string
enum: [password]
tenantId:
type: string
clientId:
type: string
phone:
type: string
pattern: '^1[3-9]\\d{9}$'
password:
type: string
pattern: '^[a-fA-F0-9]{32}$'
validToken:
type: string
SmsLoginRequest:
type: object
required: [grantType, tenantId, phone, smsCode]
properties:
grantType:
type: string
enum: [sms]
tenantId:
type: string
clientId:
type: string
phone:
type: string
pattern: '^1[3-9]\\d{9}$'
smsCode:
type: string
pattern: '^\\d{4,8}$'
validToken:
type: string
SmsCodeRequest:
type: object
required: [tenantId, phone, sceneCode]
properties:
tenantId:
type: string
clientId:
type: string
phone:
type: string
pattern: '^1[3-9]\\d{9}$'
sceneCode:
$ref: '#/components/schemas/CaptchaSceneCode'
validToken:
type: string
PasswordChangeRequest:
type: object
required: [oldPassword, newPassword]
properties:
oldPassword:
type: string
pattern: '^[a-fA-F0-9]{32}$'
newPassword:
type: string
pattern: '^[a-fA-F0-9]{32}$'
validToken:
type: string
PasswordResetRequest:
type: object
required: [tenantId, phone, smsCode, newPassword]
properties:
tenantId:
type: string
phone:
type: string
pattern: '^1[3-9]\\d{9}$'
smsCode:
type: string
pattern: '^\\d{4,8}$'
newPassword:
type: string
pattern: '^[a-fA-F0-9]{32}$'
validToken:
type: string
PhoneChangeRequest:
type: object
required: [newPhone, smsCode]
properties:
newPhone:
type: string
pattern: '^1[3-9]\\d{9}$'
smsCode:
type: string
pattern: '^\\d{4,8}$'
validToken:
type: string
AccountDeactivateRequest:
type: object
required: [password]
properties:
password:
type: string
pattern: '^[a-fA-F0-9]{32}$'
reason:
type: string
maxLength: 200
ProfileUpdateRequest:
type: object
properties:
nickName:
type: string
maxLength: 32
avatarOssId:
type: integer
format: int64
sex:
type: string
enum: ['0', '1', '2']
birthday:
type: string
format: date
provinceCode:
type: string
cityCode:
type: string
districtCode:
type: string
addressDetail:
type: string
maxLength: 100
Profile:
type: object
required: [userId, phone]
properties:
userId:
type: integer
format: int64
phone:
type: string
nickName:
type: string
avatarOssId:
type: integer
format: int64
nullable: true
avatarUrl:
type: string
format: uri
nullable: true
sex:
type: string
nullable: true
birthday:
type: string
format: date
nullable: true
provinceCode:
type: string
nullable: true
cityCode:
type: string
nullable: true
districtCode:
type: string
nullable: true
addressDetail:
type: string
nullable: true
LoginData:
type: object
required: [accessToken, userId, tenantId, clientId]
properties:
accessToken:
type: string
userId:
type: integer
format: int64
tenantId:
type: string
clientId:
type: string
FileUpload:
type: object
required: [ossId, url, fileName, originalName]
properties:
ossId:
type: integer
format: int64
url:
type: string
format: uri
thumbnailUrl:
type: string
format: uri
nullable: true
fileName:
type: string
originalName:
type: string
Region:
type: object
required: [regionCode, regionName]
properties:
regionCode:
type: string
regionName:
type: string
parentCode:
type: string
nullable: true
level:
type: integer
minimum: 0
CaptchaRequirement:
type: object
required: [required, sceneCode]
properties:
required:
type: boolean
sceneCode:
$ref: '#/components/schemas/CaptchaSceneCode'
providerCode:
type: string
nullable: true
captchaType:
type: string
nullable: true
CaptchaChallengeRequest:
type: object
required: [tenantId, clientId, sceneCode]
properties:
tenantId:
type: string
clientId:
type: string
sceneCode:
$ref: '#/components/schemas/CaptchaSceneCode'
subject:
type: string
CaptchaChallenge:
type: object
required: [challengeId, providerCode, captchaType]
properties:
challengeId:
type: string
providerCode:
type: string
captchaType:
type: string
backgroundImage:
type: string
format: uri
nullable: true
templateImage:
type: string
format: uri
nullable: true
uuid:
type: string
nullable: true
image:
type: string
nullable: true
CaptchaVerifyRequest:
type: object
required: [tenantId, clientId, sceneCode, challengeId, providerCode, captchaType, payload]
properties:
tenantId:
type: string
clientId:
type: string
sceneCode:
$ref: '#/components/schemas/CaptchaSceneCode'
subject:
type: string
challengeId:
type: string
providerCode:
type: string
captchaType:
type: string
payload:
oneOf:
- $ref: '#/components/schemas/TianaiCaptchaPayload'
- $ref: '#/components/schemas/SystemImageCaptchaPayload'
TianaiCaptchaPayload:
type: object
required: [track]
properties:
track:
type: object
required: [trackList]
properties:
trackList:
type: array
items:
type: object
required: [x, y, t, type]
properties:
x:
type: integer
y:
type: integer
t:
type: integer
type:
type: string
SystemImageCaptchaPayload:
type: object
required: [uuid, code]
properties:
uuid:
type: string
code:
type: string
CaptchaVerify:
type: object
required: [validToken]
properties:
validToken:
type: string
expireAt:
type: string
format: date-time
nullable: true
VoidResponse:
type: object
required: [code, msg]
properties:
code:
type: integer
example: 200
msg:
type: string
data:
nullable: true
LoginResponse:
type: object
required: [code, msg, data]
properties:
code:
type: integer
msg:
type: string
data:
$ref: '#/components/schemas/LoginData'
ProfileResponse:
type: object
required: [code, msg, data]
properties:
code:
type: integer
msg:
type: string
data:
$ref: '#/components/schemas/Profile'
FileUploadResponse:
type: object
required: [code, msg, data]
properties:
code:
type: integer
msg:
type: string
data:
$ref: '#/components/schemas/FileUpload'
RegionResponse:
type: object
required: [code, msg, data]
properties:
code:
type: integer
msg:
type: string
data:
$ref: '#/components/schemas/Region'
RegionListResponse:
type: object
required: [code, msg, data]
properties:
code:
type: integer
msg:
type: string
data:
type: array
items:
$ref: '#/components/schemas/Region'
CaptchaRequirementResponse:
type: object
required: [code, msg, data]
properties:
code:
type: integer
msg:
type: string
data:
$ref: '#/components/schemas/CaptchaRequirement'
CaptchaChallengeResponse:
type: object
required: [code, msg, data]
properties:
code:
type: integer
msg:
type: string
data:
$ref: '#/components/schemas/CaptchaChallenge'
CaptchaVerifyResponse:
type: object
required: [code, msg, data]
properties:
code:
type: integer
msg:
type: string
data:
$ref: '#/components/schemas/CaptchaVerify'
securitySchemes:
BearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
+3580
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -147,7 +147,6 @@
<script src="utils/axios.js"></script>
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
<script src="public/js/promotion-pages.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
</html>
-1
View File
@@ -107,7 +107,6 @@
<script src="utils/axios.js"></script>
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
<script src="public/js/article-pages.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
</html>
+72
View File
@@ -0,0 +1,72 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>儿童个人信息处理规则 - 代代相传</title>
<link rel="stylesheet" href="public/css/public.css" />
<link rel="stylesheet" href="public/css/site-info.css" />
</head>
<body>
<header class="site-header">
<div class="container nav">
<a class="brand magnetic" href="index.html">
<img class="brand-logo" src="public/images/logo.png" alt="我们的家谱,代代相传" />
</a>
<nav class="nav-links">
<a href="index.html">首页</a>
<a href="news.html">新闻资讯</a>
<a href="help.html">帮助中心</a>
<a href="about.html">关于我们</a>
</nav>
<div class="nav-actions">
<a href="login.html">登录</a>
<a href="register.html">注册</a>
</div>
</div>
</header>
<main>
<section class="site-info-hero">
<div class="container">
<div>
<div class="site-info-kicker">Children Privacy</div>
<h1>儿童个人信息处理规则</h1>
<p>用于说明儿童个人信息的处理边界、监护人权利和保护措施。</p>
</div>
<div class="site-info-mark"></div>
</div>
</section>
<section class="site-info-section">
<div class="container site-info-layout">
<article class="site-info-panel">
<div class="site-info-notice">
待法务确认:本页面仅建立儿童个人信息规则的访问入口,不构成已生效的规则。发布前必须由运营主体与法务确认适用年龄、监护人授权与完整文本。
</div>
<h2>正式文本应涵盖</h2>
<p>
儿童信息处理目的、必要性、监护人同意方式、信息访问更正删除渠道,以及发生个人信息安全事件时的告知安排。
</p>
</article>
<aside class="site-info-panel">
<h2>相关文档</h2>
<div class="site-info-links">
<a href="privacy.html">隐私政策</a>
<a href="terms.html">服务协议</a>
<a href="help.html">帮助中心</a>
</div>
</aside>
</div>
</section>
</main>
<footer class="footer">
<div class="container">
<div class="footer-legal">
<div>© 2026 代代相传. All rights reserved.</div>
<a href="privacy.html">隐私政策</a>
<a href="terms.html">服务协议</a>
</div>
</div>
</footer>
<script src="public/js/page-effects.js"></script>
</body>
</html>
+1 -1
View File
@@ -9,12 +9,12 @@
})(typeof globalThis !== 'undefined' ? globalThis : window, function (root) {
'use strict';
// 这里只维护运行环境和后端基础地址,具体接口路径统一放在 ApiClient 中。
var ENVIRONMENTS = {
development: {
apiBaseUrl: 'http://182.61.18.23:8080'
},
production: {
// 后端尚未提供正式生产域名,暂时保持用户指定的测试地址。
apiBaseUrl: 'http://182.61.18.23:8080'
}
};
-2
View File
@@ -130,8 +130,6 @@
<script src="utils/axios.js"></script>
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
<script src="public/js/region-pages.js"></script>
<script src="public/js/genealogy-pages.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
</html>
@@ -1,354 +0,0 @@
# PC 接口对接重新规划与状态追踪
创建时间:2026-07-09 15:14:58 +08:00
接口依据:`genealogy-pc-openapi.yaml`
接口基础地址:`http://182.61.18.23:8080`
接口依据:`genealogy-pc-openapi.yaml`
## 当前地址契约
- 运行时唯一来源:根目录 `config.js`
- 当前开发和生产地址:`http://182.61.18.23:8080`
- 地址变更记录:2026-07-11 已由历史测试域名切换为当前 IP 和端口;文档中出现的 `test-genealogy-api.ddxcjp.cn` 仅用于描述历史事件,不代表当前运行时配置。
## 执行规则
1. 所有 PC 接口对接必须先进入本追踪表,再改代码。
2. 每一项开始前先标记“进行中”,记录年月日时分秒和时区。
3. 每一项完成后记录完成时间、修改文件、接口路径、验证命令和结果。
4. 只有完成验证后才能标记“已完成”。
5. `genealogy-pc-openapi.yaml` 没有明确接口的页面,标记“暂缓”或“待确认”,不继续套用旧路径。
6. 样式继续写入 CSS 文件,业务 JS 不注入样式。
7. 新增或修改的 HTML、JS、CSS 注释使用中文,代码保持可读、不压缩。
## PC 文档当前接口范围
- 验证中心:`/captcha/require``/captcha/challenge``/captcha/verify``/auth/code`
- PC 认证:`/genealogy/pc/auth/register``/genealogy/pc/auth/login``/genealogy/pc/auth/login/sms``/genealogy/pc/auth/sms/code``/genealogy/pc/auth/profile``/genealogy/pc/auth/password``/genealogy/pc/auth/password/reset``/genealogy/pc/auth/phone``/genealogy/pc/auth/account/deactivate``/genealogy/pc/auth/logout`
- PC 文件:`/genealogy/pc/files/upload``/genealogy/pc/files/resumable/init``/genealogy/pc/files/resumable/chunk``/genealogy/pc/files/resumable/complete``/genealogy/pc/files/reference`
- 行政区划:`/genealogy/region/children``/genealogy/region/path/{regionCode}``/genealogy/region/search``/genealogy/region/{regionCode}`
## 最新 PC YAML 分析记录
分析时间:2026-07-09 15:36:16 +08:00
分析文件:`genealogy-pc-openapi.yaml`
1. `paths` 中实际存在 25 个接口操作,分为验证码、PC 认证、PC 文件、行政区划四类。
2. `servers` 仍是本地地址示例;当前运行时基础地址以 `config.js` 为准,为 `http://182.61.18.23:8080`
3. 登录后接口使用 `Authorization` 作为 token header,同时所有 PC 认证和文件接口都要求 header `clientid`;用户最新提供的 PC `clientid``ced7e5f0498645c6ec642dcf450b036f`,客户端 Key 为 `web_pc`
4. `components.tags``components.schemas/requestBodies` 中出现家谱、动态、世系、字辈等业务模型,但 `paths` 没有对应接口路径;实现时不能把这些 schema 当成可调用 PC 接口。
5. 验证码场景以后台配置为准,当前明确可用的 PC/H5 场景为 `WEB_H5_LOGIN``WEB_H5_REGISTER``WEB_H5_FORGOT_PASSWORD`
6. `ProfileUpdate` 示例出现 `regionCode/addressDetail`,但 schema 实际字段是 `provinceCode/cityCode/districtCode`,页面资料提交时应以 schema 字段为准。
7. `SmsLoginBody` 未声明 `validToken` 字段,但短信发送 `SmsCodeBody` 必填 `validToken`;短信登录流程应先通过验证码拿票据,再发短信验证码,最后用短信码登录。
8. 文件引用 `FileReferenceBody` 必填 `bizType/bizTable/bizId/bizField``ossId/ossIds/usageScene/usageName` 可选;但文章、动态、相册等业务主体接口未提供,文件引用只能作为公共上传能力,不代表主业务已可提交。
## 验证码与 TAC 调用记录
- 后台验证码配置截图中,PC/H5 当前明确可用场景是 `WEB_H5_LOGIN``WEB_H5_REGISTER``WEB_H5_FORGOT_PASSWORD`,客户端 Key 为 `web_pc`,验证服务为“天爱行为验证码”,验证码类型为“滑块验证码”。
- `ADMIN_LOGIN` 是后台管理登录,类型为旋转验证码,不归当前 PC/H5 页面使用。
- PC/H5 登录页调用链:
1. 页面加载 `public/tac/css/tac.css``public/tac/js/tac.min.js``public/js/captcha-pages.js`
2. 表单提供 `input[name="validToken"]` 隐藏字段和 `[data-captcha-box]` 容器。
3. `auth-pages.js` 在登录提交前调用 `CaptchaPages.ensureToken(form, { sceneCode: 'WEB_H5_LOGIN', subject: 手机号 })`
4. `captcha-pages.js` 先请求 `GET /captcha/require?tenantId=000000&clientId=ced7e5f0498645c6ec642dcf450b036f&sceneCode=WEB_H5_LOGIN&subject=手机号`
5. 如果返回 `required=false`,业务表单可继续提交;如果需要验证,则创建 `new CaptchaConfig(...)``new TAC(config, ...)`
6. TAC 内部请求 `POST /captcha/challenge` 获取 `captchaType/challengeId/payload`,适配为 TAC 需要的 `data.type/data.id/backgroundImage/templateImage`
7. 用户拖动完成后,TAC 请求 `POST /captcha/verify`,提交 `challengeId/providerCode/captchaType/payload.track`,其中 `left/top` 由轨迹首尾点计算。
8. 验证成功后从返回 `data.validToken` 取票据,写回表单隐藏字段 `validToken`,再提交 PC 登录接口。
- PC/H5 注册使用 `WEB_H5_REGISTER`,找回密码和找回密码短信验证码使用 `WEB_H5_FORGOT_PASSWORD`;换绑手机、注销账号暂未看到明确 PC/H5 场景编码,仍待后端补充后再接。
## 编号计划
| 编号 | 状态 | 页面范围 | 接口范围 | 计划与验证 |
| --- | --- | --- | --- | --- |
| PC-000 | 复核通过 | 全项目 | `genealogy-pc-openapi.yaml` | 历史记录保留;当前 PC YAML `paths` 再次核对通过,真实接口范围仍只有验证码、PC 认证、PC 文件和地区。 |
| PC-001 | 复核通过 | 根目录 `config.js` | 开发/生产环境、API 基础地址 | 当前 `config.js` 已收敛为环境和基础地址唯一入口;`node tests\config.test.js``node --check config.js` 通过。 |
| PC-002 | 复核通过 | `utils` 公共工具层 | Axios、请求封装、PC 接口客户端 | 当前公共层为 `utils/axios.js``utils/AxiosRequestUtil.js``utils/ApiClient.js`;旧 fetch 工具和旧 public API 客户端已删除。 |
| PC-003 | 本地复核通过 | 认证、资料、安全页面 | `/genealogy/pc/auth/*``/captcha/*` | 认证、资料、安全测试和脚本顺序检查均通过;真实登录业务仍依赖后端账户数据。 |
| PC-004 | 本地复核通过 | 文件上传页面 | `/genealogy/pc/files/*` | `node tests\upload-pages.test.js``node tests\api-client.test.js` 通过;文件上传仍需真实文件和后端授权进行端到端验证。 |
| PC-005 | 本地复核通过 | 地区选择页面 | `/genealogy/region/*` | `node tests\region-pages.test.js``node tests\profile-pages.test.js` 通过。 |
| PC-006 | 复核通过 | 页面覆盖矩阵 | PC YAML 覆盖边界 | 保持“公共能力不等于整页业务已对接”的边界,未覆盖业务继续受控失败。 |
| PC-007 | 复核通过 | 实施规划 | `genealogy-pc-openapi.yaml` | 当前计划与 YAML `paths` 一致,不使用旧路径 fallback。 |
| PC-008 | 复核通过 | PC YAML 未覆盖业务页面 | `PC_API_NOT_AVAILABLE` | `utils/ApiClient.js` 统一抛 `PC_API_NOT_AVAILABLE`,过期请求路径扫描无结果。 |
| PC-009 | 本地复核通过 | 登录、注册、找回密码 | PC auth、`/captcha/*` | 三页场景编码、表单契约、Axios 请求客户端和相关测试均通过;真实账号流程待业务数据联调。 |
| PC-010 | 已复核(当前地址已同步) | `config.js``tests/config.test.js``utils/ApiClient.js` | API 基础地址 | 当前开发和生产地址均为 `http://182.61.18.23:8080`;地址唯一运行时来源为 `config.js``tests/config.test.js` 已同步断言。 |
| PC-011 | 本地复核通过 | 三张认证页 TAC 弹窗 | `public/tac``/captcha/*` | 页面级挂载、移动端约束和结构测试通过;未修改 `tac.min.js`。 |
## PC 页面覆盖矩阵草案
| 分类 | 页面/模块 | 当前判断 | 处理方式 |
| --- | --- | --- | --- |
| 完整可闭环 | `login.html` | PC YAML 有 `/genealogy/pc/auth/login`,后台验证码配置明确 `WEB_H5_LOGIN`,TAC 可完整串联。 | 可作为第一批执行页面。 |
| 接口存在但部分验证码场景待确认 | `register.html``forgot-password.html``profile-security.html` 中的换绑/注销相关动作 | PC YAML 有对应认证接口;注册场景为 `WEB_H5_REGISTER`,找回密码场景为 `WEB_H5_FORGOT_PASSWORD`,换绑和注销暂未提供 PC/H5 场景。 | 注册和找回密码可接 TAC;换绑、注销涉及验证码的提交先记录待确认,不套用未经确认的场景。 |
| 资料接口可对接 | `profile.html``profile-data.html` 中用户资料读取/修改 | PC YAML 有 `/genealogy/pc/auth/profile`。 | 只处理用户资料读写;页面里其他非资料业务不因此视为完成。 |
| 公共地区能力可保留 | `create-genealogy.html``profile-create-family.html``profile-data.html` 中地区选择控件 | PC YAML 有 `/genealogy/region/*`。 | 只保留和验证地区选择;创建家谱等主业务接口 PC YAML 未覆盖,仍待确认。 |
| 文件能力可切换 | 文章、动态、相册、成长、备忘、祭祀等页面里的上传控件 | PC YAML 有 `/genealogy/pc/files/*`。 | 只把上传、分片、文件引用切到 PC 文件接口;文章/动态/相册等主业务接口仍待确认。 |
| PC YAML 未覆盖 | 家谱主体、成员、字辈、世系、动态、文章、相册、祭祀、族务、VIP、通知、反馈、帮助、推广、姓氏、搜索等主业务页面 | 当前 PC YAML 没有这些业务路径。 | 等待补充 PC 接口后再逐页规划。 |
## 流程记录
- 2026-07-09 15:14:58 +08:00:收到用户纠正,停止旧接口继续推进;读取 `genealogy-pc-openapi.yaml`,确认 PC 路径前缀为 `/genealogy/pc`,基础地址改用 `https://test-genealogy-api.ddxcjp.cn`
- 2026-07-09 15:14:58 +08:00:创建本 PC 专用追踪表,PC-001 标记为“进行中”。
- 2026-07-09 15:17:57 +08:00:收到用户补充要求,重新规划 `config.js` 环境切换和 `utils` 公共 JS 分层;停止直接执行旧 PC-001,把 PC-001 到 PC-006 重排为待执行计划。
- 2026-07-09 15:24:02 +08:00:收到用户补充验证码要求;记录 `public/tac` 调用链,确认 PC/H5 登录使用 `WEB_H5_LOGIN`,其他未配置 PC/H5 场景的认证验证码先待确认。
- 2026-07-09 15:31:14 +08:00:根据用户质疑修正 PC-006;不再把“公共地区/文件接口可用”写成“整页可对接”,改为页面覆盖矩阵和缺口清单。
- 2026-07-09 15:34:26 +08:00:同步修正详细规划文件 `docs/superpowers/plans/2026-07-09-pc-api-replan.md` 的 Task 6,并将 PC-006 标记为“已修正”。
- 2026-07-09 15:41:40 +08:00:按最新 YAML 分析结果重写 `docs/superpowers/plans/2026-07-09-pc-api-replan.md`,新增 PC-007;明确旧路径不能作为 PC fallback,缺失业务接口统一降级为接口待确认。
- 2026-07-09 15:44:18 +08:00:开始执行 PC-001/PC-002,先按 TDD 创建 `config.js``utils` 公共工具测试,再实现最小配置与工具层。
- 2026-07-09 15:47:17 +08:00:完成 PC-001 和 PC-002 的公共工具层部分;`config.js` 位于根目录,`utils` 只放跨页面公共 JS,页面业务仍留在 `public/js`
- 2026-07-09 15:51:34 +08:00:完成 PC-002 的请求客户端切换;旧路径不再作为网络请求路径,未覆盖业务统一抛 `PC_API_NOT_AVAILABLE`
- 2026-07-09 15:55:28 +08:00PC-003 认证页验证码阶段完成;登录使用 `WEB_H5_LOGIN`,未确认 PC/H5 场景的注册和找回密码不调用验证中心。
- 2026-07-09 15:57:36 +08:00:收到用户补充截图,确认 PC `clientid``ced7e5f0498645c6ec642dcf450b036f`、客户端 Key 为 `web_pc`,新增注册场景 `WEB_H5_REGISTER` 和找回密码场景 `WEB_H5_FORGOT_PASSWORD`;同步修正配置、计划和认证页场景映射。
- 2026-07-09 16:00:22 +08:00PC-003 完成;认证页、资料页、安全页的 PC auth/captcha 接入和脚本顺序已验证。
- 2026-07-09 16:03:23 +08:00PC-004 完成;上传公共能力切换到 PC 文件接口,相关上传页已补齐公共层脚本顺序。
- 2026-07-09 16:05:12 +08:00PC-005 完成;地区选择只保留公共行政区划能力,创建家谱等主业务仍按 PC YAML 未覆盖处理。
- 2026-07-09 16:06:35 +08:00PC-008 完成;PC YAML 未覆盖业务不会继续发送过期请求,全量 Node 测试通过。
- 2026-07-09 16:07:42 +08:00:收尾验证完成;全量 `tests/*.test.js` 通过,`public/js` 非压缩业务脚本 `node --check` 通过,过期请求路径、旧验证码场景、旧 PC clientId 扫描无输出;`git diff --check` 因当前目录不是 Git 仓库无法执行。
- 2026-07-09 16:09:55 +08:00:开始登录、注册、找回密码三页专项核对;按 PC YAML 的 `PasswordLoginBody``PasswordRegisterBody``SmsCodeBody``PasswordResetBody` 修正页面字段、输入类型、验证码场景声明和文案。
- 2026-07-09 16:12:10 +08:00:登录、注册、找回密码三页专项完成;三页表单已显式声明 `data-captcha-scene`,手机号输入改为 `type="tel"``inputmode="numeric"``maxlength="11"`,找回密码文案改为只支持注册手机号;`auth-pages.js` 优先读取页面 `data-captcha-scene`。验证:`node tests\auth-pages.test.js``node tests\captcha-pages.test.js``node tests\api-client.test.js` 通过;三页字段/脚本顺序扫描通过;旧邮箱文案和旧验证码场景扫描无输出。
- 2026-07-09 16:19:11 +08:00:根据用户反馈“接口文档少了很多东西”重新核对 `genealogy-pc-openapi.yaml` 的真实 `paths`;确认当前 YAML 只有验证中心、认证登录、文件上传、行政区划四类共 25 个操作,虽然声明了家谱、成员、字辈、世系、家族圈、文章、相册、祭祀、族务、VIP、消息、反馈等标签和模型,但没有对应可调用路径。结论:这些业务页面不能继续视为已对接,只能保留 `PC_API_NOT_AVAILABLE`,等待后端补充 PC 接口文档。
- 2026-07-09 16:48:05 +08:00:处理注册页滑动验证请求失败。确认失败不是注册字段问题,而是测试域名 HTTPS 握手失败;开发环境接口基础地址修正为 HTTP,并兼容清理旧 HTTPS 本地覆盖值。
- 2026-07-09 16:56:22 +08:00:处理 TAC 验证码显示位置问题。确认不是后端验证码类型问题,也不是 `tac.min.js` 被改;问题来自前端挂载点在表单中,导致 TAC 相对定位弹窗被卡片布局吞进去。
- 2026-07-09 17:02:35 +08:00TAC 弹窗挂载修复完成。三张认证页改为页面级固定遮罩容器承载 TAC,表单内部不再放验证码挂载点;新增结构测试防止回退。
- 2026-07-09 17:04:21 +08:00:复现用户反馈的 `503 Service Unavailable``curl.exe` 请求 `/captcha/require` 和域名根路径 `/` 均返回 `Server: SakuraFrp``503 Service Unavailable`,响应页提示检查 SakuraFrp 隧道节点、隧道配置、frpc 是否运行、隧道是否在线。结论:这是测试接口域名/内网穿透服务当前不可用,不是前端字段、TAC 样式或请求参数导致。
## PC-012 认证页 layui 提示与 TAC 弹窗样式修正
- 状态:本地复核通过
- 计划开始时间:2026-07-09 17:14:08 +08:00
- 计划文件:`docs/superpowers/plans/2026-07-09-auth-layer-message.md`
- 页面范围:`login.html``register.html``forgot-password.html`
- 接口范围:不新增接口,只修正 `/captcha/*` 流程中的前端提示方式和弹窗展示方式。
- 完成时间:2026-07-09 17:20:24 +08:00
- 修改文件:`login.html``register.html``forgot-password.html``utils/MessageUtil.js``public/js/auth-pages.js``public/js/captcha-pages.js``public/css/public.css``tests/auth-page-structure.test.js``tests/auth-message.test.js``docs/superpowers/plans/2026-07-09-auth-layer-message.md`
- 验证结果:
- `node tests\auth-page-structure.test.js` 通过。
- `node tests\auth-message.test.js` 通过。
- `node tests\auth-pages.test.js` 通过。
- `node tests\captcha-pages.test.js` 通过。
- `node --check public\js\auth-pages.js` 通过。
- `node --check public\js\captcha-pages.js` 通过。
- `node --check utils\MessageUtil.js` 通过。
- `Get-ChildItem -Path tests -Filter *.test.js | Sort-Object Name | ForEach-Object { node $_.FullName }` 全部通过。
- 剩余风险:测试域名当前仍可能返回 SakuraFrp `503 Service Unavailable`,这是后端/隧道可用性问题,不是本次提示和样式改动导致。
- 执行计划:
1. 已完成失败测试:`tests/auth-page-structure.test.js``tests/auth-message.test.js`
2. 已完成:三页加载 layui 样式和脚本。
3. 已完成:`auth-pages.js``captcha-pages.js` 提示统一走 `MessageUtil`/`layui.layer.msg`,不再使用浏览器原生 `alert`
4. 已完成:`public/css/public.css` 增加认证页 layer 提示皮肤。
5. 已完成:焦点测试、相关测试、语法检查和全量 Node 测试通过。
## PC-013 TAC 取消或请求失败后的遮罩清理
- 状态:本地复核通过
- 计划开始时间:2026-07-10 09:10:03 +08:00
- 计划文件:`docs/superpowers/plans/2026-07-10-tac-modal-cleanup.md`
- 页面范围:`login.html``register.html``forgot-password.html`
- 接口范围:`/captcha/require``/captcha/challenge``/captcha/verify`
- 根因记录:现有 `.auth-captcha-modal` 仅依赖 `:empty` 判断隐藏;TAC 在取消、CORS 失败或 503 失败后仍可能保留子节点,导致外层遮罩层不再为空从而不会消失。
- 处理计划:由 `captcha-pages.js` 显式管理 `is-active` 状态、`aria-hidden` 和残留 DOM;成功、取消、初始化异常、Promise 失败路径全部调用统一清理函数。
- 完成时间:2026-07-10 09:15:55 +08:00
- 修改文件:`public/js/captcha-pages.js``public/css/captcha-modal.css``login.html``register.html``forgot-password.html``tests/captcha-pages.test.js``tests/auth-page-structure.test.js``docs/superpowers/plans/2026-07-10-tac-modal-cleanup.md`
- 完成结果:`openCaptchaBox` 在每次初始化前清理旧 TAC DOM 并显示遮罩;`closeCaptchaBox` 在成功、取消、初始化异常、Promise 失败后清空 DOM、隐藏遮罩并更新 `aria-hidden`;三张认证页加载新的公共验证码弹窗 CSS。
- 验证结果:`node tests\\captcha-pages.test.js``node tests\\auth-page-structure.test.js``node --check public\\js\\captcha-pages.js``Get-ChildItem -Path tests -Filter *.test.js | Sort-Object Name | ForEach-Object { node $_.FullName }` 均通过。
- 剩余风险:`/captcha/*` 的 503、CORS 或 TLS 错误属于测试网关/服务端可用性问题;本项只保证它们触发取消或异常后,前端不会继续遗留遮罩。
## PC-014 已完成记录全量复核
- 状态:已完成
- 开始时间:2026-07-10 09:20:38 +08:00
- 计划文件:`docs/superpowers/plans/2026-07-10-pc-tracker-reverification.md`
- 复核范围:PC-000 至 PC-013 中所有标记为“已完成”或“已修正”的项目。
- 复核规则:以当前 `genealogy-pc-openapi.yaml``paths`、当前代码、当前 Node 测试、语法检查和静态扫描为准;历史记录只作为待核对内容,不作为证明。
- 状态规则:当前证据完整才保留“已完成”;代码或记录不一致改为“待修正”;仅受测试网关影响的项目标记“外部环境待验证”。
- 暂停时间:2026-07-10 09:27:30 +08:00
- 暂停原因:用户要求删除现有 fetch 请求层并整体切换为 Axios;重构前取得的复核结果不能作为重构后的最终证据。
- 完成时间:2026-07-10 10:03:18 +08:00
- 当前结论:上方 PC-000 至 PC-011 及下方 PC-012、PC-013 的复核状态为本次有效状态,历史文件名、历史命令和历史时间仅用于追溯。
- 验证证据:当前 YAML 路径扫描、Axios 公共层测试、认证/验证码/资料/安全/上传/地区测试、45 页脚本顺序测试、全量 Node 测试、语法检查均通过;受限环境外 `curl.exe -I http://test-genealogy-api.ddxcjp.cn/captcha/require` 返回 `HTTP 200 OK`
## PC-015 Axios 公共请求层重构
- 状态:已完成
- 开始时间:2026-07-10 09:27:30 +08:00
- 计划文件:`docs/superpowers/plans/2026-07-10-axios-request-layer-refactor.md`
- 目录边界:`utils/axios.js` 存第三方 Axios 非压缩浏览器构建;`utils/AxiosRequestUtil.js` 存公共请求封装;`utils/ApiClient.js` 存跨页面 PC 接口客户端;`public/js` 只存页面业务脚本。
- 清理目标:删除 `utils/RequestUtil.js``public/js/api-client.js`,并清除 `fetch``fetchImpl``root.fetch` 和旧页面脚本引用;不提供兼容分支。
- 范围修正:2026-07-10 09:27:30 +08:00 初始清单只按旧 RequestUtil 引用列出 14 页;本轮静态扫描发现总共有 45 页加载接口客户端,已修正为自动扫描全部相关 HTML 页面,剩余 31 页必须迁移后才能完成 PC-015。
- 配置职责修正:2026-07-10 09:47:25 +08:00 参考 `C:\Users\Administrator\Desktop\job\3Dyjs\config.js` 后确认,根目录 `config.js` 只负责开发/生产环境与 API 基础地址;PC clientId、tenantId、token key 和 Authorization header 由 `utils/ApiClient.js` 负责。当前未提供生产地址,生产环境暂时使用用户指定的测试地址,待后端提供生产域名后单独替换。
- 配置单一来源复核:2026-07-10 10:10:56 +08:00 已移除 `utils/ApiClient.js``DEFAULT_BASE_URL`;客户端现在只能消费根目录 `config.js``apiBaseUrl` 或调用方显式传入的 `baseUrl`,缺失时立即抛出明确错误。
- 完成时间:2026-07-10 10:03:18 +08:00
- 完成结果:下载 Axios 1.7.9 非压缩浏览器构建至 `utils/axios.js`;创建 `utils/AxiosRequestUtil.js``utils/ApiClient.js`;删除 `utils/RequestUtil.js``public/js/api-client.js`45 页统一加载 `config.js``StorageUtil.js`、Axios、AxiosRequestUtil、ApiClient 后再加载页面业务脚本。
- 验证结果:`node tests\config.test.js``node tests\utils.test.js``node tests\api-client.test.js``node tests\api-script-order.test.js`、全量 Node 测试、`node --check` 和旧 fetch/旧脚本扫描均通过;`git diff --check` 无空白错误。2026-07-10 10:10:56 +08:00 再次回归通过,并确认客户端不再导出 `DEFAULT_BASE_URL`
## PC-016 TAC 原生样式调用清理
- 状态:已完成(由 PC-017 修正调用位置)
- 开始时间:2026-07-10 10:19:46 +08:00
- 计划文件:`docs/superpowers/plans/2026-07-10-tac-native-style-cleanup.md`
- 页面范围:`login.html``register.html``forgot-password.html`
- 约束:只调用 `public/tac` 自带的 CSS 和 JS;移除项目自定义验证码遮罩、尺寸约束与视觉配置,不改动 TAC 第三方文件。
- 完成时间:2026-07-10 10:32:20 +08:00
- 修改结果:删除 `public/css/captcha-modal.css`;删除 `public/css/public.css` 中 35 行自定义验证码规则;三页仅保留 `public/tac/css/tac.css`,并使用无 class 的 `[data-captcha-box]``captcha-pages.js` 只调用 `new TAC(config)`
- 验证结果:`node tests\auth-page-structure.test.js``node tests\captcha-pages.test.js`、全量 Node 测试、`node --check public\js\captcha-pages.js`、自定义验证码视觉标识扫描和 `git diff --check` 均通过。
- 外部边界:本项只恢复 TAC 原生视觉与调用边界;验证码背景尺寸校验失败仍需以后端挑战响应的原始图片尺寸为依据另行修复。
- 复核时间:2026-07-10 10:38:10 +08:00
- 复核结论:TAC 的 `init()` 只将组件追加到 `CaptchaConfig.bindEl`,不负责页面级定位;删除静态挂载点定位样式后,组件被追加到 `body` 末尾,因此渲染到页面底部。本项不能保留“已完成”。
## PC-017 TAC 默认弹层调用纠正
- 状态:已完成
- 开始时间:2026-07-10 10:38:10 +08:00
- 计划文件:`docs/superpowers/plans/2026-07-10-tac-layer-call-correction.md`
- 调用位置:`auth-pages.js` 表单提交前调用 `CaptchaPages.ensureToken`;验证码适配层使用 Layui 默认 `layer.open` 创建临时容器,容器传入 `CaptchaConfig.bindEl`,然后调用 `new TAC(config)`
- 功能边界:不限制后端下发的 `SLIDER``ROTATE``CONCAT``WORD_IMAGE_CLICK``DISABLED` 类型;刷新由 TAC `reloadCaptcha()` 处理;成功、关闭与异常路径由适配层关闭对应 Layui 层。
- 完成时间:2026-07-10 10:44:11 +08:00
- 修改结果:删除三页静态 `[data-captcha-box]``captcha-pages.js` 用 Layui 默认 `layer.open` 创建临时容器作为 `CaptchaConfig.bindEl`,并保持 `new TAC(config)` 原样调用;TAC 结束后关闭同一 Layui 层。
- 验证结果:`node tests\auth-page-structure.test.js``node tests\captcha-pages.test.js`、全量 Node 测试、`node --check public\js\captcha-pages.js``git diff --check` 均通过;静态扫描确认三页、公共 CSS 和验证码适配层没有 `data-captcha-box``auth-captcha-modal``captcha-modal.css``bgUrl``logoUrl`
- 浏览器边界:尚未在真实测试网关完成有效轨迹验证;需在浏览器发起一次注册、登录或找回密码操作,确认 Layui 默认层居中和后端 `validToken` 流程。
## PC-018 TAC Layui content 契约修复
- 状态:已完成(由 PC-019 修正真实挂载点)
- 开始时间:2026-07-10 10:50:06 +08:00
- 完成时间:2026-07-10 10:54:03 +08:00
- 计划文件:`docs/superpowers/plans/2026-07-10-tac-layui-content-contract-fix.md`
- 页面范围:`login.html``register.html``forgot-password.html`
- 接口范围:`/captcha/require``/captcha/challenge``/captcha/verify`
- 根因记录:浏览器中 `/captcha/require` 已返回 `200`,但点击提交后提示 `d.parents is not a function` 且 TAC 不显示。根因是 `captcha-pages.js` 将原生 DOM 节点直接传入 `layui.layer.open({ content })`;当前 Layui 2.11.5 弹层内部会对 `content` 调用 `.parents()`,原生 DOM 没有该方法,导致弹层创建中断。
- 修改文件:`public/js/captcha-pages.js``tests/captcha-pages.test.js``docs/superpowers/plans/2026-07-10-tac-layui-content-contract-fix.md``docs/pc-api-page-integration-tracker-2026-07-09.md`
- 修改结果:`createCaptchaLayer` 现在使用 `layui.$``$``jQuery` 包装临时 DOM 后传给 `layer.open`;返回给 TAC 的 `box` 仍是真实 DOM 节点,继续作为 `CaptchaConfig.bindEl`。未修改 `public/tac`,未新增验证码 CSS,未传 TAC 视觉配置。
- 验证结果:先运行 `node tests\captcha-pages.test.js` 复现失败;修复后 `node tests\auth-page-structure.test.js``node tests\captcha-pages.test.js``node --check public\js\captcha-pages.js``git diff --check` 通过。`git diff --check` 仅输出既有 CRLF 行尾提示,没有空白错误。
## PC-020 TAC Layui 弹层居中修复
- 状态:已完成
- 开始时间:2026-07-10 11:11:05 +08:00
- 完成时间:2026-07-10 11:14:31 +08:00
- 计划文件:`docs/superpowers/plans/2026-07-10-tac-layer-center-fix.md`
- 页面范围:`login.html``register.html``forgot-password.html`
- 接口范围:`/captcha/require``/captcha/challenge``/captcha/verify`
- 根因记录:Layui 打开弹层时验证码挂载点仍是空节点,TAC 后续异步创建自己的 `#tianai-captcha-parent`,原生尺寸为 `318px * 318px`,导致 Layui 先按空内容定位后,最终验证码窗口偏离视觉中心。
- 修改文件:`public/js/captcha-pages.js``tests/captcha-pages.test.js``docs/superpowers/plans/2026-07-10-tac-layer-center-fix.md``docs/pc-api-page-integration-tracker-2026-07-09.md`
- 修改结果:`createCaptchaLayer` 使用 Layui 默认 `layer.open` 时增加 `area: ['318px', '318px']``offset: 'auto'`,尺寸来自 TAC 原生 `#tianai-captcha-parent`;未修改 `public/tac`,未新增验证码自定义 CSS,未传 TAC 视觉配置。
- 验证结果:先运行 `node tests\captcha-pages.test.js` 复现 `layerOptions.area` 缺失失败;修复后 `node tests\captcha-pages.test.js``node tests\auth-page-structure.test.js``node --check public\js\captcha-pages.js``git diff --check` 通过。`git diff --check` 仅输出既有 LF/CRLF 换行提示,没有空白错误。
## PC-021 验证结果参数格式修正
- 状态:已完成
- 开始时间:2026-07-10 14:31:14 +08:00
- 完成时间:2026-07-10 14:34:28 +08:00
- 计划文件:`docs/superpowers/plans/2026-07-10-captcha-verify-track-contract.md`
- 页面范围:`login.html``register.html``forgot-password.html`
- 接口范围:`POST /captcha/verify`
- 根因记录:后端最新示例要求验证轨迹提交为 `payload.track`,但前端仍按旧契约提交 `payload.id``payload.data`,并缺少 `left/top`
- 修改文件:`public/js/captcha-pages.js``tests/captcha-pages.test.js``docs/superpowers/plans/2026-07-10-captcha-verify-track-contract.md``docs/pc-api-page-integration-tracker-2026-07-09.md`
- 修改结果:`buildVerifyBody` 现在提交 `payload.track``left/top` 优先使用已有值,缺失时由轨迹首尾点差值计算;旧的 `payload.id``payload.data` 不再作为 `/captcha/verify` 请求体提交。
- 验证结果:先运行 `node tests\captcha-pages.test.js` 复现旧请求体失败;修复后 `node tests\captcha-pages.test.js``node tests\auth-page-structure.test.js``node tests\auth-pages.test.js``node --check public\js\captcha-pages.js``git diff --check` 通过。`git diff --check` 仅输出 LF/CRLF 换行提示,没有空白错误。
## PC-022 登录页样式优化
- 状态:已完成
- 开始时间:2026-07-10 14:42:32 +08:00
- 完成时间:2026-07-10 14:50:23 +08:00
- 计划文件:`docs/superpowers/plans/2026-07-10-login-auth-flow-polish.md`
- 页面范围:`login.html`
- 修改文件:`public/css/login.css`
- 修改结果:登录方式切换区改为圆角分段控件,选中项使用浅红底和红字;按钮与底部链接增加间距。样式全部写在登录页 CSS 中,未通过 JS 注入样式。
- 验证结果:`node tests\auth-page-structure.test.js``node tests\auth-pages.test.js``node tests\captcha-pages.test.js``node --check public\js\auth-pages.js``node --check public\js\captcha-pages.js``git diff --check` 通过。`git diff --check` 仅输出 LF/CRLF 换行提示,没有空白错误。
## PC-023 注册跳转与验证码取票据流程复核
- 状态:已完成
- 开始时间:2026-07-10 14:42:32 +08:00
- 完成时间:2026-07-10 14:50:23 +08:00
- 计划文件:`docs/superpowers/plans/2026-07-10-login-auth-flow-polish.md`
- 页面范围:`login.html``register.html``forgot-password.html`
- 接口范围:登录、注册、忘记密码提交前的 `/captcha/verify`
- 根因记录:`register.html` 仍把注册成功地址写为 `create-genealogy.html`,公共认证脚本也保留同样兜底地址;但创建家谱属于登录后的行为,注册完成应回到登录页。
- 修改文件:`register.html``public/js/auth-pages.js``tests/auth-page-structure.test.js`
- 修改结果:注册页 `data-success-url` 改为 `login.html`,注册提交的兜底跳转也改为 `login.html`;结构测试补充三张认证页验证码场景和 `validToken` 隐藏字段检查。公共 `CaptchaPages` 仍统一用 `payload.track``/captcha/verify` 换取 `validToken`,登录、注册、忘记密码表单提交前共用这一入口。
- 验证结果:先运行 `node tests\auth-page-structure.test.js` 复现注册跳转失败;修复后 `node tests\auth-page-structure.test.js``node tests\auth-pages.test.js``node tests\captcha-pages.test.js``node --check public\js\auth-pages.js``node --check public\js\captcha-pages.js``git diff --check` 通过。`git diff --check` 仅输出 LF/CRLF 换行提示,没有空白错误。
## PC-024 认证页表单校验和结构规范化
- 状态:已完成
- 开始时间:2026-07-10 14:56:08 +08:00
- 完成时间:2026-07-10 15:11:49 +08:00
- 计划文件:`docs/superpowers/plans/2026-07-10-auth-form-validation-normalization.md`
- 页面范围:`login.html``register.html``forgot-password.html`
- 接口范围:登录、短信登录、注册、找回密码提交前的本地校验与验证码取票据
- 结论记录:`form` 标签本身不是落后写法,认证提交页保留语义表单更合适;但 `data-captcha-scene``data-success-url``data-scene-code` 这类业务配置不应散在 HTML 上。
- 修改文件:`login.html``register.html``forgot-password.html``public/js/auth-pages.js``tests/auth-pages.test.js``tests/auth-page-structure.test.js`
- 修改结果:三张认证页表单改用稳定 `id` 作为 JS 挂载点,移除认证业务配置型 `data-*`;登录方式切换改用标准 `aria-controls` 关联表单面板;`auth-pages.js` 新增 `FORM_CONFIG` 统一管理场景码和跳转地址;新增 `validateAuthValues` 统一校验手机号、密码、短信验证码和确认密码。登录、注册、忘记密码提交前仍统一走 `CaptchaPages.ensureToken`,由 `captcha-pages.js``payload.track``/captcha/verify` 换取 `validToken`
- 验证结果:先运行 `node tests\auth-page-structure.test.js``node tests\auth-pages.test.js` 复现失败;修复后 `node tests\auth-page-structure.test.js``node tests\auth-pages.test.js``node tests\captcha-pages.test.js``node --check public\js\auth-pages.js``node --check public\js\captcha-pages.js``git diff --check` 通过。扫描确认三张认证页和 `auth-pages.js``data-auth-form``data-captcha-scene``data-success-url``data-scene-code``data-login-mode` 残留;`git diff --check` 仅输出 LF/CRLF 换行提示,没有空白错误。
- 复核结论:该项只解决 `d.parents is not a function`;用户 2026-07-10 10:59 左右浏览器复测显示 Layui 遮罩出现、`/captcha/challenge` 返回 200,但 TAC 本体未显示,因此真实挂载点问题进入 PC-019 修正。
## PC-019 TAC Layui 实际挂载点修复
- 状态:已完成
- 开始时间:2026-07-10 10:59:52 +08:00
- 完成时间:2026-07-10 11:04:18 +08:00
- 计划文件:`docs/superpowers/plans/2026-07-10-tac-layer-real-mount-fix.md`
- 页面范围:`login.html``register.html``forgot-password.html`
- 接口范围:`/captcha/require``/captcha/challenge``/captcha/verify`
- 根因记录:用户浏览器截图显示 Layui 遮罩已经出现,`/captcha/require``/captcha/challenge` 都返回 `200`,但弹层内没有 TAC 验证窗口。根因是 PC-018 仍返回打开前的临时 DOM 给 `CaptchaConfig.bindEl`,不能保证该节点就是 Layui 弹层内容区里的真实节点。
- 修改文件:`public/js/captcha-pages.js``tests/captcha-pages.test.js``docs/superpowers/plans/2026-07-10-tac-layer-real-mount-fix.md``docs/pc-api-page-integration-tracker-2026-07-09.md`
- 修改结果:`createCaptchaLayer` 现在用字符串 `content` 让 Layui 创建 `<div data-captcha-layer-box="..."></div>`,弹层打开后再通过 `document.querySelector` 获取这个实际节点,并将它作为 TAC 的 `bindEl`。未修改 `public/tac`,未新增验证码 CSS,未传 TAC 视觉配置。
- 验证结果:先运行 `node tests\captcha-pages.test.js` 复现失败;修复后 `node tests\auth-page-structure.test.js``node tests\captcha-pages.test.js``node --check public\js\captcha-pages.js``git diff --check` 通过。`git diff --check` 仅输出既有 CRLF 行尾提示,没有空白错误。
## PC-025 注册、登录到个人资料闭环
- 状态:已完成
- 完成时间:2026-07-11 09:32:35 +08:00
- 规划文件:`docs/superpowers/specs/2026-07-11-auth-to-profile-flow-design.md`
- 页面范围:`register.html``login.html``profile.html``profile-data.html`
- 接口范围:`/genealogy/pc/auth/register``/genealogy/pc/auth/login``/genealogy/pc/auth/login/sms``/genealogy/pc/auth/profile``/genealogy/pc/auth/logout`
- 地址契约:运行时唯一来源为 `config.js`;当前开发和生产地址均为 `http://182.61.18.23:8080`
- 流程结果:注册成功清除 token 并跳转 `login.html`;当前登录响应从 `data.access_token` 取 JWT,且兼容 `token``accessToken``tokenValue` 后跳转 `profile.html`;个人资料无 token 或收到 HTTP/业务 `401` 时清 token 并跳转 `index.html`;确认退出无论接口结果都清 token 并跳转 `index.html`
- 修改文件:`tests/config.test.js``tests/utils.test.js``utils/ApiClient.js``tests/api-client.test.js``public/js/profile-pages.js``public/js/profile-common.js``profile.html``tests/profile-pages.test.js``tests/profile-logout-structure.test.js`
- 验证结果:`node tests/config.test.js``node tests/utils.test.js``node tests/api-client.test.js``node tests/auth-pages.test.js``node tests/profile-pages.test.js``node tests/profile-logout-structure.test.js``node --check utils/ApiClient.js``node --check public/js/profile-pages.js``node --check public/js/profile-common.js` 和全量 Node 测试均通过。
## PC-026 安全页认证入口收敛
- 状态:部分完成
- 完成时间:2026-07-11
- 页面范围:`profile-security.html`
- 接口范围:`/genealogy/pc/auth/login/sms``/genealogy/pc/auth/phone``/genealogy/pc/auth/account/deactivate``/genealogy/pc/auth/logout`
- 契约结果:`login.html` 是密码和短信登录的唯一入口;登录成功跳转 `profile.html`。已登录的账号安全页不得重复发起登录流程。退出登录和账号注销完成后均跳转 `index.html`
- 修改结果:删除安全页遗留的短信登录表单、旧接口说明、请求体构造和提交分支;安全页退出无论接口请求结果都跳转首页,账号注销成功后也跳转首页。
- 后端阻塞:换绑手机仍无法形成完整 PC/H5 流程。`POST /genealogy/pc/auth/sms/code` 必填 `sceneCode``validToken`,但当前未定义换绑手机的 PC/H5 场景码;不得使用未经确认的场景替代。待后端提供场景码后,补充滑动验证、发送短信验证码和换绑提交链路。
- 验证结果:先运行 `node tests/security-pages.test.js`,确认旧安全页短信登录导出仍存在而按预期失败;修复后该测试通过。全量验证待本项后续记录补充。
## PC-027 PC-only 接口契约清理
- 状态:已完成
- 完成时间:2026-07-11
- 契约所有者:`genealogy-pc-openapi.yaml` 是 PC/H5 页面接口的唯一依据;运行时请求由 `utils/ApiClient.js` 统一发起。
- 修改结果:删除旧接口文档和错误对接记录;清除页面、脚本、规划和 PC YAML 中遗留的旧路径、场景码与文档引用;PC YAML 的验证码示例统一使用 `WEB_H5_LOGIN`,注册来源示例为 `PC`
- 登录确认:`login.html` 的短信登录继续经 `public/js/auth-pages.js` 调用 `utils/ApiClient.js`,请求 `POST /genealogy/pc/auth/login/sms`,验证码场景为 `WEB_H5_LOGIN`
- 验证结果:新增 `tests/pc-api-contract.test.js`。该测试确认旧接口文档与错误记录不存在,扫描当前运行代码、PC YAML 和有效规划没有遗留旧契约,并断言短信登录使用 PC 路径和 `WEB_H5_LOGIN`
## PC-028 当前 PC 接口页面覆盖
- 状态:部分完成
- 完成时间:2026-07-11
- 接口清单:`genealogy-pc-openapi.yaml` 当前有 25 个操作;`tests/pc-endpoint-coverage.test.js` 对每项操作断言页面调用或明确后端阻塞原因。
- 已形成页面闭环的 21 项操作:验证码查询/挑战/校验,注册、密码登录、短信登录、短信验证码,资料读取/修改,修改密码、找回密码、注销账号、退出登录,单文件上传、分片初始化/上传/完成,地区下级查询/搜索/路径/详情。
- 文件上传结果:`profile-data.html` 的头像上传不再要求手填 OSS ID。文件不超过 4 MB 时调用单文件上传;超过 4 MB 时依次调用分片初始化、每片上传和完成上传,完成后回填同一 `avatarOssId`,由“保存资料”提交资料接口。
- 地区结果:`profile-data.html` 支持按地区名称或编码搜索;选择结果会查询地区详情和路径,再回填省、市、区县选择器后提交资料接口。
- 后端契约阻塞的 4 项操作:
1. `GET /auth/code`:接口是兼容旧图形验证码,但当前 PC 登录/注册请求体没有可提交的验证码字段。
2. `PUT /genealogy/pc/auth/phone`:发送短信前需要 PC/H5 换绑手机号验证码场景码,当前文档未提供。
3. `POST /genealogy/pc/files/reference`:缺少可由当前 PC 页面创建的业务表名和业务 ID 契约。
4. `DELETE /genealogy/pc/files/reference`:同样缺少业务表名、业务 ID 和业务字段的来源契约。
- 浏览器验收顺序:
1. `register.html`:完成注册,确认跳转 `login.html` 且不保留登录态。
2. `login.html` 密码登录:完成滑块验证和登录,确认跳转 `profile.html`
3. `login.html` 短信登录:完成滑块验证、发送短信和登录,确认跳转 `profile.html`
4. `profile.html`:确认资料成功加载;清空本地 token 后重新进入,确认跳转首页。
5. `profile-data.html`:修改昵称、性别、生日后保存;上传小于等于 4 MB 的头像并保存资料。
6. `profile-data.html`:上传大于 4 MB 的头像,确认网络依次出现 `resumable/init``resumable/chunk``resumable/complete`,再保存资料。
7. `profile-data.html`:搜索地区名称或编码,选择结果,确认省市区回填后保存。
8. `forgot-password.html`:完成验证码、短信码和密码重置。
9. `profile-security.html`:分别测试修改密码、账号注销和退出登录;退出与注销成功后均回首页。
- 验证结果:`node tests/pc-endpoint-coverage.test.js``node tests/upload-pages.test.js``node tests/region-pages.test.js``node tests/profile-pages.test.js``node tests/auth-pages.test.js``node tests/captcha-pages.test.js``node tests/api-client.test.js``node tests/security-pages.test.js` 均通过。
@@ -1,128 +0,0 @@
# Auth Layer Message Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 让登录、注册、忘记密码三页的提示统一使用 layui `layer.msg`,不再出现浏览器原生 alert,并补好认证页提示和 TAC 弹窗样式。
**Architecture:** 页面加载 `public/layui/css/layui.css``public/layui/layui.js``auth-pages.js``captcha-pages.js` 只负责调用提示,不写样式;提示皮肤与 TAC 弹窗样式写入 `public/css/public.css`
**Tech Stack:** 原生 HTML/CSS/JavaScript、layui layer、public/tac、Node 断言测试。
## Global Constraints
- 计划开始时间:2026-07-09 17:14:08 +08:00。
- 只处理登录、注册、忘记密码三页的提示与弹窗样式问题。
- 不修改 `public/tac/js/tac.min.js`
- 不使用 HTML 原生 `alert` 作为用户提示。
- 样式写在 CSS 文件里,JS 只切 class 或调用组件。
- 新增或修改的 HTML、CSS、JS 注释使用中文,代码保持可读。
---
### Task 1: 补充失败测试
**Files:**
- Modify: `tests/auth-page-structure.test.js`
- Create: `tests/auth-message.test.js`
**Interfaces:**
- Consumes: 当前三页 HTML 与认证脚本。
- Produces: 能捕获缺少 layui 和原生 alert 兜底的测试。
- [x] **Step 1: 添加页面结构断言**
检查 `login.html``register.html``forgot-password.html` 必须加载 `public/layui/css/layui.css``public/layui/layui.js`,且 `layui.js``auth-pages.js` 前面。
- [x] **Step 2: 添加提示脚本断言**
检查 `public/js/auth-pages.js``public/js/captcha-pages.js` 不再包含 `root.alert`,并检查 `public/css/public.css` 提供 `.auth-layer-message`
- [x] **Step 3: 验证红灯**
运行:
```powershell
node tests\auth-page-structure.test.js
node tests\auth-message.test.js
```
预期:两个测试均失败,分别提示缺少 layui 和仍存在 `root.alert`
### Task 2: 接入 layui 并替换原生提示
**Files:**
- Modify: `login.html`
- Modify: `register.html`
- Modify: `forgot-password.html`
- Modify: `public/js/auth-pages.js`
- Modify: `public/js/captcha-pages.js`
**Interfaces:**
- Consumes: `layui.layer.msg(message, options)`
- Produces: `showMessage(message)` 统一使用 layer,缺少 layui 时只输出控制台警告,不再弹原生 alert。
- [x] **Step 1: 三页加载 layui 样式和脚本**
在三页 `<head>` 中加入 `public/layui/css/layui.css`,在 `auth-pages.js` 前加入 `public/layui/layui.js`
- [x] **Step 2: 修改认证脚本提示**
`auth-pages.js``showMessage` 改为优先调用 `layui.layer.msg(message, { skin: 'auth-layer-message' })`,没有 layui 时使用 `console.warn`
- [x] **Step 3: 修改验证码脚本提示**
`captcha-pages.js``showMessage` 同步改为同样的 layer 提示策略。
### Task 3: 补齐公共样式
**Files:**
- Modify: `public/css/public.css`
**Interfaces:**
- Consumes: layui layer 生成的 `.auth-layer-message`
- Produces: 认证页统一提示皮肤和移动端更稳的 TAC 弹窗尺寸。
- [x] **Step 1: 添加 layui 消息皮肤**
在公共 CSS 中添加 `.auth-layer-message`,控制圆角、背景、文字和阴影。
- [x] **Step 2: 微调 TAC 弹窗移动端尺寸**
继续使用 `.auth-captcha-modal` 页面级挂载,限制 TAC 宽度不超过视口,避免盖层内容顶到边缘。
### Task 4: 验证并记录完成
**Files:**
- Modify: `docs/pc-api-page-integration-tracker-2026-07-09.md`
**Interfaces:**
- Consumes: Task 1-3 的实现结果。
- Produces: PC-012 完成记录。
- [x] **Step 1: 跑焦点测试**
运行:
```powershell
node tests\auth-page-structure.test.js
node tests\auth-message.test.js
```
预期:全部通过。
- [x] **Step 2: 跑相关测试和语法检查**
运行:
```powershell
node tests\auth-pages.test.js
node tests\captcha-pages.test.js
node --check public\js\auth-pages.js
node --check public\js\captcha-pages.js
```
预期:全部通过。
- [x] **Step 3: 更新追踪记录**
把 PC-012 标记为已完成,写入完成时间、修改文件、验证命令和剩余风险。
@@ -1,761 +0,0 @@
# PC API Replan 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:** 按最新 `genealogy-pc-openapi.yaml` 重新整理 PC/H5 页面接口对接,只接入 `paths` 中真实存在的验证码、PC 认证、PC 文件和行政区划接口,并清除旧 APP 路径作为 PC 合同的风险。
**Architecture:** 根目录 `config.js` 是接口基础地址、客户端 ID、租户、token key 的唯一配置入口;`utils` 只放跨页面公共 JS`public/js/api-client.js` 只暴露 PC YAML `paths` 中存在的接口和受控的“PC 接口未提供”错误。页面业务 JS 继续放在 `public/js`,页面只能调用 PC 已确认方法;PC YAML 未覆盖的页面保留静态/本地展示或显示接口待确认状态。
**Tech Stack:** 静态 HTML、原生 JS、jQuery/Layui、Node 测试脚本、`public/tac` 天爱验证码静态资源。
## Global Constraints
- 接口依据只使用 `genealogy-pc-openapi.yaml``paths`
- 开发环境接口基础地址固定为 `https://test-genealogy-api.ddxcjp.cn`
- PC 默认 `clientid` 使用用户最新截图确认值:`ced7e5f0498645c6ec642dcf450b036f`,客户端 Key 为 `web_pc`
- 登录后请求使用 `Authorization` header,PC 认证和文件请求同时携带 `clientid` header。
- 样式写入 CSS 文件,不用 JS 注入样式。
- 新增或修改的 HTML、JS、CSS 注释使用中文。
- `utils` 只放跨页面公共 JS;页面业务 JS 继续放 `public/js`
- PC YAML 没有明确接口的页面不套用 APP 路径,不把 schema/requestBodies 当成可调用接口。
- 验证码只使用后台已配置的 PC/H5 场景;当前明确场景为 `WEB_H5_LOGIN``WEB_H5_REGISTER``WEB_H5_FORGOT_PASSWORD`
- 换绑手机、注销账号的 PC/H5 验证码场景未确认,不套用 `APP_*` 场景。
---
## File Structure
- Create or Modify: `config.js`
- 唯一负责环境切换、开发接口地址、PC clientid、tenantId、token key、token header name。
- Create or Modify: `utils/StorageUtil.js`
- 负责 localStorage 安全读写。
- Create or Modify: `utils/FormUtil.js`
- 负责表单读取、空值清理、数字转换、布尔转换。
- Create or Modify: `utils/RequestUtil.js`
- 负责 URL 拼接、header 合并、FormData 判断、JSON 解包、业务错误对象。
- Modify: `public/js/api-client.js`
- 只把 PC YAML `paths` 中存在的接口作为真实请求方法;旧 APP 路径不得作为 fallback。
- Modify: `public/js/auth-pages.js`
- 登录使用 `WEB_H5_LOGIN`;注册使用 `WEB_H5_REGISTER`;找回密码使用 `WEB_H5_FORGOT_PASSWORD`
- Modify: `public/js/captcha-pages.js`
-`/captcha/require``/captcha/challenge``/captcha/verify` 组织 TAC 调用。
- Modify: `public/js/profile-pages.js`
- 用户资料提交字段以 `ProfileUpdateBody` schema 为准:`provinceCode/cityCode/districtCode`
- Modify: `public/js/security-pages.js`
- 修改密码、换绑手机、注销账号只调用 PC 认证接口;涉及验证码的动作保留 PC/H5 场景待确认记录。
- Modify: `public/js/upload-pages.js`
- 上传、分片上传、文件引用只调用 `/genealogy/pc/files/*`
- Modify: `public/js/region-pages.js`
- 地区选择只调用 `/genealogy/region/*`
- Modify: related HTML files
- API 页面按顺序加载 `config.js``utils/*.js``public/js/api-client.js`、页面业务脚本。
- Modify: `tests/*.test.js`
- 测试只验证 PC YAML 已确认接口;旧 APP 路径测试改为“不可作为 PC 合同”。
- Modify: `docs/pc-api-page-integration-tracker-2026-07-09.md`
- 每项开始/完成都回填时间、文件、接口、验证结果。
---
### Task 1: 配置入口与公共工具合同
**Files:**
- Create or Modify: `config.js`
- Create or Modify: `utils/StorageUtil.js`
- Create or Modify: `utils/FormUtil.js`
- Create or Modify: `utils/RequestUtil.js`
- Test: `tests/config.test.js`
- Test: `tests/utils.test.js`
**Interfaces:**
- Produces: `GenealogyConfig.getConfig(): { env, apiBaseUrl, clientId, tenantId, tokenKey, tokenHeaderName }`
- Produces: `GenealogyConfig.getApiBaseUrl(): string`
- Produces: `GenealogyConfig.getClientId(): string`
- Produces: `GenealogyConfig.getTenantId(): string`
- Produces: `GenealogyConfig.getTokenKey(): string`
- Produces: `StorageUtil.read(storage, key): string|null`
- Produces: `StorageUtil.write(storage, key, value): void`
- Produces: `StorageUtil.remove(storage, key): void`
- Produces: `FormUtil.trimOrUndefined(value): string|undefined`
- Produces: `FormUtil.toNumberOrUndefined(value): number|undefined`
- Produces: `FormUtil.toBoolean(value): boolean`
- Produces: `FormUtil.getFormValues(form): object`
- Produces: `RequestUtil.createRequester(options)(method, path, requestOptions): Promise<any>`
- [ ] **Step 1: Write config test**
Create or replace `tests/config.test.js` with:
```js
const assert = require('assert');
const configFactory = require('../config.js');
const store = {};
const config = configFactory({
localStorage: {
getItem: (key) => Object.prototype.hasOwnProperty.call(store, key) ? store[key] : null,
setItem: (key, value) => { store[key] = String(value); }
}
});
assert.strictEqual(config.getApiBaseUrl(), 'https://test-genealogy-api.ddxcjp.cn');
assert.strictEqual(config.getClientId(), 'ced7e5f0498645c6ec642dcf450b036f');
assert.strictEqual(config.getTenantId(), '000000');
assert.strictEqual(config.getTokenKey(), 'genealogy_auth_token');
assert.strictEqual(config.getConfig().tokenHeaderName, 'Authorization');
store.genealogy_api_base_url = 'https://custom.example.test';
assert.strictEqual(config.getApiBaseUrl(), 'https://custom.example.test');
console.log('config tests passed');
```
- [ ] **Step 2: Run config test**
Run: `node tests\config.test.js`
Expected before implementation: FAIL if `config.js` is absent or lacks these methods.
Expected after implementation: PASS with `config tests passed`.
- [ ] **Step 3: Implement `config.js`**
Use this implementation:
```js
(function (root, factory) {
// 全局配置同时支持浏览器页面和 Node 测试。
if (typeof module === 'object' && module.exports) {
module.exports = factory;
return;
}
root.GenealogyConfig = factory(root);
})(typeof globalThis !== 'undefined' ? globalThis : window, function (root) {
'use strict';
var DEFAULT_ENV = 'development';
var API_BASE_URLS = {
development: 'https://test-genealogy-api.ddxcjp.cn',
production: ''
};
var CLIENT_ID = 'ced7e5f0498645c6ec642dcf450b036f';
var TENANT_ID = '000000';
var TOKEN_KEY = 'genealogy_auth_token';
var TOKEN_HEADER_NAME = 'Authorization';
var ENV_KEY = 'genealogy_env';
var BASE_URL_KEY = 'genealogy_api_base_url';
function readStorage(key) {
try {
return root.localStorage && root.localStorage.getItem(key);
} catch (error) {
return null;
}
}
function getEnv() {
return readStorage(ENV_KEY) || DEFAULT_ENV;
}
function getApiBaseUrl() {
return readStorage(BASE_URL_KEY) || API_BASE_URLS[getEnv()] || API_BASE_URLS.development;
}
function getConfig() {
return {
env: getEnv(),
apiBaseUrl: getApiBaseUrl(),
clientId: CLIENT_ID,
tenantId: TENANT_ID,
tokenKey: TOKEN_KEY,
tokenHeaderName: TOKEN_HEADER_NAME
};
}
return {
getConfig: getConfig,
getApiBaseUrl: getApiBaseUrl,
getClientId: function () { return CLIENT_ID; },
getTenantId: function () { return TENANT_ID; },
getTokenKey: function () { return TOKEN_KEY; },
getTokenHeaderName: function () { return TOKEN_HEADER_NAME; }
};
});
```
- [ ] **Step 4: Write utilities test**
Create or replace `tests/utils.test.js` with focused utility assertions:
```js
const assert = require('assert');
const StorageUtil = require('../utils/StorageUtil.js');
const FormUtil = require('../utils/FormUtil.js');
const RequestUtil = require('../utils/RequestUtil.js');
assert.strictEqual(FormUtil.trimOrUndefined(' 张三 '), '张三');
assert.strictEqual(FormUtil.trimOrUndefined(' '), undefined);
assert.strictEqual(FormUtil.toNumberOrUndefined('12'), 12);
assert.strictEqual(FormUtil.toNumberOrUndefined(''), undefined);
assert.strictEqual(FormUtil.toBoolean('on'), true);
assert.strictEqual(FormUtil.toBoolean('0'), false);
const store = {};
const storage = {
getItem: (key) => Object.prototype.hasOwnProperty.call(store, key) ? store[key] : null,
setItem: (key, value) => { store[key] = String(value); },
removeItem: (key) => { delete store[key]; }
};
StorageUtil.write(storage, 'token', 'abc');
assert.strictEqual(StorageUtil.read(storage, 'token'), 'abc');
StorageUtil.remove(storage, 'token');
assert.strictEqual(StorageUtil.read(storage, 'token'), null);
const calls = [];
const requester = RequestUtil.createRequester({
baseUrl: 'https://test-genealogy-api.ddxcjp.cn',
clientId: 'pc-client',
tokenHeaderName: 'Authorization',
getToken: () => 'token-x',
fetchImpl: async (url, options) => {
calls.push({ url, options });
return {
ok: true,
status: 200,
json: async () => ({ code: 200, data: { ok: true } })
};
}
});
requester('GET', '/genealogy/pc/auth/profile', {
query: { keyword: '汤 氏' }
}).then((data) => {
assert.deepStrictEqual(data, { ok: true });
assert.strictEqual(calls[0].url, 'https://test-genealogy-api.ddxcjp.cn/genealogy/pc/auth/profile?keyword=%E6%B1%A4+%E6%B0%8F');
assert.strictEqual(calls[0].options.headers.clientid, 'pc-client');
assert.strictEqual(calls[0].options.headers.Authorization, 'Bearer token-x');
console.log('utils tests passed');
});
```
- [ ] **Step 5: Implement utility files**
Implement the utilities with Chinese comments around storage try/catch, FormData detection, JSON response parsing and `code !== 200` error creation. `RequestUtil.createRequester` must omit `Authorization` when `requestOptions.auth === false`.
- [ ] **Step 6: Run utility tests**
Run: `node tests\utils.test.js`
Expected: PASS with `utils tests passed`.
---
### Task 2: API 客户端收紧到 PC YAML `paths`
**Files:**
- Modify: `public/js/api-client.js`
- Test: `tests/api-client.test.js`
**Interfaces:**
- Consumes: `GenealogyConfig`
- Consumes: `StorageUtil`
- Consumes: `RequestUtil.createRequester`
- Produces: `GenealogyApi.createClient(options)`
- Produces: `GenealogyApi.defaultClient`
- Produces PC methods:
- `login(body)`, `register(body)`, `loginBySms(body)`, `sendSmsCode(body)`
- `getProfile()`, `updateProfile(body)`, `changePassword(body)`, `resetPassword(body)`, `changePhone(body)`, `deactivateAccount(body)`, `logout()`
- `uploadFile(formData)`, `initResumableUpload(body)`, `uploadChunk(formData)`, `completeResumableUpload(body)`, `bindFileReference(body)`, `releaseFileReference(query)`
- `getRegionChildren(parentCode)`, `getRegionPath(regionCode)`, `searchRegions(query)`, `getRegion(regionCode)`
- `captchaRequire(query)`, `captchaChallenge(body)`, `captchaVerify(body)`, `legacyCaptcha()`
- Produces unsupported method behavior: methods for PC YAML 未覆盖业务 throw `Error` with `code === 'PC_API_NOT_AVAILABLE'` and no network request.
- [ ] **Step 1: Replace API client test**
Rewrite `tests/api-client.test.js` so it verifies only PC-confirmed paths and unsupported APP-era methods:
```js
const assert = require('assert');
const GenealogyApi = require('../public/js/api-client.js');
const calls = [];
const client = GenealogyApi.createClient({
baseUrl: 'https://test-genealogy-api.ddxcjp.cn',
clientId: 'client-x',
tenantId: 'tenant-x',
tokenKey: 'token-key',
tokenHeaderName: 'Authorization',
storage: { getItem: () => 'abc-token' },
fetchImpl: async (url, options) => {
calls.push({ url, options });
return {
ok: true,
status: 200,
json: async () => ({ code: 200, data: { url } })
};
}
});
(async () => {
await client.login({ tenantId: 'tenant-x', phone: '13800000000', password: 'md5' });
assert.strictEqual(calls[0].url, 'https://test-genealogy-api.ddxcjp.cn/genealogy/pc/auth/login');
assert.strictEqual(calls[0].options.headers.clientid, 'client-x');
assert.strictEqual(calls[0].options.headers.Authorization, undefined);
await client.getProfile();
assert.strictEqual(calls[1].url, 'https://test-genealogy-api.ddxcjp.cn/genealogy/pc/auth/profile');
assert.strictEqual(calls[1].options.headers.Authorization, 'Bearer abc-token');
await client.updateProfile({ nickName: '张三', provinceCode: '510000', cityCode: '510100', districtCode: '510104' });
assert.strictEqual(calls[2].url, 'https://test-genealogy-api.ddxcjp.cn/genealogy/pc/auth/profile');
assert.strictEqual(calls[2].options.method, 'PUT');
await client.uploadFile(new FormData());
assert.strictEqual(calls[3].url, 'https://test-genealogy-api.ddxcjp.cn/genealogy/pc/files/upload');
await client.getRegionChildren('51');
assert.strictEqual(calls[4].url, 'https://test-genealogy-api.ddxcjp.cn/genealogy/region/children?parentCode=51');
await client.captchaRequire({ sceneCode: 'WEB_H5_LOGIN', subject: '13800000000' });
assert.strictEqual(calls[5].url, 'https://test-genealogy-api.ddxcjp.cn/captcha/require?tenantId=tenant-x&clientId=client-x&sceneCode=WEB_H5_LOGIN&subject=13800000000');
assert.strictEqual(calls[5].options.headers.Authorization, undefined);
assert.throws(
() => client.listGenealogies(),
(error) => error.code === 'PC_API_NOT_AVAILABLE'
);
assert.strictEqual(calls.length, 6);
console.log('api-client tests passed');
})();
```
- [ ] **Step 2: Run API client test**
Run: `node tests\api-client.test.js`
Expected before implementation: FAIL while old APP paths or old scene tests remain.
Expected after implementation: PASS with `api-client tests passed`.
- [ ] **Step 3: Implement confirmed PC methods**
In `public/js/api-client.js`, remove APP auth/files paths. Map methods exactly:
```js
login: function (body) {
return request('POST', '/genealogy/pc/auth/login', { body: body, auth: false });
},
register: function (body) {
return request('POST', '/genealogy/pc/auth/register', { body: body, auth: false });
},
sendSmsCode: function (body) {
return request('POST', '/genealogy/pc/auth/sms/code', { body: body, auth: false });
},
loginBySms: function (body) {
return request('POST', '/genealogy/pc/auth/login/sms', { body: body, auth: false });
},
getProfile: function () {
return request('GET', '/genealogy/pc/auth/profile');
},
updateProfile: function (body) {
return request('PUT', '/genealogy/pc/auth/profile', { body: body });
}
```
Add the remaining PC auth/files/region/captcha methods with the same direct mapping from YAML `paths`.
- [ ] **Step 4: Implement unsupported method guard**
For old business methods whose PC paths do not exist, keep the method name only if current pages call it, but route it to:
```js
function createUnavailableMethod(name) {
return function () {
var error = new Error('PC 接口文档未提供该业务接口:' + name);
error.code = 'PC_API_NOT_AVAILABLE';
throw error;
};
}
```
Use a Chinese comment above the mapping: `// PC YAML 未提供这些业务 paths,保留方法名只用于阻止旧 APP 路径继续发请求。`
- [ ] **Step 5: Verify the PC auth/files contract**
Run: `node tests\pc-api-contract.test.js`
Expected: PC 请求契约扫描通过。
- [ ] **Step 6: Run API client test again**
Run: `node tests\api-client.test.js`
Expected: PASS with `api-client tests passed`.
---
### Task 3: 登录验证码与 `public/tac` 调用
**Files:**
- Modify: `public/js/auth-pages.js`
- Modify: `public/js/captcha-pages.js`
- Modify: `login.html`
- Modify: `register.html`
- Modify: `forgot-password.html`
- Test: `tests/auth-pages.test.js`
- Test: `tests/captcha-pages.test.js`
**Interfaces:**
- Consumes: `public/tac/css/tac.css`
- Consumes: `public/tac/js/tac.min.js`
- Consumes: `GenealogyApi.defaultClient.captchaRequire/challenge/verify`
- Produces: `AuthPages.getCaptchaScene('login') === 'WEB_H5_LOGIN'`
- Produces: `AuthPages.getCaptchaScene('register') === 'WEB_H5_REGISTER'`
- Produces: `AuthPages.getCaptchaScene('password-reset') === 'WEB_H5_FORGOT_PASSWORD'`
- Produces: `CaptchaPages.buildChallengeBody(options, api)`
- Produces: `CaptchaPages.buildVerifyBody(options, api)`
- Produces: `CaptchaPages.ensureToken(form, options)`
- [ ] **Step 1: Replace auth scene tests**
In `tests/auth-pages.test.js`, require these expectations:
```js
assert.strictEqual(AuthPages.getCaptchaScene('login'), 'WEB_H5_LOGIN');
assert.strictEqual(AuthPages.getCaptchaScene('register'), 'WEB_H5_REGISTER');
assert.strictEqual(AuthPages.getCaptchaScene('password-reset'), 'WEB_H5_FORGOT_PASSWORD');
```
- [ ] **Step 2: Replace captcha tests**
In `tests/captcha-pages.test.js`, all challenge/verify body tests must use `WEB_H5_LOGIN`:
```js
assert.deepStrictEqual(CaptchaPages.buildChallengeBody({
sceneCode: 'WEB_H5_LOGIN',
subject: '13800000000'
}, fakeApi), {
tenantId: 'tenant-x',
clientId: 'client-x',
sceneCode: 'WEB_H5_LOGIN',
subject: '13800000000'
});
```
- [ ] **Step 3: Run auth and captcha tests**
Run:
```powershell
node tests\auth-pages.test.js
node tests\captcha-pages.test.js
```
Expected before implementation: FAIL if `PC_LOGIN` or `PC_REGISTER` remains.
Expected after implementation: PASS.
- [ ] **Step 4: Implement scene mapping**
In `public/js/auth-pages.js`:
```js
function getCaptchaScene(formType) {
// PC/H5 认证场景来自后台验证码配置,客户端 Key 为 web_pc。
var map = {
login: 'WEB_H5_LOGIN',
register: 'WEB_H5_REGISTER',
'password-reset': 'WEB_H5_FORGOT_PASSWORD'
};
return map[formType] || '';
}
```
If `sceneCode` is empty, `ensureCaptcha` must return `true` without calling `/captcha/require` and must not fabricate `validToken`.
- [ ] **Step 5: Verify TAC assets in auth pages**
Run:
```powershell
Select-String -Path login.html,register.html,forgot-password.html -Pattern 'public/tac/css/tac.css|public/tac/js/tac.min.js|public/js/captcha-pages.js|name="validToken"|data-captcha-box'
```
Expected:
- `login.html` has TAC CSS, TAC JS, `captcha-pages.js`, hidden `validToken`, and `[data-captcha-box]`.
- `register.html` and `forgot-password.html` may keep TAC assets and hidden fields, but no PC/H5 scene is configured until backend confirms scene code.
---
### Task 4: 认证与资料页面只接 PC auth schema
**Files:**
- Modify: `public/js/auth-pages.js`
- Modify: `public/js/profile-pages.js`
- Modify: `public/js/security-pages.js`
- Modify: `login.html`
- Modify: `register.html`
- Modify: `forgot-password.html`
- Modify: `profile.html`
- Modify: `profile-data.html`
- Modify: `profile-security.html`
- Test: `tests/auth-pages.test.js`
- Test: `tests/profile-pages.test.js`
- Test: `tests/security-pages.test.js`
**Interfaces:**
- Consumes: `GenealogyApi.defaultClient`
- Produces request bodies:
- `PasswordLoginBody`: `grantType/tenantId/phone/password/validToken`
- `PasswordRegisterBody`: `grantType/tenantId/phone/password/nickName/registerSource/validToken`
- `PasswordResetBody`: `tenantId/phone/smsCode/newPassword/validToken`
- `ProfileUpdateBody`: `nickName/avatarOssId/sex/birthday/provinceCode/cityCode/districtCode`
- [ ] **Step 1: Update body-builder tests**
`tests/auth-pages.test.js` must assert `registerSource: 'PC'` for register body and no `APP_*` scene in any body.
`tests/profile-pages.test.js` must assert profile update uses `provinceCode/cityCode/districtCode`, not `regionCode/addressDetail`.
- [ ] **Step 2: Run focused tests**
Run:
```powershell
node tests\auth-pages.test.js
node tests\profile-pages.test.js
node tests\security-pages.test.js
```
Expected before implementation: FAIL where old scene/body fields remain.
Expected after implementation: PASS.
- [ ] **Step 3: Implement auth body builders**
Use PC YAML schema fields only. For register:
```js
function buildRegisterBody(values, config) {
var body = {
grantType: 'password',
tenantId: config.tenantId,
phone: values.phone,
password: values.password,
registerSource: 'PC'
};
if (values.nickName) body.nickName = values.nickName;
if (values.validToken) body.validToken = values.validToken;
return body;
}
```
- [ ] **Step 4: Implement profile body builder**
Use schema fields:
```js
function buildProfileUpdateBody(values) {
return removeEmpty({
nickName: values.nickName,
avatarOssId: toNumberOrUndefined(values.avatarOssId),
sex: values.sex,
birthday: values.birthday,
provinceCode: values.provinceCode,
cityCode: values.cityCode,
districtCode: values.districtCode
});
}
```
- [ ] **Step 5: Verify page script order**
Run:
```powershell
Select-String -Path login.html,register.html,forgot-password.html,profile.html,profile-data.html,profile-security.html -Pattern 'config.js|utils/StorageUtil.js|utils/FormUtil.js|utils/RequestUtil.js|public/js/api-client.js'
```
Expected: every page that uses `GenealogyApi` loads config and utils before `api-client.js`.
---
### Task 5: 文件上传和文件引用只切 PC files
**Files:**
- Modify: `public/js/upload-pages.js`
- Modify: upload-related page scripts that call `GenealogyApi.defaultClient.uploadFile`
- Test: `tests/upload-pages.test.js`
- Test: `tests/api-client.test.js`
**Interfaces:**
- Consumes: `GenealogyApi.defaultClient.uploadFile(formData)`
- Consumes: `GenealogyApi.defaultClient.initResumableUpload(body)`
- Consumes: `GenealogyApi.defaultClient.uploadChunk(formData)`
- Consumes: `GenealogyApi.defaultClient.completeResumableUpload(body)`
- Consumes: `GenealogyApi.defaultClient.bindFileReference(body)`
- Consumes: `GenealogyApi.defaultClient.releaseFileReference(query)`
- [ ] **Step 1: Add upload tests**
`tests/upload-pages.test.js` must verify:
- single upload calls `/genealogy/pc/files/upload`
- resumable init calls `/genealogy/pc/files/resumable/init`
- file reference body requires `bizType/bizTable/bizId/bizField`
- [ ] **Step 2: Run upload tests**
Run: `node tests\upload-pages.test.js`
Expected before implementation: FAIL if old APP file paths remain.
Expected after implementation: PASS.
- [ ] **Step 3: Implement upload mapping**
Only map file capability. Do not submit article/feed/album/ceremony/growth/memo business forms unless PC YAML provides their business endpoints.
- [ ] **Step 4: Verify the PC file contract**
Run: `node tests\pc-api-contract.test.js`
Expected: PC 请求契约扫描通过。
---
### Task 6: 行政区划接口和资料地区字段
**Files:**
- Modify: `public/js/region-pages.js`
- Modify: `public/js/profile-pages.js`
- Test: `tests/region-pages.test.js`
- Test: `tests/profile-pages.test.js`
**Interfaces:**
- Consumes: `GET /genealogy/region/children`
- Consumes: `GET /genealogy/region/path/{regionCode}`
- Consumes: `GET /genealogy/region/search`
- Consumes: `GET /genealogy/region/{regionCode}`
- Produces profile fields: `provinceCode/cityCode/districtCode`
- [ ] **Step 1: Add region tests**
`tests/region-pages.test.js` must assert:
- empty parent loads province list with no required parent code.
- search requires `keyword`.
- selected province/city/district writes `provinceCode/cityCode/districtCode` to the form.
- [ ] **Step 2: Run region tests**
Run: `node tests\region-pages.test.js`
Expected before implementation: FAIL if form still expects `regionCode/addressDetail`.
Expected after implementation: PASS.
- [ ] **Step 3: Implement region mapping**
Use `GenealogyApi.defaultClient.getRegionChildren`, `getRegionPath`, `searchRegions`, `getRegion`. Do not create genealogy submit behavior here.
---
### Task 7: PC YAML 未覆盖页面降级为接口待确认
**Files:**
- Modify: page scripts that currently call APP-era business methods:
- `public/js/genealogy-pages.js`
- `public/js/join-apply-pages.js`
- `public/js/member-admin-pages.js`
- `public/js/article-pages.js`
- `public/js/feed-pages.js`
- `public/js/album-pages.js`
- `public/js/ceremony-pages.js`
- `public/js/growth-pages.js`
- `public/js/memo-pages.js`
- `public/js/generation-pages.js`
- `public/js/lineage-pages.js`
- `public/js/notification-pages.js`
- `public/js/help-pages.js`
- `public/js/promotion-pages.js`
- `public/js/feedback-pages.js`
- `public/js/vip-pages.js`
- Test: `tests/unsupported-pages.test.js`
**Interfaces:**
- Consumes: `PC_API_NOT_AVAILABLE` errors from `GenealogyApi`
- Produces: visible or console-safe page state that says PC interface is not configured, without issuing APP requests.
- [ ] **Step 1: Add unsupported-page smoke test**
Create `tests/unsupported-pages.test.js` to load each page script in Node with a fake client method that throws `{ code: 'PC_API_NOT_AVAILABLE' }`. Assert each script handles the error without rethrowing from its init function.
- [ ] **Step 2: Run unsupported-page test**
Run: `node tests\unsupported-pages.test.js`
Expected before implementation: FAIL for scripts that assume APP methods are valid.
Expected after implementation: PASS.
- [ ] **Step 3: Implement shared page fallback**
In each affected page script, catch `PC_API_NOT_AVAILABLE` and render a small existing-style empty state. Add a Chinese comment: `// PC YAML 未提供该业务接口,页面先展示接口待确认状态。`
- [ ] **Step 4: Verify no obsolete business path is emitted from the API client**
Run: `node tests\pc-api-contract.test.js`
Expected: PC 请求契约扫描通过。
---
### Task 8: 追踪表和执行记录
**Files:**
- Modify: `docs/pc-api-page-integration-tracker-2026-07-09.md`
**Interfaces:**
- Consumes: Task 1-7 verification results.
- Produces: updated status rows with exact timestamps, changed files, interface paths and verification commands.
- [ ] **Step 1: Mark task start before edits**
Before executing each PC task, update the row status from `待开始` to `进行中` with exact time from:
```powershell
Get-Date -Format "yyyy-MM-dd HH:mm:ss zzz"
```
- [ ] **Step 2: Mark task completion after verification**
After the task's verification commands pass, update status to `已完成` and record:
- completion time
- modified files
- interface paths
- verification commands
- verification result
- [ ] **Step 3: Keep missing interfaces in coverage matrix**
Any page whose main business path is absent from `genealogy-pc-openapi.yaml` remains in `PC YAML 未覆盖` or `接口待确认` and must not be promoted to full integration.
---
## Verification Gate
Run these before marking the replan execution complete:
```powershell
node tests\config.test.js
node tests\utils.test.js
node tests\api-client.test.js
node tests\auth-pages.test.js
node tests\captcha-pages.test.js
node tests\profile-pages.test.js
node tests\security-pages.test.js
node tests\upload-pages.test.js
node tests\region-pages.test.js
node tests\unsupported-pages.test.js
Get-ChildItem -Path public\js -Filter *.js | Where-Object { $_.Name -notin @('jquery360.js','jquery.min.js') } | Sort-Object Name | ForEach-Object { node --check $_.FullName }
node tests\pc-api-contract.test.js
git diff --check
```
Expected:
- All listed Node tests pass.
- Business JS syntax check passes.
- PC 请求契约扫描通过。
- 认证和验证码代码/tests 不含过期场景默认值。
- `git diff --check` passes, or if this directory is not a Git repository, record the exact output.
## Self-Review
- Spec coverage: this plan covers latest PC YAML `paths`, test domain, config.js, utils/public-js separation, CSS/no style injection, Chinese comments, TAC login scene, profile schema mismatch, and PC-missing business interfaces.
- Placeholder scan: no implementation task asks the worker to invent missing PC endpoints or use APP paths as compatibility.
- Type consistency: `GenealogyConfig`, `StorageUtil`, `FormUtil`, `RequestUtil`, `GenealogyApi.createClient`, `AuthPages.getCaptchaScene`, and `CaptchaPages.ensureToken` names are consistent across tasks.
@@ -1,127 +0,0 @@
# PC Auth Three Pages 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:**`genealogy-pc-openapi.yaml` 和 Apifox 截图,把登录、注册、忘记密码三页与 PC 认证接口、验证中心接口对齐。
**Architecture:** 根目录 `config.js` 继续作为环境、`clientid``tenantId` 的唯一配置入口;`utils` 继续存放跨页面公共工具;页面业务逻辑集中在 `public/js/auth-pages.js`,接口调用集中在 `public/js/api-client.js`,验证码适配集中在 `public/js/captcha-pages.js`。页面差异样式只写入对应 CSS 文件。
**Tech Stack:** 原生 HTML/CSS/JavaScript、`public/tac` 滑动验证、Node 断言测试。
## Global Constraints
- 接口依据只使用 `genealogy-pc-openapi.yaml` 与用户补充的 Apifox 截图。
- 开发环境接口地址为 `https://test-genealogy-api.ddxcjp.cn`
- PC `clientid``ced7e5f0498645c6ec642dcf450b036f`,租户 ID 为 `000000`
- 登录验证码场景为 `WEB_H5_LOGIN`,注册验证码场景为 `WEB_H5_REGISTER`,忘记密码验证码场景为 `WEB_H5_FORGOT_PASSWORD`
- 不在 JS 中注入样式;页面差异样式写入对应 CSS 文件,公共样式写入 `public/css/public.css`
- 新增或修改的 HTML、CSS、JS 注释使用中文,代码保持可读,不压缩。
---
### Task 1: 记录专项计划
**Files:**
- Modify: `docs/pc-api-page-integration-tracker-2026-07-09.md`
- Create: `docs/superpowers/plans/2026-07-09-pc-auth-three-pages.md`
**Interfaces:**
- Consumes: 用户确认“可以”先做登录、注册、忘记密码三页。
- Produces: PC-009 专项计划记录。
- [x] **Step 1: 写入本计划**
创建本计划,明确页面、接口、验证码场景、样式和验证约束。
- [x] **Step 2: 写入追踪记录**
在追踪文档中新增 `PC-009`,状态为“进行中”,开始时间为 `2026-07-09 16:32:17 +08:00`
### Task 2: 登录页补齐短信登录
**Files:**
- Modify: `login.html`
- Modify: `public/css/login.css`
- Modify: `public/js/auth-pages.js`
- Modify: `tests/auth-pages.test.js`
- Modify: `tests/api-client.test.js`
**Interfaces:**
- Consumes: `ApiClient.login(body)``ApiClient.loginBySms(body)``ApiClient.sendSmsCode(body)``CaptchaPages.ensureToken(form, options)`
- Produces: 登录页同时支持密码登录和短信登录。
- [x] **Step 1: 页面增加登录方式切换**
`login.html` 的登录表单内增加模式切换按钮,密码登录区域保留 `phone/password/validToken`,短信登录区域增加 `phone/smsCode/validToken` 和发送验证码按钮。
- [x] **Step 2: CSS 写入登录页专属样式**
`public/css/login.css` 增加模式切换、隐藏区域、验证码行样式,不在 JS 中写样式。
- [x] **Step 3: JS 支持短信登录提交**
`public/js/auth-pages.js` 增加 `buildSmsLoginBody(values)`,提交时按当前模式调用 `/genealogy/pc/auth/login``/genealogy/pc/auth/login/sms`
- [x] **Step 4: 发送短信复用场景码**
登录页短信验证码按钮使用 `data-scene-code="WEB_H5_LOGIN"`,忘记密码页继续使用 `WEB_H5_FORGOT_PASSWORD`
- [x] **Step 5: 更新测试**
`tests/auth-pages.test.js` 增加短信登录 body 断言;`tests/api-client.test.js` 增加 `loginBySms``sendSmsCode` 路径/入参断言。
### Task 3: 注册和忘记密码复核
**Files:**
- Verify: `register.html`
- Verify: `forgot-password.html`
- Verify: `public/js/auth-pages.js`
**Interfaces:**
- Consumes: `/genealogy/pc/auth/register``/genealogy/pc/auth/sms/code``/genealogy/pc/auth/password/reset`
- Produces: 注册和忘记密码页面确认继续按 PC 字段提交。
- [x] **Step 1: 注册页字段复核**
确认注册页只提交 `phone/password/nickName/validToken``grantType/tenantId/clientId/registerSource``api-client.js` 统一补齐。
- [x] **Step 2: 忘记密码页字段复核**
确认忘记密码页提交 `tenantId/phone/smsCode/newPassword/validToken`,不再出现邮箱找回文案。
### Task 4: 专项验证和完成记录
**Files:**
- Modify: `docs/pc-api-page-integration-tracker-2026-07-09.md`
**Interfaces:**
- Consumes: Task 2 和 Task 3 的实现结果。
- Produces: PC-009 完成记录和验证结果。
- [x] **Step 1: 运行测试**
运行:
```powershell
node tests\auth-pages.test.js
node tests\api-client.test.js
node tests\captcha-pages.test.js
```
期望全部输出 `passed`
- [x] **Step 2: 运行语法检查**
运行:
```powershell
node --check public\js\auth-pages.js
node --check public\js\api-client.js
node --check public\js\captcha-pages.js
```
期望无输出且退出码为 0。
- [x] **Step 3: 更新追踪文档**
`PC-009` 状态改为“已完成”,写入完成时间、修改文件、接口路径、验证码场景和验证结果。
@@ -1,95 +0,0 @@
# TAC Captcha Auth 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:**`public/tac` 滑动验证接入登录、注册、忘记密码三个认证页面,并按 `genealogy-pc-openapi.yaml` 的验证中心接口换取 `validToken` 后再提交业务接口。
**Architecture:** 新增 `public/js/captcha-pages.js` 作为 TAC 与验证中心接口之间的适配层;`auth-pages.js` 在提交登录、注册、发送短信、重置密码前调用验证码适配层。API 基础地址、clientId、tenantId 继续由 `api-client.js` 统一提供。
**Tech Stack:** 静态 HTML、原生 JS、现有 `public/tac/js/tac.min.js`、现有 `public/tac/css/tac.css`、Node 单元测试。
## Global Constraints
- 严格依据 `genealogy-pc-openapi.yaml``/captcha/require``/captcha/challenge``/captcha/verify` 字段接入。
- 业务 JS 不压缩,新增注释使用中文。
- 自有样式写入 CSS 文件,不通过自有 JS 注入样式。
- 第三方 `tac.min.js` 保持原样,不重写其内部样式和 DOM 生成逻辑。
- 登录、注册、忘记密码三页都必须有隐藏 `validToken` 字段和验证码挂载容器。
---
### Task 1: API Client Captcha Helpers
**Files:**
- Modify: `public/js/api-client.js`
- Modify: `tests/api-client.test.js`
**Interfaces:**
- Produces: `client.buildApiUrl(path, query)`
- Produces: `client.captchaRequirement({ sceneCode, subject })`
- [ ] **Step 1: Write failing tests** for captcha requirement URL, query, and public URL building.
- [ ] **Step 2: Run tests** and confirm missing methods fail.
- [ ] **Step 3: Implement minimal API client helpers**.
- [ ] **Step 4: Run tests** and confirm pass.
### Task 2: TAC Adapter Module
**Files:**
- Create: `public/js/captcha-pages.js`
- Create: `tests/captcha-pages.test.js`
**Interfaces:**
- Produces: `CaptchaPages.buildChallengeBody(options, api)`
- Produces: `CaptchaPages.adaptChallengeResponse(response)`
- Produces: `CaptchaPages.buildVerifyBody(requestData, context, api)`
- Produces: `CaptchaPages.extractValidToken(response)`
- Produces: `CaptchaPages.ensureToken(form, options)`
- [ ] **Step 1: Write failing tests** for OpenAPI-to-TAC mapping and validToken extraction.
- [ ] **Step 2: Run tests** and confirm module missing fails.
- [ ] **Step 3: Implement adapter with Chinese comments**.
- [ ] **Step 4: Run tests** and confirm pass.
### Task 3: Auth Flow Integration
**Files:**
- Modify: `public/js/auth-pages.js`
- Modify: `tests/auth-pages.test.js`
**Interfaces:**
- Consumes: `CaptchaPages.ensureToken(form, { sceneCode, subject })`
- Updates: login/register/password reset/send code flows wait for captcha before API submission.
- [ ] **Step 1: Write failing tests** for login body validToken and scene code mapping.
- [ ] **Step 2: Run tests** and confirm expected failure.
- [ ] **Step 3: Integrate captcha gate before submit/send code**.
- [ ] **Step 4: Run tests** and confirm pass.
### Task 4: HTML/CSS Wiring
**Files:**
- Modify: `login.html`
- Modify: `register.html`
- Modify: `forgot-password.html`
- Modify: `public/css/public.css`
**Interfaces:**
- Adds: `<link rel="stylesheet" href="public/tac/css/tac.css" />`
- Adds: hidden `validToken`
- Adds: `data-captcha-box`
- Adds: `public/tac/js/tac.min.js` and `public/js/captcha-pages.js`
- [ ] **Step 1: Add page hooks** with Chinese HTML comments where helpful.
- [ ] **Step 2: Add CSS-only layout for captcha container**.
- [ ] **Step 3: Run static checks** for scripts, hooks, no self JS style injection.
### Task 5: Verification And Records
**Files:**
- Modify: `docs/api-page-integration-2026-07-09.md`
- [ ] **Step 1: Run all Node tests**.
- [ ] **Step 2: Run `node --check` for all business JS**.
- [ ] **Step 3: Run static checks for HTML hooks and style-injection patterns**.
- [ ] **Step 4: Append dated progress record**.
@@ -1,26 +0,0 @@
# 2026-07-10 认证页表单校验和结构规范化计划
- 计划编号:PC-024
- 开始时间:2026-07-10 14:56:08 +08:00
- 页面范围:`login.html``register.html``forgot-password.html`
- 接口范围:登录、短信登录、注册、找回密码提交前的本地校验与验证码取票据
## 结论
- 当前三页只有很弱的前端校验,主要是必填判断和找回密码两次密码一致校验,不算完整表单验证。
- `<form>` 标签不是落后写法,它是提交类页面的语义结构;但把 `data-captcha-scene``data-success-url` 这类业务配置写在 HTML 上不够集中,后续维护容易散。
## 执行清单
1. [x] 写失败测试:认证页不能继续把业务配置写在表单 `data-*` 属性上。
2. [x] 写失败测试:认证页 JS 要提供统一表单校验函数。
3. [x] HTML 表单改为使用 `id` 作为挂载点。
4. [x] `auth-pages.js` 增加统一表单配置和校验函数。
5. [x] 保持三页提交前统一走 `CaptchaPages.ensureToken`,并继续使用 `payload.track` 换取票据。
6. [x] 运行结构测试、业务测试、验证码测试、语法检查和空白检查。
## 验证记录
- 2026-07-10 14:56:08 +08:00:开始记录 PC-024,并补充结构和校验测试。
- 2026-07-10 14:56:08 +08:00`node tests\auth-page-structure.test.js``node tests\auth-pages.test.js` 已按预期失败,失败点为 HTML 未使用表单 `id`、JS 缺少 `sms-login` 场景配置和统一校验函数。
- 2026-07-10 15:11:49 +08:00:修复后 `node tests\auth-page-structure.test.js``node tests\auth-pages.test.js``node tests\captcha-pages.test.js``node --check public\js\auth-pages.js``node --check public\js\captcha-pages.js` 均通过;`git diff --check` 无空白错误,仅有 LF/CRLF 换行提示。
@@ -1,251 +0,0 @@
# Axios 公共请求层重构 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 删除现有 `fetch` 请求实现,以本地 Axios 浏览器版和 `utils/AxiosRequestUtil.js` 替换为唯一公共请求层,页面业务 JS 不再依赖或实现底层网络请求。
**Architecture:** `utils/axios.js` 是固定版本的第三方浏览器依赖;`utils/AxiosRequestUtil.js` 负责 Axios 实例、请求头、token、参数、响应解包和错误转换;`utils/ApiClient.js` 负责所有 PC 接口契约与 token 生命周期。所有页面在页面业务脚本前加载三个 utils 文件,旧 `utils/RequestUtil.js``public/js/api-client.js` 被删除且不保留兼容入口。
**Tech Stack:** Axios 1.7.9 浏览器非压缩构建、原生 JavaScript、Node `assert` 测试、PowerShell 静态扫描。
## Global Constraints
- 计划创建时间:2026-07-10 09:27:30 +08:00。
- `utils` 只存通用依赖和公共请求封装;`public/js` 只存页面业务脚本,不存 API 客户端或底层请求实现。
- 不保留 `fetch``fetchImpl``root.fetch``RequestUtil``public/js/api-client.js` 或旧脚本引用;不做 Axios/fetch 双实现兼容。
- Axios 使用本地 `utils/axios.js`,页面运行时不依赖 CDN。
- 普通 JSON 请求传对象给 Axios;文件上传传 `FormData`;不手写 JSON 字符串。
- 每个请求都带 `clientid`;有 token 且 `auth !== false` 时带 `Authorization: Bearer <token>`
- PC-014 在本重构完成后重新执行,重构前的复核结论不作为最终证据。
---
### Task 0: 收敛环境配置职责
**Files:**
- Modify: `config.js`
- Modify: `tests/config.test.js`
- Modify: `utils/ApiClient.js`
**Interfaces:**
- Produces: `GenealogyConfig.getEnvironment()`,只返回 `development``production`
- Produces: `GenealogyConfig.getApiBaseUrl()`,只返回当前环境的接口基础地址。
- Produces: `GenealogyConfig.getConfig()`,只返回 `{ environment, apiBaseUrl }`
- Consumes: `ApiClient` 自己定义 PC `clientId``tenantId`、token key 和 token header。
- [x] **Step 1: 写配置职责失败测试**
```js
assert.deepStrictEqual(config.getConfig(), {
environment: 'development',
apiBaseUrl: 'http://test-genealogy-api.ddxcjp.cn'
});
assert.strictEqual(config.getClientId, undefined);
assert.strictEqual(config.getTenantId, undefined);
assert.strictEqual(config.getTokenKey, undefined);
```
- [x] **Step 2: 运行测试确认旧配置职责过宽**
Run: `node tests\\config.test.js`
Expected: FAIL,旧配置仍暴露 clientId、tenantId、token key 或 token header。
- [x] **Step 3: 重写 config.js 为环境和域名唯一入口**
```js
var environments = {
development: { apiBaseUrl: 'http://test-genealogy-api.ddxcjp.cn' },
production: { apiBaseUrl: 'http://test-genealogy-api.ddxcjp.cn' }
};
function getConfig() {
return {
environment: getEnvironment(),
apiBaseUrl: getApiBaseUrl()
};
}
```
- [x] **Step 4: 将 PC 身份和 token 常量保留在 ApiClient**
```js
var DEFAULT_CLIENT_ID = 'ced7e5f0498645c6ec642dcf450b036f';
var DEFAULT_TENANT_ID = '000000';
var DEFAULT_TOKEN_KEY = 'genealogy_auth_token';
var DEFAULT_TOKEN_HEADER_NAME = 'Authorization';
```
补充约束:`ApiClient` 不定义接口基础地址;必须由根目录 `config.js``apiBaseUrl` 或调用方显式 `baseUrl` 提供。
- [x] **Step 5: 运行配置与 API 客户端测试**
Run: `node tests\\config.test.js; node tests\\api-client.test.js; node --check config.js; node --check utils\\ApiClient.js`
Expected: 所有命令通过。
### Task 1: 锁定 Axios 请求契约的失败测试
**Files:**
- Modify: `tests/utils.test.js`
- Modify: `tests/api-client.test.js`
**Interfaces:**
- Produces: `AxiosRequestUtil.createRequester(options)`
- Consumes: `options.axiosInstance`,该对象实现 `request(config)`
- Produces: `request(method, path, { query, body, headers, auth })`,向 Axios 传入 `{ method, url, params, data, headers }`
- [x] **Step 1: 把工具测试改为 Axios 假实例**
```js
const axiosCalls = [];
const requester = AxiosRequestUtil.createRequester({
baseUrl: 'https://api.example.test',
clientId: 'pc-client',
getToken: () => 'token-x',
axiosInstance: {
request: async (config) => {
axiosCalls.push(config);
return { status: 200, data: { code: 200, data: { ok: true } } };
}
}
});
```
- [x] **Step 2: 运行测试确认因旧 fetch 契约失败**
Run: `node tests\\utils.test.js; node tests\\api-client.test.js`
Expected: FAIL,提示缺少 `AxiosRequestUtil` 或仍使用 `fetchImpl`
### Task 2: 创建 Axios 工具层并删除 fetch 工具层
**Files:**
- Create: `utils/axios.js`
- Create: `utils/AxiosRequestUtil.js`
- Create: `utils/ApiClient.js`
- Delete: `utils/RequestUtil.js`
- Delete: `public/js/api-client.js`
**Interfaces:**
- `AxiosRequestUtil.createRequester(options)` 只接受 Axios 实例,不接受 `fetchImpl`
- `GenealogyApi.createClient(options)``utils/ApiClient.js` 提供,只接受 `axiosInstance` 测试注入;生产环境读取 `window.axios`
- [x] **Step 1: 下载固定 Axios 非压缩浏览器构建**
Run: `curl.exe --fail --location https://cdn.jsdelivr.net/npm/axios@1.7.9/dist/axios.js --output utils\\axios.js`
Expected: `utils/axios.js` 存在且首行包含 Axios 版本信息。
- [x] **Step 2: 实现 AxiosRequestUtil 最小请求流程**
```js
var response = await axiosInstance.request({
method: method.toLowerCase(),
url: path,
params: requestOptions.query,
data: requestOptions.body,
headers: headers
});
return unwrapResponse(response.data);
```
- [x] **Step 3: 从零实现 utils/ApiClient.js 并只依赖 AxiosRequestUtil**
```js
var requestUtil = getAxiosRequestUtil();
var requester = requestUtil.createRequester({
baseUrl: baseUrl,
clientId: clientId,
getToken: getToken,
axiosInstance: settings.axiosInstance || root.axios
});
```
- [x] **Step 4: 删除旧 fetch 请求文件和旧公共 API 文件**
Run: `Remove-Item -LiteralPath utils\\RequestUtil.js; Remove-Item -LiteralPath public\\js\\api-client.js`
Expected: 两个文件不存在,后续扫描无 `RequestUtil``fetchImpl``root.fetch``fetch(``public/js/api-client.js` 匹配。
- [x] **Step 5: 运行工具和 API 客户端测试**
Run: `node tests\\utils.test.js; node tests\\api-client.test.js; node --check utils\\AxiosRequestUtil.js; node --check utils\\ApiClient.js`
Expected: 所有命令通过。
### Task 3: 更新页面公共脚本顺序
**Files:**
- Modify: 所有根目录中加载 `public/js/api-client.js``utils/ApiClient.js` 的 HTML 页面。
- Test: `tests/auth-page-structure.test.js`
- Test: `tests/api-script-order.test.js`
- [x] **Step 1: 以自动发现的脚本顺序测试锁定页面依赖**
```js
assert.strictEqual(pages.length, 45);
assert.ok(axiosIndex < axiosRequestUtilIndex);
assert.ok(axiosRequestUtilIndex < apiClientIndex);
assert.strictEqual(html.includes('utils/RequestUtil.js'), false);
```
- [x] **Step 2: 运行测试确认旧脚本顺序不满足新契约**
Run: `node tests\\auth-page-structure.test.js`
Expected: FAIL,仍有页面加载旧 API 客户端或未加载 Axios 和 AxiosRequestUtil。
- [x] **Step 3: 逐页替换公共脚本引用**
```html
<script src="utils/axios.js"></script>
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
```
- [x] **Step 4: 运行结构测试和静态扫描**
Run: `node tests\\auth-page-structure.test.js; rg -n "utils/RequestUtil\\.js|public/js/api-client\\.js|fetchImpl|root\\.fetch|fetch\\(" --glob "!public/tac/**" --glob "!utils/axios.js" .`
Expected: 测试通过,旧请求层标识无匹配。
### Task 4: 重跑 PC 复核并更新记录
**Files:**
- Modify: `docs/pc-api-page-integration-tracker-2026-07-09.md`
- Modify: `docs/superpowers/plans/2026-07-10-axios-request-layer-refactor.md`
- Modify: `docs/superpowers/plans/2026-07-10-pc-tracker-reverification.md`
- [x] **Step 1: 运行所有 Node 测试与语法检查**
Run: `Get-ChildItem -Path tests -Filter *.test.js | Sort-Object Name | ForEach-Object { node $_.FullName }; node --check utils\\AxiosRequestUtil.js; node --check utils\\ApiClient.js; git diff --check`
Expected: 所有测试通过,语法检查和差异检查无错误。
- [x] **Step 2: 执行 PC-014 复核命令并记录外部网关状态**
Run: `curl.exe -I --max-time 15 http://test-genealogy-api.ddxcjp.cn/captcha/require`
Expected: 记录当次结果;仅本地验证通过的项标为“本地复核通过”,网关不可用时标注“外部环境待验证”。
- [x] **Step 3: 写入完成时间和清理结论**
Record: Axios 文件来源、删除的 `fetch` 文件、页面脚本替换范围、所有验证命令及结果。
## 自检
- 所有底层网络调用都从 `utils/AxiosRequestUtil.js` 发起。
- `public/js` 中不含 `fetch`、Axios 实例创建或请求头拼装。
- 所有页面在页面业务脚本前加载 `utils/axios.js``utils/AxiosRequestUtil.js``utils/ApiClient.js`
- `utils/RequestUtil.js``public/js/api-client.js``fetchImpl``root.fetch` 和旧页面脚本引用均不存在。
## 完成记录
- 完成时间:2026-07-10 10:10:56 +08:00。
- Axios 来源:`axios@1.7.9/dist/axios.js` 的本地非压缩浏览器构建,存放于 `utils/axios.js`
- 请求契约:所有 PC 请求均由 `utils/AxiosRequestUtil.js` 通过 `axios.request` 发起;`utils/ApiClient.js` 仅封装 PC 接口和 token 生命周期。
- 配置边界:根目录 `config.js` 是接口基础地址唯一来源;`ApiClient` 不再保存 `DEFAULT_BASE_URL`
- 页面范围:45 个加载接口客户端的 HTML 页面已统一脚本顺序;旧 `utils/RequestUtil.js``public/js/api-client.js` 已删除。
- 验证:全量 Node 测试、语法检查、45 页脚本顺序检查、旧 fetch/旧脚本扫描和 `git diff --check` 均通过。
@@ -1,20 +0,0 @@
# 2026-07-10 验证结果参数格式修正计划
- 计划编号:PC-021
- 开始时间:2026-07-10 14:31:14 +08:00
- 页面范围:`login.html``register.html``forgot-password.html`
- 接口范围:`POST /captcha/verify`
- 新契约:验证轨迹统一提交为 `payload.track`,不再提交旧的 `payload.data`
## 执行清单
1. [x] 写失败测试:按后端最新示例断言 `payload.track``left``top``trackList`
2. [x] 修改 `captcha-pages.js`:把 TAC 轨迹转换成后端要求的 `payload.track`
3. [x] 更新追踪文档中旧的 `payload.data.trackList` 记录。
4. [x] 运行焦点测试、语法检查和空白检查。
## 验证记录
- 2026-07-10 14:31:14 +08:00:开始按新请求体契约补失败测试。
- 2026-07-10 14:31:14 +08:00`node tests\captcha-pages.test.js` 已按预期失败,失败点为实际请求体仍提交 `payload.id``payload.data`
- 2026-07-10 14:34:28 +08:00:修复后 `node tests\captcha-pages.test.js``node tests\auth-page-structure.test.js``node tests\auth-pages.test.js``node --check public\js\captcha-pages.js` 均通过;`git diff --check` 无空白错误,仅有 LF/CRLF 换行提示。
@@ -1,26 +0,0 @@
# 2026-07-10 登录页样式与认证流程修正计划
- 计划编号:PC-022 / PC-023
- 开始时间:2026-07-10 14:42:32 +08:00
- 页面范围:`login.html``register.html``forgot-password.html`
- 接口范围:登录、注册、忘记密码提交前的 `/captcha/verify` 取票据流程
## PC-022 登录页样式优化
1. [x] 优化 `public/css/login.css` 中登录方式切换区,让选中状态、文本居中和整体边框更自然。
2. [x] 增加登录按钮与底部链接之间的间距。
3. [x] 保持样式在 CSS 文件内,不用 JS 注入样式。
## PC-023 注册跳转与验证码流程复核
1. [x] 写失败测试:注册完成后必须跳转 `login.html`
2. [x] 写结构测试:三张认证页都必须声明验证码场景并携带 `validToken`
3. [x] 修改 `register.html` 的成功跳转。
4. [x] 复核公共 `CaptchaPages` 仍统一按 `payload.track` 换取 `validToken`
5. [x] 运行相关测试、语法检查和空白检查。
## 验证记录
- 2026-07-10 14:42:32 +08:00:开始记录 PC-022 / PC-023,先补认证页结构测试。
- 2026-07-10 14:42:32 +08:00`node tests\auth-page-structure.test.js` 已按预期失败,失败点为 `register.html` 仍跳转 `create-genealogy.html`
- 2026-07-10 14:50:23 +08:00:修复后 `node tests\auth-page-structure.test.js``node tests\auth-pages.test.js``node tests\captcha-pages.test.js``node --check public\js\auth-pages.js``node --check public\js\captcha-pages.js` 均通过;`git diff --check` 无空白错误,仅有 LF/CRLF 换行提示。
@@ -1,150 +0,0 @@
# PC 接口对接记录全量复核 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 重新验证 `pc-api-page-integration-tracker-2026-07-09.md` 中 PC-000 至 PC-013 的完成状态,并以当前代码、当前 PC YAML 和本次命令结果修正记录。
**Architecture:** `genealogy-pc-openapi.yaml``paths` 是接口范围唯一来源;代码和 HTML 是当前实现来源;Node 测试、语法检查和静态扫描是可重复验证来源。无法由前端离线验证的测试网关可用性明确标记为“外部环境待验证”,不将其当作页面对接完成证据。
**Tech Stack:** OpenAPI YAML、原生 JavaScript、Node `assert` 测试、PowerShell 静态扫描、Git 差异检查。
## Global Constraints
- 计划创建时间:2026-07-10 09:20:38 +08:00。
- 范围仅限追踪表中状态为“已完成”或“已修正”的 PC-000 至 PC-013;不新增业务接口或页面功能。
- 复核时不采用旧 APP 路径,不以历史测试文字代替当前命令结果。
- 每个结论必须记录命令、结果、时间和证据边界;证据不足必须改为“复核失败/待修正”或“外部环境待验证”。
- 仅在记录与事实不一致时修改文档;不改无关业务代码。
---
### Task 1: 复核契约和配置项
**Files:**
- Modify: `docs/pc-api-page-integration-tracker-2026-07-09.md`
- Read: `genealogy-pc-openapi.yaml`
- Read: `config.js`
- Read: `public/js/api-client.js`
- Test: `tests/config.test.js`
- Test: `tests/api-client.test.js`
**Covers:** PC-000、PC-001、PC-007、PC-010。
- [x] **Step 1: 核对 YAML 路径与客户端配置**
Run: `rg -n "^ /captcha/|^ /auth/code:|^ /genealogy/pc/auth/|^ /genealogy/pc/files/|^ /genealogy/region/" genealogy-pc-openapi.yaml; rg -n "test-genealogy-api|ced7e5f0498645c6ec642dcf450b036f|web_pc" config.js public/js/api-client.js`
Expected: 当前 YAML 只包含验证码、PC 认证、PC 文件、地区路径;代码使用当前 PC clientId 和开发环境 HTTP 基础地址。
- [x] **Step 2: 运行配置与 API 客户端测试**
Run: `node tests\\config.test.js; node tests\\api-client.test.js; node --check config.js; node --check public\\js\\api-client.js`
Expected: 四条命令均通过。
### Task 2: 复核通用工具与未覆盖业务降级
**Files:**
- Modify: `docs/pc-api-page-integration-tracker-2026-07-09.md`
- Read: `utils/StorageUtil.js`
- Read: `utils/FormUtil.js`
- Read: `utils/RequestUtil.js`
- Read: `public/js/api-client.js`
- Test: `tests/utils.test.js`
- Test: `tests/api-client.test.js`
**Covers:** PC-002、PC-008。
- [x] **Step 1: 运行工具和 API 降级测试**
Run: `node tests\\utils.test.js; node tests\\api-client.test.js; node tests\\pc-api-contract.test.js`
Expected: 三个测试通过,PC 请求契约扫描无遗留项。
- [x] **Step 2: 核对未覆盖业务的错误边界**
Run: `rg -n "PC_API_NOT_AVAILABLE" public\\js\\api-client.js`
Expected: 未被 YAML `paths` 覆盖的业务方法统一受控失败,不生成旧 APP 网络请求。
### Task 3: 复核认证、验证码与资料页面
**Files:**
- Modify: `docs/pc-api-page-integration-tracker-2026-07-09.md`
- Read: `login.html`
- Read: `register.html`
- Read: `forgot-password.html`
- Read: `public/js/auth-pages.js`
- Read: `public/js/captcha-pages.js`
- Read: `utils/MessageUtil.js`
- Test: `tests/auth-pages.test.js`
- Test: `tests/captcha-pages.test.js`
- Test: `tests/auth-page-structure.test.js`
- Test: `tests/auth-message.test.js`
- Test: `tests/profile-pages.test.js`
- Test: `tests/security-pages.test.js`
**Covers:** PC-003、PC-009、PC-011、PC-012、PC-013。
- [x] **Step 1: 检查认证场景、接口路径、TAC 挂载和 layui 提示**
Run: `rg -n "WEB_H5_LOGIN|WEB_H5_REGISTER|WEB_H5_FORGOT_PASSWORD|captcha-modal.css|auth-captcha-modal|MessageUtil|layer.msg" login.html register.html forgot-password.html public\\js\\auth-pages.js public\\js\\captcha-pages.js utils\\MessageUtil.js`
Expected: 三张认证页分别使用正确场景;验证码使用页面级挂载点;提示由 `MessageUtil`/layui 提供。
- [x] **Step 2: 运行认证和验证码测试**
Run: `node tests\\auth-pages.test.js; node tests\\captcha-pages.test.js; node tests\\auth-page-structure.test.js; node tests\\auth-message.test.js; node tests\\profile-pages.test.js; node tests\\security-pages.test.js; node --check public\\js\\auth-pages.js; node --check public\\js\\captcha-pages.js; node --check utils\\MessageUtil.js`
Expected: 所有测试和语法检查通过。
- [x] **Step 3: 记录网关外部依赖边界**
Run: `curl.exe -I --max-time 15 http://test-genealogy-api.ddxcjp.cn/captcha/require`
Expected: 仅记录当次 HTTP 结果;无论结果如何,不将测试网关可用性视为前端实现的通过或失败。
### Task 4: 复核文件与地区公共能力
**Files:**
- Modify: `docs/pc-api-page-integration-tracker-2026-07-09.md`
- Read: `public/js/upload-pages.js`
- Read: `public/js/region-pages.js`
- Test: `tests/upload-pages.test.js`
- Test: `tests/region-pages.test.js`
**Covers:** PC-004、PC-005、PC-006。
- [x] **Step 1: 运行文件和地区测试**
Run: `node tests\\upload-pages.test.js; node tests\\region-pages.test.js; node --check public\\js\\upload-pages.js; node --check public\\js\\region-pages.js`
Expected: 所有测试和语法检查通过。
- [x] **Step 2: 扫描旧路径与页面覆盖边界**
Run: `rg -n "PC_API_NOT_AVAILABLE" utils\\ApiClient.js`
Expected: 文件、地区旧 APP 路径无匹配;业务页面覆盖边界仍由 `PC_API_NOT_AVAILABLE` 保护。
### Task 5: 全量验证与追踪表修正
**Files:**
- Modify: `docs/pc-api-page-integration-tracker-2026-07-09.md`
- Modify: `docs/superpowers/plans/2026-07-10-pc-tracker-reverification.md`
- [x] **Step 1: 运行全量测试和差异检查**
Run: `Get-ChildItem -Path tests -Filter *.test.js | Sort-Object Name | ForEach-Object { node $_.FullName }; git diff --check`
Expected: 所有测试通过,差异检查无空白错误。
- [x] **Step 2: 写入复核矩阵和最终状态**
Record: PC-000 至 PC-013 每项的本次复核时间、验证命令、结论和外部环境边界;任何不一致项从“已完成”改为“待修正”。
## 自检
- 覆盖范围:PC-000 至 PC-013 的所有“已完成/已修正”项均被归入一个复核任务。
- 来源一致性:接口范围只引用 `genealogy-pc-openapi.yaml``paths`,不引用旧 APP 文档。
- 状态规则:只有当前命令和当前代码同时支持的结论才能保留“已完成”。
@@ -1,128 +0,0 @@
# Registration Token State 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:** Ensure registration never creates or retains a browser authentication token, while password and SMS login continue to persist theirs.
**Architecture:** `utils/ApiClient.js` remains the single owner of browser token persistence. The `register()` method will leave its request and response contract unchanged, then clear the configured storage key after a successful response. `tests/api-client.test.js` will exercise the behavior through the public client methods and an in-memory storage adapter.
**Tech Stack:** Browser JavaScript (UMD), Node.js `assert` tests, Axios request adapter.
## Global Constraints
- Only `login()` and `loginBySms()` may persist a response token under the configured token key.
- Successful `register()` clears the configured token key even when its response contains `token`, `accessToken`, or `tokenValue`.
- Failed registration leaves existing token storage unchanged.
- Registration page behavior remains a redirect to `login.html`.
- Do not refactor unrelated request, storage, page, or authentication behavior.
---
### Task 1: Enforce registration token state in the API client
**Files:**
- Modify: `tests/api-client.test.js:83-111`
- Modify: `utils/ApiClient.js:176-182`
**Interfaces:**
- Consumes: `client.register(body)` and `client.loginBySms(body)` from `GenealogyApi.createClient()`.
- Produces: `client.register(body)` returns the API response while removing the configured token key after a successful request.
- [ ] **Step 1: Write the failing regression test**
Insert the following immediately after the existing `client.loginBySms(...)` assertions and before `client.sendSmsCode(...)` in `tests/api-client.test.js`:
```js
assert.strictEqual(store.values['token-key'], 'server-token');
store.setItem('token-key', 'existing-token');
const registerResponse = await client.register({
phone: '13900000000',
password: 'register-md5',
nickName: 'new-user',
validToken: 'register-ticket'
});
assert.deepStrictEqual(registerResponse, {
token: 'server-token',
url: '/genealogy/pc/auth/register'
});
assert.strictEqual(calls[8].url, '/genealogy/pc/auth/register');
assert.strictEqual(calls[8].headers.Authorization, undefined);
assert.strictEqual(calls[8].data.grantType, 'password');
assert.strictEqual(calls[8].data.registerSource, 'PC');
assert.strictEqual(store.getItem('token-key'), null);
const failedRegisterStore = createStore({
'token-key': 'existing-token'
});
const failedRegisterClient = GenealogyApi.createClient({
baseUrl: 'https://test-genealogy-api.ddxcjp.cn',
clientId: 'client-x',
tenantId: 'tenant-x',
tokenKey: 'token-key',
tokenStore: failedRegisterStore,
axiosInstance: {
request: async () => {
throw new Error('register failed');
}
}
});
await assert.rejects(
failedRegisterClient.register({
phone: '13900000001',
password: 'register-md5',
nickName: 'failed-user',
validToken: 'register-ticket'
}),
/register failed/
);
assert.strictEqual(failedRegisterStore.getItem('token-key'), 'existing-token');
```
Move the existing `client.sendSmsCode(...)` assertions from index `8` to index `9`, then change the final call-count assertion from `9` to `10`.
- [ ] **Step 2: Run the regression test and confirm the current failure**
Run: `node tests/api-client.test.js`
Expected: failure at `assert.strictEqual(store.getItem('token-key'), null)` because the current `register()` implementation writes `server-token` to `token-key`. The failed-registration assertion will already pass, documenting that the token must change only after a successful response.
- [ ] **Step 3: Implement the minimal token-state change**
In `utils/ApiClient.js`, replace the body of `register()` with:
```js
async function register(body) {
var data = await request('POST', '/genealogy/pc/auth/register', {
auth: false,
body: Object.assign({ grantType: 'password', registerSource: 'PC' }, withTenant(body))
});
clearToken();
return data;
}
```
- [ ] **Step 4: Run the focused test and confirm it passes**
Run: `node tests/api-client.test.js`
Expected: `api-client tests passed`.
- [ ] **Step 5: Run the complete Node test suite**
Run: `Get-ChildItem -Path tests -Filter *.test.js | ForEach-Object { node $_.FullName }`
Expected: every test script prints its success message and PowerShell exits with code `0`.
- [ ] **Step 6: Check the final diff and commit the implementation**
Run: `git diff --check`
Expected: exit code `0` with no whitespace errors.
Then commit only the implementation files:
```powershell
git add -- utils/ApiClient.js tests/api-client.test.js
git commit -m "fix: clear token after registration"
```
@@ -1,84 +0,0 @@
# TAC 默认层调用纠正实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**目标:** 在不写任何验证码 CSS、也不修改 TAC 第三方文件的前提下,将 TAC 挂载到 Layui 默认弹层中,不再渲染到认证页底部。
**架构:** `auth-pages.js` 仍在表单提交时调用 `CaptchaPages.ensureToken``captcha-pages.js` 通过已加载的 `layui.layer.open` 创建默认容器,将该容器传给 `CaptchaConfig.bindEl`,然后原样调用 `new TAC(config)`。TAC 继续根据后端下发的 `captchaType` 自己选择滑块、旋转、拼图、文字点击或禁用态。
**技术栈:** Layui 内置 `layer``public/tac/css/tac.css``public/tac/js/tac.min.js`、原生 JavaScript、Node `assert` 测试。
## 全局约束
- 计划创建时间:2026-07-10 10:38:10 +08:00。
- 不修改 `public/tac/css/tac.css``public/tac/js/tac.min.js` 或 Layui 第三方文件。
- 不创建或注入任何验证码 CSS;不传 `bgUrl``logoUrl`、尺寸、皮肤等 TAC 视觉配置。
- 只使用 Layui 默认 `layer.open` 作为弹层容器,TAC 的内部外观完全由其自带文件负责。
- 只修改认证三页、验证码适配、对应测试和记录。
---
### 任务 1:锁定正确的弹层挂载契约
**文件:**
- 修改:`tests/auth-page-structure.test.js`
- 修改:`tests/captcha-pages.test.js`
- [x] **步骤 1:写页面结构失败测试。**
断言 `login.html``register.html``forgot-password.html` 不再保留 `[data-captcha-box]` 静态挂载点,且 `public/layui/layui.js``captcha-pages.js` 前加载。
- [x] **步骤 2:写验证码适配失败测试。**
断言适配层调用 `root.layui.layer.open`、在结束时调用 `root.layui.layer.close`,仍只调用 `new root.TAC(config)`,不存在自定义验证码视觉标识。
- [x] **步骤 3:运行测试并确认当前底部挂载实现失败。**
运行:`node tests\auth-page-structure.test.js; node tests\captcha-pages.test.js`
预期:因静态 `[data-captcha-box]` 与缺少 `layer.open` 而失败。
### 任务 2:使用 Layui 默认层承载 TAC
**文件:**
- 修改:`login.html`
- 修改:`register.html`
- 修改:`forgot-password.html`
- 修改:`public/js/captcha-pages.js`
- [x] **步骤 1:删除三页静态 `[data-captcha-box]`。**
- [x] **步骤 2:在 `captcha-pages.js` 创建无样式的 DOM 容器,并以 `layui.layer.open({ type: 1, content: mount })` 打开默认弹层。**
- [x] **步骤 3:将该 DOM 容器作为 `CaptchaConfig.bindEl`,并保持 `new TAC(config)` 原样调用。**
- [x] **步骤 4:在 TAC 成功、关闭、初始化异常和 Promise 异常时销毁 TAC 并关闭对应 Layui 层。**
- [x] **步骤 5:运行聚焦测试和语法检查。**
运行:`node tests\auth-page-structure.test.js; node tests\captcha-pages.test.js; node --check public\js\captcha-pages.js`
预期:全部通过。
### 任务 3:全量验证和记录
**文件:**
- 修改:`docs/pc-api-page-integration-tracker-2026-07-09.md`
- 修改:`docs/superpowers/plans/2026-07-10-tac-layer-call-correction.md`
- [x] **步骤 1:运行全量 Node 测试、静态扫描和差异检查。**
运行:`Get-ChildItem -Path tests -Filter *.test.js | Sort-Object Name | ForEach-Object { node $_.FullName }; rg -n "data-captcha-box|auth-captcha-modal|captcha-modal.css|bgUrl|logoUrl" login.html register.html forgot-password.html public\css public\js; git diff --check`
预期:测试和差异检查通过;认证页中不再有静态验证码挂载点或自定义视觉标识。
- [x] **步骤 2:记录完成时间、TAC 功能边界和浏览器实测边界。**
## 完成记录
- 完成时间:2026-07-10 10:44:11 +08:00。
- 根因:TAC 的 `init()` 只向 `CaptchaConfig.bindEl` 追加组件;原先静态挂载点位于 `body` 末尾,移除定位样式后自然渲染在页面底部。
- 修改:删除三页静态 `[data-captcha-box]`;验证码适配层使用 Layui 默认 `layer.open` 创建临时容器并传给 `bindEl`;成功、TAC 关闭、初始化异常与 Promise 异常都会清空容器并关闭同一层。
- TAC 边界:保留原生 `new TAC(config)`、原生样式、原生刷新和后端类型分发;不传视觉配置,不写验证码 CSS,不改动任何 `public/tac` 文件。
- 验证:失败测试已复现底部挂载;聚焦测试、全量 Node 测试、`node --check public/js/captcha-pages.js``git diff --check` 均通过。
- 浏览器边界:未在真实测试网关完成一次有效轨迹验证;仍需在浏览器点击注册后确认 Layui 默认层居中显示及后端 `validToken` 返回。
@@ -1,23 +0,0 @@
# 2026-07-10 TAC 弹层居中修复计划
- 计划编号:PC-020
- 开始时间:2026-07-10 11:11:05 +08:00
- 页面范围:`login.html``register.html``forgot-password.html`
- 接口范围:`/captcha/require``/captcha/challenge``/captcha/verify`
- 约束:不修改 `public/tac`,不新增验证码自定义样式,不传 TAC 视觉配置,只调整 Layui 弹层调用参数。
## 根因
Layui 打开页面层时,`content` 里的验证码挂载点还是空节点;TAC 后续异步创建自己的 `#tianai-captcha-parent`,该原生外层尺寸是 `318px * 318px`。Layui 已经按空内容完成定位,所以最终验证码窗口会偏离预期位置。
## 执行清单
1. [x] 写失败测试:要求验证码弹层通过 Layui `area` 预留 `318px * 318px`,并保持默认居中。
2. [x] 修改 `captcha-pages.js`:只给 `layer.open` 增加 TAC 原生尺寸和默认居中参数。
3. [x] 运行焦点测试、结构测试、语法检查和空白检查。
4. [x] 将完成结果同步回 `pc-api-page-integration-tracker-2026-07-09.md`
## 验证记录
- 2026-07-10 11:11:05 +08:00`node tests\captcha-pages.test.js` 已按预期失败,失败点为 `layerOptions.area` 缺失。
- 2026-07-10 11:14:31 +08:00:修复后 `node tests\captcha-pages.test.js``node tests\auth-page-structure.test.js``node --check public\js\captcha-pages.js` 均通过;`git diff --check` 无空白错误,仅有既有 LF/CRLF 换行提示。
@@ -1,72 +0,0 @@
# TAC Layui 实际挂载点修复实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**目标:** 修复 Layui 遮罩已显示、验证码接口已返回 200,但 TAC 验证窗口仍不显示的问题。
**架构:** `CaptchaPages.createCaptchaLayer` 使用 Layui `layer.open` 的字符串 `content` 创建弹层内部真实挂载节点;弹层创建完成后再从 `document` 查询该实际节点,并把它交给 `CaptchaConfig.bindEl`。这样 Layui 负责弹层,TAC 挂到已经进入页面的真实 DOM。
**技术栈:** Layui 2.11.5、TAC 原生 CSS/JS、原生 JavaScript、Node `assert` 测试。
## 全局约束
- 计划创建时间:2026-07-10 10:59:52 +08:00。
- 不修改 `public/tac/css/tac.css``public/tac/js/tac.min.js` 或 Layui 第三方文件。
- 不新增验证码自定义 CSS,不注入样式,不传 TAC 视觉配置。
- 保留 `auth-pages.js -> CaptchaPages.ensureToken -> TAC` 调用链,只修正弹层挂载点。
---
### 任务 1:复现“遮罩有但实际挂载点不可见”
**文件:**
- 修改:`tests/captcha-pages.test.js`
- [x] **步骤 1:写失败测试**
断言 `layer.open``content` 是包含唯一 `data-captcha-layer-box` 的字符串;`createCaptchaLayer` 必须在弹层打开后从 `document.querySelector` 取到实际挂载节点并返回。
- [x] **步骤 2:运行失败测试**
运行:`node tests\captcha-pages.test.js`
预期:失败,当前实现仍返回打开前创建的原生 DOM。
### 任务 2:修正实际挂载点
**文件:**
- 修改:`public/js/captcha-pages.js`
- [x] **步骤 1:最小实现**
增加内部自增 ID,用字符串内容创建 `<div data-captcha-layer-box="..."></div>``layer.open` 后查询该节点并作为 `box` 返回。
- [x] **步骤 2:运行聚焦测试**
运行:`node tests\captcha-pages.test.js; node --check public\js\captcha-pages.js`
预期:全部通过。
### 任务 3:记录和回归
**文件:**
- 修改:`docs/pc-api-page-integration-tracker-2026-07-09.md`
- 修改:`docs/superpowers/plans/2026-07-10-tac-layer-real-mount-fix.md`
- [x] **步骤 1:补充 PC-019 记录**
记录用户浏览器复现现象、根因、修正方式、验证命令。
- [x] **步骤 2:最终验证**
运行:`node tests\auth-page-structure.test.js; node tests\captcha-pages.test.js; node --check public\js\captcha-pages.js; git diff --check`
预期:全部通过;`git diff --check` 仅允许 Git 行尾提示,不允许空白错误。
## 完成记录
- 完成时间:2026-07-10 11:04:18 +08:00。
- 根因:PC-018 只让 `layer.open` 不再因 `.parents()` 报错,但仍把打开前创建的临时 DOM 返回给 TAC;真实浏览器里 Layui 遮罩已出现,`/captcha/challenge` 也返回 200,但 TAC 没有挂到可见弹层内容区。
- 修复:`layer.open` 使用字符串 `content` 创建弹层内部真实节点,随后通过 `document.querySelector('[data-captcha-layer-box="..."]')` 获取实际挂载点并交给 `CaptchaConfig.bindEl`
- 约束:未修改 `public/tac`,未新增验证码 CSS,未传 TAC 视觉配置。
- 验证:失败测试已先复现;修复后 `node tests\captcha-pages.test.js``node --check public\js\captcha-pages.js` 通过。最终回归记录见 `docs/pc-api-page-integration-tracker-2026-07-09.md` 的 PC-019。
@@ -1,71 +0,0 @@
# TAC Layui content 契约修复实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**目标:** 修复认证页点击后只出现 `d.parents is not a function` 提示、TAC 弹层不显示的问题。
**架构:** `CaptchaPages.createCaptchaLayer` 继续只负责创建 Layui 默认弹层容器,不写验证码样式、不改 TAC 文件。Layui `layer.open``content` 使用 Layui/jQuery 包装对象满足弹层内部 `.parents()` 契约,TAC `bindEl` 继续使用对应的真实 DOM 节点。
**技术栈:** Layui 2.11.5、TAC 原生 CSS/JS、原生 JavaScript、Node `assert` 测试。
## 全局约束
- 计划创建时间:2026-07-10 10:50:06 +08:00。
- 不修改 `public/tac/css/tac.css``public/tac/js/tac.min.js` 或 Layui 第三方文件。
- 不新增验证码自定义 CSS,不注入样式,不传 TAC 视觉配置。
- 只修正 Layui 弹层 content 契约、对应测试和记录文档。
---
### 任务 1:复现 Layui content 契约错误
**文件:**
- 修改:`tests/captcha-pages.test.js`
- [x] **步骤 1:写失败测试**
断言 `createCaptchaLayer` 传给 `layui.layer.open``content` 必须是包装对象,包装对象需要保留真实 DOM 节点,并具备 `.parents()` 方法。
- [x] **步骤 2:运行失败测试**
运行:`node tests\captcha-pages.test.js`
预期:失败,当前实现把原生 DOM 直接传给 `content`
### 任务 2:修正 createCaptchaLayer
**文件:**
- 修改:`public/js/captcha-pages.js`
- [x] **步骤 1:最小实现**
`createCaptchaLayer` 内用 `root.layui.$ || root.$ || root.jQuery` 包装真实 DOM 节点;`layer.open({ content })` 使用包装对象;返回值中的 `box` 仍为真实 DOM,供 TAC `bindEl` 使用。
- [x] **步骤 2:运行聚焦测试**
运行:`node tests\captcha-pages.test.js; node --check public\js\captcha-pages.js`
预期:全部通过。
### 任务 3:记录和回归
**文件:**
- 修改:`docs/pc-api-page-integration-tracker-2026-07-09.md`
- 修改:`docs/superpowers/plans/2026-07-10-tac-layui-content-contract-fix.md`
- [x] **步骤 1:补充 PC-018 完成记录**
记录根因、修改文件、验证命令和结果。
- [x] **步骤 2:运行最终验证**
运行:`node tests\auth-page-structure.test.js; node tests\captcha-pages.test.js; node --check public\js\captcha-pages.js; git diff --check`
预期:全部通过;`git diff --check` 仅允许 Git 行尾提示,不允许空白错误。
## 完成记录
- 完成时间:2026-07-10 10:54:03 +08:00。
- 根因:Layui 2.11.5 的 `layer.open` 在处理 DOM 内容时会调用 `.parents()`;原实现将原生 DOM 节点直接传给 `content`,导致浏览器报 `d.parents is not a function`,弹层创建中断。
- 修复:`createCaptchaLayer` 使用 `layui.$``$``jQuery` 包装临时 DOM 后传给 `layer.open({ content })`,同时继续把真实 DOM 节点传给 `CaptchaConfig.bindEl`
- 验证:失败测试已先复现;修复后 `node tests\captcha-pages.test.js``node --check public\js\captcha-pages.js` 通过。最终回归记录见 `docs/pc-api-page-integration-tracker-2026-07-09.md` 的 PC-018。
@@ -1,150 +0,0 @@
# TAC 验证弹窗遮罩清理 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 在 TAC 验证被取消、初始化异常或接口失败时,主动关闭认证页遮罩并清空残留的验证码 DOM。
**Architecture:** `public/js/captcha-pages.js` 作为验证码弹窗生命周期的唯一拥有者,新增打开和关闭挂载容器的函数。由于原 `public.css` 不是 UTF-8,无法使用补丁安全编辑,`public/css/captcha-modal.css` 作为新增公共样式文件只根据 `is-active` 类决定遮罩显示;三张认证页继续复用同一个页面级挂载点。
**Tech Stack:** 原生 JavaScript、Node `assert` 测试、layui 提示、TAC `public/tac/js/tac.min.js`
## Global Constraints
- 计划创建时间:2026-07-10 09:10:03 +08:00。
- 仅修改 TAC 遮罩生命周期,不修改 `public/tac/js/tac.min.js`,不改变后端验证码类型选择。
- 新增或修改的 JS、CSS 注释使用中文;样式只写入 CSS 文件。
- 失败提示继续使用 `MessageUtil`/layui,禁止回退到浏览器原生 `alert`
- 503/CORS 是服务端或跨域环境可用性问题;本任务只保证前端失败后不遗留遮罩。
---
### Task 1: 用例锁定与生命周期工具
**Files:**
- Modify: `tests/captcha-pages.test.js`
- Modify: `public/js/captcha-pages.js`
**Interfaces:**
- Produces: `openCaptchaBox(box)`,清空旧内容、激活容器、设置 `aria-hidden="false"`
- Produces: `closeCaptchaBox(box)`,清空内容、取消激活、设置 `aria-hidden="true"`
- [x] **Step 1: 写失败测试**
```js
CaptchaPages.openCaptchaBox(box);
assert.strictEqual(box.classList.contains('is-active'), true);
assert.strictEqual(box.attributes['aria-hidden'], 'false');
CaptchaPages.closeCaptchaBox(box);
assert.strictEqual(box.classList.contains('is-active'), false);
assert.strictEqual(box.attributes['aria-hidden'], 'true');
assert.strictEqual(box.innerHTML, '');
```
- [x] **Step 2: 运行失败测试**
Run: `node tests\\captcha-pages.test.js`
Expected: FAIL,提示 `openCaptchaBox is not a function`
- [x] **Step 3: 实现最小生命周期函数**
```js
function openCaptchaBox(box) {
if (!box) return;
box.innerHTML = '';
box.classList.add('is-active');
box.setAttribute('aria-hidden', 'false');
}
function closeCaptchaBox(box) {
if (!box) return;
box.innerHTML = '';
box.classList.remove('is-active');
box.setAttribute('aria-hidden', 'true');
}
```
- [x] **Step 4: 将关闭函数接入 TAC 成功、取消、初始化异常和 Promise 捕获路径**
```js
if (captchaInstance && captchaInstance.destroyWindow) captchaInstance.destroyWindow();
closeCaptchaBox(box);
```
- [x] **Step 5: 运行单元测试与语法检查**
Run: `node tests\\captcha-pages.test.js; node --check public\\js\\captcha-pages.js`
Expected: 两条命令均通过。
### Task 2: CSS 显示状态收口
**Files:**
- Create: `public/css/captcha-modal.css`
- Modify: `login.html`
- Modify: `register.html`
- Modify: `forgot-password.html`
- Test: `tests/auth-page-structure.test.js`
**Interfaces:**
- Consumes: `.auth-captcha-modal.is-active`
- Produces: 默认隐藏、激活后显示的页面级遮罩。
- [x] **Step 1: 写失败结构测试**
```js
assert.ok(publicCss.includes('.auth-captcha-modal.is-active'));
assert.ok(publicCss.includes('display: none;'));
```
- [x] **Step 2: 运行失败测试**
Run: `node tests\\auth-page-structure.test.js`
Expected: FAIL,缺少 `.auth-captcha-modal.is-active` 规则。
- [x] **Step 3: 实现 CSS 状态规则**
```css
.auth-captcha-modal {
display: none;
}
.auth-captcha-modal.is-active {
display: grid;
}
```
- [x] **Step 4: 运行结构测试**
Run: `node tests\\auth-page-structure.test.js`
Expected: PASS。
### Task 3: 全量验证与记录
**Files:**
- Modify: `docs/pc-api-page-integration-tracker-2026-07-09.md`
- Modify: `docs/superpowers/plans/2026-07-10-tac-modal-cleanup.md`
- [x] **Step 1: 运行验证码相关和全量 Node 测试**
Run: `Get-ChildItem -Path tests -Filter *.test.js | Sort-Object Name | ForEach-Object { node $_.FullName }`
Expected: 所有测试通过。
- [x] **Step 2: 记录完成时间、修改文件和验证结果**
```markdown
- [x] 完成时间:YYYY-MM-DD HH:mm:ss +08:00
- [x] 验证:相关测试、语法检查、全量 Node 测试均通过。
```
## 自检
- 需求覆盖:取消后遮罩清理、失败后遮罩清理、成功后遮罩清理、CSS 显示状态和回归测试均有对应任务。
- 占位扫描:无 `TODO``TBD` 或未定义接口。
- 接口一致性:JS 导出名、测试调用名和 CSS 类名统一为 `openCaptchaBox``closeCaptchaBox``is-active`
## 完成记录
- 完成时间:2026-07-10 09:15:55 +08:00。
- 修改文件:`public/js/captcha-pages.js``public/css/captcha-modal.css``login.html``register.html``forgot-password.html``tests/captcha-pages.test.js``tests/auth-page-structure.test.js`
- 验证:`node tests\\captcha-pages.test.js``node tests\\auth-page-structure.test.js``node --check public\\js\\captcha-pages.js` 和全量 `tests/*.test.js` 均通过。
- 已知边界:测试域名的 503、CORS 或 TLS 可用性问题仍需后端或网关处理;该前端修复保证取消或异常后不残留页面遮罩。
@@ -1,85 +0,0 @@
# TAC 原生样式调用清理实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**目标:** 登录、注册、找回密码页面只调用 `public/tac` 自带的验证码组件和样式,不再追加任何验证码视觉样式或视觉配置。
**架构:** 页面保留一个无 class 的 `[data-captcha-box]` 挂载点;`captcha-pages.js` 只负责验证码接口参数和成功、取消、异常后的 DOM 清理;TAC 的外观完全由 `public/tac/css/tac.css``tac.min.js` 决定。
**技术栈:** 原生 JavaScript、TAC 本地组件、Node `assert` 测试。
## 全局约束
- 计划创建时间:2026-07-10 10:19:46 +08:00。
- 不修改 `public/tac/css/tac.css``public/tac/js/tac.min.js`
- 不为验证码新增、覆盖或注入 CSS;不向 `new TAC` 传入 `bgUrl``logoUrl` 等视觉配置。
- 仅修改登录、注册、找回密码三页及其验证码适配、测试和记录。
---
### 任务 1:用测试锁定原生 TAC 调用边界
**文件:**
- 修改:`tests/auth-page-structure.test.js`
- 修改:`tests/captcha-pages.test.js`
- [x] **步骤 1:将页面结构测试改为禁止自定义验证码 CSS 与容器 class。**
断言三页加载 `public/tac/css/tac.css`,不加载 `public/css/captcha-modal.css`,且 `[data-captcha-box]` 不含 `auth-captcha-modal` class。
- [x] **步骤 2:将组件测试改为要求原生构造调用。**
断言 `captcha-pages.js` 中不存在 `bgUrl``logoUrl``.auth-captcha-modal` 视觉控制。
- [x] **步骤 3:运行测试并确认当前实现失败。**
运行:`node tests\auth-page-structure.test.js; node tests\captcha-pages.test.js`
预期:因当前自定义验证码 CSS、容器 class 和 TAC 视觉配置而失败。
### 任务 2:删除自定义验证码视觉层
**文件:**
- 修改:`login.html`
- 修改:`register.html`
- 修改:`forgot-password.html`
- 修改:`public/css/public.css`
- 删除:`public/css/captcha-modal.css`
- 修改:`public/js/captcha-pages.js`
- [x] **步骤 1:删除三页对 `captcha-modal.css` 的引用,并将挂载点改为无 class 的 `data-captcha-box`。**
- [x] **步骤 2:删除 `public.css` 中 `.auth-captcha-modal` 及其 TAC 尺寸限制规则。**
- [x] **步骤 3:删除 `captcha-modal.css`。**
- [x] **步骤 4:将 `new TAC(config, { ... })` 改为 `new TAC(config)`,并将挂载点查询、打开和关闭逻辑改为无视觉状态的 DOM 清理。**
- [x] **步骤 5:运行两项聚焦测试并确认通过。**
运行:`node tests\auth-page-structure.test.js; node tests\captcha-pages.test.js; node --check public\js\captcha-pages.js`
预期:全部通过。
### 任务 3:全量验证并记录
**文件:**
- 修改:`docs/pc-api-page-integration-tracker-2026-07-09.md`
- 修改:`docs/superpowers/plans/2026-07-10-tac-native-style-cleanup.md`
- [x] **步骤 1:运行全量 Node 测试、验证码样式静态扫描和差异检查。**
运行:`Get-ChildItem -Path tests -Filter *.test.js | Sort-Object Name | ForEach-Object { node $_.FullName }; rg -n "captcha-modal.css|auth-captcha-modal|bgUrl|logoUrl" login.html register.html forgot-password.html public\css public\js; git diff --check`
预期:测试与差异检查通过;扫描没有自定义验证码视觉标识。
- [x] **步骤 2:记录完成时间、修改范围、验证结果和外部验证码实测边界。**
## 完成记录
- 完成时间:2026-07-10 10:32:20 +08:00。
- 删除 `public/css/captcha-modal.css`,并从 `public/css/public.css` 删除 35 行 `.auth-captcha-box``.auth-captcha-modal` 和 TAC 尺寸限制规则。
- 三个认证页仅加载 `public/tac/css/tac.css`,无 class 的 `[data-captcha-box]` 只作为 TAC 挂载点。
- `captcha-pages.js` 现在只执行 `new TAC(config)`;不再传入 `bgUrl``logoUrl` 或切换任何视觉 class。成功、取消、初始化异常后仍清空 TAC 第三方 DOM。
- 验证:聚焦结构/验证码测试、全量 Node 测试、`node --check public/js/captcha-pages.js`、自定义样式标识扫描和 `git diff --check` 均通过。
- 外部边界:尚未用有效验证码轨迹完成真实服务端验证;`/captcha/verify` 的尺寸校验问题仍需以后端挑战响应原始尺寸为准单独处理。
@@ -1,474 +0,0 @@
# Auth To Profile Flow 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:** Complete the PC registration, login, profile, unauthorized, logout, and endpoint-address flow with one token owner and deterministic redirects.
**Architecture:** `config.js` remains the sole runtime owner of the API base address. `utils/ApiClient.js` owns token validation, persistence, and cleanup; `profile-pages.js` decides whether a profile request is allowed and redirects unauthenticated users to the homepage; `profile-common.js` delegates confirmed logout to the API client. The integration tracker records the current address and verified behavior only after verification commands complete.
**Tech Stack:** Browser JavaScript (UMD and jQuery), Axios, Node.js `assert` tests, static HTML, Markdown.
## Global Constraints
- The current API base address is `http://182.61.18.23:8080` for development and production, and `config.js` is its only runtime owner.
- Runtime-related tests use the same address; only documents explicitly marked historical may retain the former test domain.
- Successful registration clears `genealogy_auth_token` and redirects to `login.html`; it never persists a registration response token.
- Only successful password or SMS login with a nonempty response token persists `genealogy_auth_token` and redirects to `profile.html`.
- `profile.html` and `profile-data.html` redirect to `index.html` before a profile request when no token exists, and after HTTP or business `401` responses.
- Confirmed logout requests `/genealogy/pc/auth/logout`, clears the token regardless of the request outcome, and redirects to `index.html`.
- Do not add global Axios redirects, duplicate token storage in pages, alter API paths, or change password-reset, phone-change, account-deactivation, or profile pages that do not load the profile API.
---
### Task 1: Synchronize the configured API address test
**Files:**
- Modify: `tests/config.test.js:11-28`
- Modify: `tests/utils.test.js:31`
**Interfaces:**
- Consumes: `configFactory(...).getConfig()` and `getApiBaseUrl()` from `config.js`.
- Produces: a test that asserts the current `config.js` address for both environments.
- [ ] **Step 1: Confirm the existing address-contract test fails**
The current test contains these stale assertions:
```js
assert.deepStrictEqual(developmentConfig.getConfig(), {
environment: 'development',
apiBaseUrl: 'http://test-genealogy-api.ddxcjp.cn'
});
assert.strictEqual(productionConfig.getApiBaseUrl(), 'http://test-genealogy-api.ddxcjp.cn');
```
Run: `node tests/config.test.js`
Expected: failure because `config.js` already returns `http://182.61.18.23:8080`.
- [ ] **Step 2: Update the stale test expectations**
Replace the two expected URL values with:
```js
assert.deepStrictEqual(developmentConfig.getConfig(), {
environment: 'development',
apiBaseUrl: 'http://182.61.18.23:8080'
});
assert.strictEqual(productionConfig.getApiBaseUrl(), 'http://182.61.18.23:8080');
```
Replace the mocked requester address in `tests/utils.test.js` with:
```js
baseUrl: 'http://182.61.18.23:8080',
```
- [ ] **Step 3: Run the focused configuration test**
Run: `node tests/config.test.js`
Expected: `config tests passed`.
Run: `node tests/utils.test.js`
Expected: `utils tests passed`.
- [ ] **Step 4: Commit the address-test synchronization**
```powershell
git add -- tests/config.test.js tests/utils.test.js
git commit -m "test: sync configured api address"
```
### Task 2: Enforce login-token and logout-cleanup contracts
**Files:**
- Modify: `utils/ApiClient.js:52-60, 167-191, 270-274`
- Modify: `tests/api-client.test.js:83-177`
**Interfaces:**
- Consumes: `getTokenFromResponse(data)`, `setToken(token)`, `clearToken()`, and public `client.login`, `client.loginBySms`, and `client.logout` methods.
- Produces: `client.login(body)` and `client.loginBySms(body)` reject with `ApiError('登录响应缺少 token')` when their response has no token; `client.logout()` clears the configured token key before resolving or rejecting.
- [ ] **Step 1: Write failing API-client assertions**
Append this after the registration assertions and before `client.sendSmsCode(...)` in `tests/api-client.test.js`:
```js
const missingTokenStore = createStore();
const missingTokenClient = GenealogyApi.createClient({
baseUrl: 'http://182.61.18.23:8080',
clientId: 'client-x',
tenantId: 'tenant-x',
tokenKey: 'token-key',
tokenStore: missingTokenStore,
axiosInstance: {
request: async () => ({
status: 200,
data: { code: 200, data: {} }
})
}
});
await assert.rejects(
missingTokenClient.login({ phone: '13800000000', password: 'md5' }),
(error) => error.message === '登录响应缺少 token'
);
assert.strictEqual(missingTokenStore.getItem('token-key'), null);
store.setItem('token-key', 'logout-token');
await client.logout();
assert.strictEqual(calls[9].url, '/genealogy/pc/auth/logout');
assert.strictEqual(store.getItem('token-key'), null);
const failedLogoutStore = createStore({
'token-key': 'logout-token'
});
const failedLogoutClient = GenealogyApi.createClient({
baseUrl: 'http://182.61.18.23:8080',
clientId: 'client-x',
tenantId: 'tenant-x',
tokenKey: 'token-key',
tokenStore: failedLogoutStore,
axiosInstance: {
request: async () => {
throw new Error('logout failed');
}
}
});
await assert.rejects(failedLogoutClient.logout(), /logout failed/);
assert.strictEqual(failedLogoutStore.getItem('token-key'), null);
```
Move the existing SMS-code assertions from `calls[9]` to `calls[10]` and change the final call-count assertion from `10` to `11`.
Replace the two existing `baseUrl: 'https://test-genealogy-api.ddxcjp.cn'` fixtures in this file with:
```js
baseUrl: 'http://182.61.18.23:8080',
```
- [ ] **Step 2: Run the focused API-client test and confirm failure**
Run: `node tests/api-client.test.js`
Expected: failure at the missing-token rejection because the current login method resolves without writing a token, before reaching the logout assertions.
- [ ] **Step 3: Add the minimal token validation and logout cleanup**
Add this helper after `getTokenFromResponse` in `utils/ApiClient.js`:
```js
function requireLoginToken(data) {
var token = getTokenFromResponse(data);
if (!token) throw new ApiError('登录响应缺少 token');
return token;
}
```
Replace the token writes in `login()` and `loginBySms()` with:
```js
setToken(requireLoginToken(data));
```
Replace `logout` with:
```js
logout: async function () {
try {
return await request('DELETE', '/genealogy/pc/auth/logout');
} finally {
clearToken();
}
},
```
- [ ] **Step 4: Run the API-client test and confirm all token paths pass**
Run: `node tests/api-client.test.js`
Expected: `api-client tests passed`.
- [ ] **Step 5: Commit the API-client contract**
```powershell
git add -- utils/ApiClient.js tests/api-client.test.js
git commit -m "fix: complete auth token lifecycle"
```
### Task 3: Guard profile access and connect confirmed logout
**Files:**
- Modify: `public/js/profile-pages.js:18-22, 165-198`
- Modify: `public/js/profile-common.js:9-82`
- Modify: `profile.html:80, 355`
- Modify: `tests/profile-pages.test.js:1-65`
- Create: `tests/profile-logout-structure.test.js`
**Interfaces:**
- Consumes: public `api.getToken()`, `api.clearToken()`, `api.currentProfile()`, `api.updateProfile()`, and `api.logout()` methods.
- Produces: `ProfilePages.shouldRedirectToHome(api, error)` for token and unauthorized classification; profile reads and writes redirect to `index.html` when that helper returns true; the existing logout controls call `api.logout()` before navigating to `index.html`.
- [ ] **Step 1: Write failing profile and logout tests**
Add these assertions before the final `console.log` in `tests/profile-pages.test.js`:
```js
assert.strictEqual(ProfilePages.shouldRedirectToHome({
getToken: () => ''
}), true);
assert.strictEqual(ProfilePages.shouldRedirectToHome({
getToken: () => 'token-x'
}), false);
assert.strictEqual(ProfilePages.shouldRedirectToHome({
getToken: () => 'token-x'
}, { status: 401 }), true);
assert.strictEqual(ProfilePages.shouldRedirectToHome({
getToken: () => 'token-x'
}, { code: 401 }), true);
assert.strictEqual(ProfilePages.shouldRedirectToHome({
getToken: () => 'token-x'
}, { status: 500 }), false);
```
Create `tests/profile-logout-structure.test.js` with:
```js
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const profileHtml = fs.readFileSync(path.join(__dirname, '..', 'profile.html'), 'utf8');
const profileCommon = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'profile-common.js'), 'utf8');
assert.match(profileHtml, /data-logout-confirm/);
assert.match(profileHtml, /<a class="btn primary magnetic" href="index\.html" data-logout-confirm>/);
assert.match(profileCommon, /function performLogout\(targetUrl\)/);
assert.match(profileCommon, /api\.logout\(\)/);
assert.match(profileCommon, /targetUrl \|\| 'index\.html'/);
console.log('profile logout structure tests passed');
```
- [ ] **Step 2: Run the new focused tests and confirm failure**
Run: `node tests/profile-pages.test.js`
Expected: failure because `shouldRedirectToHome` is not exported.
Run: `node tests/profile-logout-structure.test.js`
Expected: failure because the logout confirmation markup and `performLogout` function do not exist.
- [ ] **Step 3: Add profile token guard and unauthorized redirect**
Add these helpers after `getApi()` in `public/js/profile-pages.js`:
```js
function shouldRedirectToHome(api, error) {
var status = error && (error.status || error.code);
return !api || !api.getToken || !api.getToken() || Number(status) === 401;
}
function redirectToHome() {
if (!root.location) return;
if (typeof root.location.replace === 'function') {
root.location.replace('index.html');
return;
}
root.location.href = 'index.html';
}
function redirectUnauthorized(api, error) {
if (!shouldRedirectToHome(api, error)) return false;
if (api && api.clearToken) api.clearToken();
redirectToHome();
return true;
}
```
Replace the existing combined early-return condition in `loadProfile()` with these two branches:
```js
if (!api || !documentRef || !documentRef.querySelector('[data-profile-page]')) return;
if (redirectUnauthorized(api)) return;
```
Update its `catch` block to start with:
```js
if (redirectUnauthorized(api, error)) return;
```
Update `submitProfileForm()` to place `if (redirectUnauthorized(api)) return;` after its existing null guard, and to start its `catch` block with `if (redirectUnauthorized(api, error)) return;` before setting an error status or message.
Export the classifier with the existing public methods:
```js
shouldRedirectToHome: shouldRedirectToHome,
```
- [ ] **Step 4: Connect both logout confirmations to the API client**
Add this function before `bindLogout()` in `public/js/profile-common.js`:
```js
function performLogout(targetUrl) {
var api = window.GenealogyApi && window.GenealogyApi.defaultClient;
var redirect = function () {
window.location.href = targetUrl || 'index.html';
};
if (!api || !api.logout) {
redirect();
return;
}
api.logout().catch(function () {}).then(redirect);
}
```
In `bindLogout()`, change the default target URL to `index.html`, replace the Layui confirmation callback body with `performLogout(targetUrl);`, and add this event handler before the existing `[data-logout-close]` handler:
```js
$(document).on('click', '[data-logout-confirm]', function (event) {
event.preventDefault();
closeLegacyLogout();
performLogout($(this).attr('href') || 'index.html');
});
```
In `profile.html`, change the legacy confirmation anchor to:
```html
<a class="btn primary magnetic" href="index.html" data-logout-confirm>确认退出</a>
```
- [ ] **Step 5: Run the focused profile tests and syntax checks**
Run: `node tests/profile-pages.test.js`
Expected: `profile-pages tests passed`.
Run: `node tests/profile-logout-structure.test.js`
Expected: `profile logout structure tests passed`.
Run: `node --check public/js/profile-pages.js`
Expected: exit code `0`.
Run: `node --check public/js/profile-common.js`
Expected: exit code `0`.
- [ ] **Step 6: Commit the profile and logout flow**
```powershell
git add -- public/js/profile-pages.js public/js/profile-common.js profile.html tests/profile-pages.test.js tests/profile-logout-structure.test.js
git commit -m "fix: guard profile auth and logout"
```
### Task 4: Record the current flow and run end-to-end regression checks
**Files:**
- Modify: `docs/pc-api-page-integration-tracker-2026-07-09.md:1-9, 32, 70, 163`
- Modify: `docs/superpowers/specs/2026-07-11-auth-to-profile-flow-design.md:3-5`
**Interfaces:**
- Consumes: the verified results of Tasks 1 through 3.
- Produces: a tracker whose current address and authentication-flow status match the code and tests, and a spec marked approved for implementation.
- [ ] **Step 1: Run all focused flow tests**
Run:
```powershell
node tests/config.test.js
node tests/api-client.test.js
node tests/auth-pages.test.js
node tests/profile-pages.test.js
node tests/profile-logout-structure.test.js
```
Expected: each command prints its success message and exits with code `0`.
- [ ] **Step 2: Run syntax and complete regression checks**
Run:
```powershell
node --check utils/ApiClient.js
node --check public/js/profile-pages.js
node --check public/js/profile-common.js
Get-ChildItem -Path tests -Filter *.test.js | ForEach-Object { & node $_.FullName; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } }
```
Expected: all syntax checks exit `0`; every test script prints its success message; PowerShell exits `0` on the first test failure instead of masking it.
- [ ] **Step 3: Update the tracker from verified evidence**
At the top of `docs/pc-api-page-integration-tracker-2026-07-09.md`, replace the active base-address line with:
```markdown
接口基础地址:`http://182.61.18.23:8080`
```
Add this section immediately after the introductory historical-record lines:
```markdown
## Current Endpoint Contract
- Runtime owner: `config.js`.
- Current development and production address: `http://182.61.18.23:8080`.
- Address change record: on 2026-07-11 the active address changed from the historical test domain to this IP address and port. Historical references to `test-genealogy-api.ddxcjp.cn` do not describe the current runtime configuration.
```
Replace the YAML-analysis item that says the project currently uses the test domain with:
```markdown
2. `servers` 仍是本地地址示例;当前运行时基础地址以 `config.js` 为准,为 `http://182.61.18.23:8080`
```
Replace the PC-010 row with:
```markdown
| PC-010 | 已复核(当前地址已同步) | `config.js``tests/config.test.js``utils/ApiClient.js` | API 基础地址 | 当前开发和生产地址均为 `http://182.61.18.23:8080`;地址唯一运行时来源为 `config.js``tests/config.test.js` 已同步断言。 |
```
After Steps 1 and 2 pass, run `Get-Date -Format "yyyy-MM-dd HH:mm:ss K"` and insert its output as the `完成时间` line immediately after the `状态` line in this record. Append the rest of this record after the existing PC entries:
```markdown
## PC-025 注册、登录到个人资料闭环
- 状态:已完成
- 规划文件:`docs/superpowers/specs/2026-07-11-auth-to-profile-flow-design.md`
- 页面范围:`register.html``login.html``profile.html``profile-data.html`
- 接口范围:`/genealogy/pc/auth/register``/genealogy/pc/auth/login``/genealogy/pc/auth/login/sms``/genealogy/pc/auth/profile``/genealogy/pc/auth/logout`
- 地址契约:运行时唯一来源为 `config.js`;当前开发和生产地址均为 `http://182.61.18.23:8080`
- 流程结果:注册成功清除 token 并跳转 `login.html`;密码和短信登录必须取得 token 后跳转 `profile.html`;个人资料无 token 或收到 HTTP/业务 `401` 时清 token 并跳转 `index.html`;确认退出无论接口结果都清 token 并跳转 `index.html`
- 修改文件:`tests/config.test.js``utils/ApiClient.js``tests/api-client.test.js``public/js/profile-pages.js``public/js/profile-common.js``profile.html``tests/profile-pages.test.js``tests/profile-logout-structure.test.js`
- 验证结果:`node tests/config.test.js``node tests/api-client.test.js``node tests/auth-pages.test.js``node tests/profile-pages.test.js``node tests/profile-logout-structure.test.js`、三个 `node --check` 命令和全量 Node 测试均通过。
```
Change the spec status body to:
```markdown
Approved for implementation.
```
- [ ] **Step 4: Check final whitespace and commit the record**
Run: `git diff --check`
Expected: exit code `0`; pre-existing CRLF conversion warnings are not whitespace errors.
```powershell
git add -- docs/pc-api-page-integration-tracker-2026-07-09.md docs/superpowers/specs/2026-07-11-auth-to-profile-flow-design.md
git commit -m "docs: record auth profile flow"
```
@@ -0,0 +1,129 @@
# 内容与协作页面状态收口 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:** 让缺少正式接口的内容、协作、消息与提醒页面明确展示开发状态,同时保留家族圈动态的真实功能。
**Architecture:** 复用 `public/js/pending-pages.js` 的页面状态契约。每个待开发页面仅增加 `data-feature-status`、中文提示文案和状态脚本引用;脚本阻止主内容区未声明可用的业务链接,不新增接口调用、示例数据或业务脚本。
**Tech Stack:** 原生 JavaScript、静态 HTML/CSS、Node LTS `node:test`
## Global Constraints
- 保留现有页面、布局、视觉类名和导航结构,不删除用户设计。
- `PC.openapi2.json` 中缺少正式接口的功能不得伪造请求或静态数据为真实业务。
- 家族圈动态 `profile-feed.html``profile-feed-edit.html` 已接入正式接口,不能标记为待开发。
- 所有新增注释使用中文;不写入测试账号或密码。
- 当前工作区含用户未提交修改,不执行提交、重置或清理操作。
---
### Task 1: 扩展页面状态测试
**Files:**
- Modify: `tests/pending-pages.test.js`
**Interfaces:**
- Consumes: 每页的 `data-feature-status="pending"``public/js/pending-pages.js`
- Produces: 内容与协作待开发页面的静态约束,以及家族圈动态未被误标记的约束。
- [x] **Step 1: 写入失败测试**
`contentPendingPages` 中列出以下页面:
```js
const contentPendingPages = [
'profile-content.html', 'profile-article.html', 'profile-article-edit.html',
'profile-album.html', 'profile-video.html', 'profile-gift.html',
'profile-gift-edit.html', 'profile-growth.html', 'profile-growth-edit.html',
'profile-memo.html', 'profile-memo-edit.html', 'profile-merit.html',
'profile-merit-edit.html', 'profile-messages.html', 'profile-feedback.html',
'profile-admin-permissions.html', 'profile-data-reminders.html'
];
```
同时断言 `profile-feed.html``profile-feed-edit.html` 不含待开发状态标记,并验证只有 `data-feature-link="available"` 的业务链接可跳转。
- [x] **Step 2: 运行失败测试**
Run: `$env:Path = 'C:\\Program Files\\nodejs;' + $env:Path; node --test tests/pending-pages.test.js`
Expected: 新增页面尚未标记,测试失败。
### Task 2: 标记内容与协作页面
**Files:**
- Modify: `profile-content.html`
- Modify: `profile-article.html`
- Modify: `profile-article-edit.html`
- Modify: `profile-album.html`
- Modify: `profile-video.html`
- Modify: `profile-gift.html`
- Modify: `profile-gift-edit.html`
- Modify: `profile-growth.html`
- Modify: `profile-growth-edit.html`
- Modify: `profile-memo.html`
- Modify: `profile-memo-edit.html`
- Modify: `profile-merit.html`
- Modify: `profile-merit-edit.html`
- Modify: `profile-messages.html`
- Modify: `profile-feedback.html`
- Modify: `profile-admin-permissions.html`
- Modify: `profile-data-reminders.html`
- Test: `tests/pending-pages.test.js`
**Interfaces:**
- Consumes: `PageAvailability.init()` 读取的 `<body data-feature-status="pending" data-feature-message="..."></body>`
- Produces: 可见的“功能开发中”提示,并拦截主内容区提交与操作按钮。
- [x] **Step 1: 为每页增加状态契约**
在目标页面的 `body` 增加 `data-feature-status="pending"` 和与业务相符的中文 `data-feature-message`。例如谱文页面使用“谱文服务正在开发中,当前页面仅保留设计预览。”
- [x] **Step 2: 加载统一状态脚本**
`ApiClient.js` 的页面在其后加载:
```html
<script src="utils/ApiClient.js"></script>
<script src="public/js/pending-pages.js"></script>
```
`profile-video.html``profile-data-reminders.html` 不依赖 `ApiClient.js`,在 `page-effects.js` 前加载状态脚本。
- [x] **Step 3: 收束业务入口跳转**
待开发页面主内容区的业务链接由状态脚本拦截并显示当前页面提示;侧栏保留页面浏览导航。`profile-content.html` 的家族圈动态入口使用 `data-feature-link="available"`,继续跳转到 `profile-feed.html`
- [x] **Step 4: 运行测试确认通过**
Run: `$env:Path = 'C:\\Program Files\\nodejs;' + $env:Path; node --test tests/pending-pages.test.js`
Expected: 待开发页面均加载状态脚本,家族圈动态页面未被标记。
### Task 3: 更新进度并做静态验证
**Files:**
- Modify: `docs/规划.md`
- Modify: `docs/superpowers/plans/2026-07-11-content-page-state-closure.md`
- Test: `tests/*.test.js`
**Interfaces:**
- Consumes: 全部页面的本地脚本引用。
- Produces: 已完成第二轮进度与可重复的验证结果。
- [x] **Step 1: 更新总规划的第二轮状态**
`docs/规划.md` 中“第二轮:内容与协作页面状态收口”从未完成更新为已完成,并写明家族圈动态未受影响。
- [x] **Step 2: 执行完整测试**
Run: `$env:Path = 'C:\\Program Files\\nodejs;' + $env:Path; npm.cmd test`
Expected: 所有 Node 测试通过。
- [x] **Step 3: 执行静态检查**
Run: PowerShell 扫描全部 HTML 的 `src="*.js"`,随后运行 `git diff --check`
Expected: 每个本地脚本文件存在,且没有空白格式错误。
@@ -0,0 +1,192 @@
# 家族圈动态接口接入 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.openapi2.json` 中已交付的 12 个家族圈动态接口接入既有动态页面,并保持未开发的家谱业务不调用不存在的接口。
**Architecture:** `PC.openapi2.json` 是接口唯一依据;`ApiClient` 提供一一对应的私有请求封装,`feed-pages.js` 只协调页面事件和已定义的接口方法。动态页从 URL 查询参数读取真实 `genealogyId`,不写入示例家谱 ID。
**Tech Stack:** 原生 JavaScript、Axios、Node LTS `node:test`、静态 HTML/CSS。
## Global Constraints
- 所有新增或修改的注释使用清晰中文。
- 不删除现有页面和样式;不接入尚未出现在 `PC.openapi2.json` 的业务。
- 不提交当前工作区:其中含用户已有的未提交修改。
- 动态读取响应目前只有通用 `ListResult`/`PageResult`,页面仅使用 `feedId``feedContent`;后端返回缺少这两个字段时展示明确错误,不猜测字段名。
---
### Task 1: 更新接口边界测试和动态请求测试
**Files:**
- Modify: `tests/api-client-contract.test.js`
- Modify: `tests/pc-scope.test.js`
- Create: `tests/feed-pages.test.js`
**Interfaces:**
- Consumes: `PC.openapi2.json` 的 12 个 `/genealogy/pc/genealogies/{genealogyId}/feeds` 操作。
- Produces: 对客户端路径、HTTP 方法、请求体和页面请求体转换的自动化约束。
- [ ] **Step 1: 写入失败测试**
```js
test('家族圈动态方法使用文档定义的路径和请求体', async () => {
const calls = [];
const client = createClientWithRecorder(calls);
await client.createFeed(900001001, { feedContent: '今天上传一张老照片。' });
await client.feedCommentsPage(900001001, 7001, { pageNum: 1, pageSize: 10 });
assert.deepEqual(calls.map((call) => [call.method, call.url]), [
['post', '/genealogy/pc/genealogies/900001001/feeds'],
['get', '/genealogy/pc/genealogies/900001001/feeds/7001/comments/page']
]);
});
test('动态表单只构造 FamilyFeedBody 字段', () => {
assert.deepEqual(FeedPages.buildFeedBody({
feedContent: '家谱动态', mediaOssIds: '101,102', status: '0'
}), {
feedType: 'text', feedContent: '家谱动态', mediaOssIds: '101,102', status: '0'
});
});
```
- [ ] **Step 2: 运行失败测试**
Run: `$env:Path = 'C:\\Program Files\\nodejs;' + $env:Path; npm.cmd test`
Expected: 动态方法或 `feed-pages.js` 尚不存在导致失败。
- [ ] **Step 3: 调整范围检查**
```js
assert.match(contract, /\/genealogy\/pc\/genealogies\/\{genealogyId\}\/feeds/);
assert.equal(read('profile-feed.html').includes('src="public/js/feed-pages.js"'), true);
assert.equal(read('profile-feed-edit.html').includes('src="public/js/feed-pages.js"'), true);
```
- [ ] **Step 4: 重新运行测试,确认仍处于预期失败状态**
Run: `$env:Path = 'C:\\Program Files\\nodejs;' + $env:Path; npm.cmd test`
Expected: 仅动态实现相关断言失败。
### Task 2: 增加 12 个家族圈 API 方法
**Files:**
- Modify: `utils/ApiClient.js`
- Modify: `tests/api-client-contract.test.js`
**Interfaces:**
- Consumes: `genealogyId:number|string``feedId:number|string``commentId:number|string`
- Produces: `feeds``feedsPage``feedDetail``createFeed``updateFeed``deleteFeed``likeFeed``unlikeFeed``feedComments``feedCommentsPage``createFeedComment``deleteFeedComment`
- [ ] **Step 1: 保持失败测试不变**
```js
await client.createFeed(900001001, { feedContent: '家谱动态' });
assert.equal(calls[0].url, '/genealogy/pc/genealogies/900001001/feeds');
assert.deepEqual(calls[0].data, { feedContent: '家谱动态' });
```
- [ ] **Step 2: 实现最小路径构造**
```js
function feedPath(genealogyId, suffix) {
return '/genealogy/pc/genealogies/' + encodeURIComponent(genealogyId) + '/feeds' + suffix;
}
createFeed: function (genealogyId, body) {
return request('POST', feedPath(genealogyId, ''), { body: body });
}
```
- [ ] **Step 3: 运行测试,确认通过**
Run: `$env:Path = 'C:\\Program Files\\nodejs;' + $env:Path; npm.cmd test`
Expected: 动态客户端路径和请求体断言通过。
### Task 3: 恢复动态页的真实交互
**Files:**
- Create: `public/js/feed-pages.js`
- Modify: `profile-feed.html`
- Modify: `profile-feed-edit.html`
- Modify: `tests/feed-pages.test.js`
**Interfaces:**
- Consumes: `GenealogyApi.defaultClient` 的 12 个动态方法和 URL 中的 `genealogyId`、可选 `feedId`
- Produces: 动态列表、发布/编辑、点赞/取消点赞、评论查看/发布/删除、动态删除事件。
- [ ] **Step 1: 使页面测试失败**
```js
assert.equal(FeedPages.getCurrentGenealogyId('?genealogyId=900001001'), '900001001');
assert.equal(FeedPages.getCurrentGenealogyId(''), '');
assert.equal(FeedPages.getFeedId({ feedId: 7001 }), '7001');
```
- [ ] **Step 2: 实现页面数据转换和事件**
```js
function buildFeedBody(values) {
return {
feedType: 'text',
feedContent: String(values.feedContent || '').trim(),
mediaOssIds: trimOrUndefined(values.mediaOssIds),
status: trimOrUndefined(values.status)
};
}
```
列表只读取 `feedId``feedContent`,并在缺少其中任一字段时显示“动态响应缺少 feedId 或 feedContent,请联系后端补充 DTO”。编辑页以 `feedId` 查询详情并调用 `updateFeed`;列表操作调用点赞、取消点赞、评论、删除接口后重新加载当前页。
- [ ] **Step 3: 挂载脚本和契约字段**
```html
<textarea id="feedContent" name="feedContent" required></textarea>
<script src="public/js/feed-pages.js"></script>
```
移除未在 `FamilyFeedBody` 中定义的“可见范围”提交字段;保持页面布局与既有视觉类名。
- [ ] **Step 4: 运行测试,确认通过**
Run: `$env:Path = 'C:\\Program Files\\nodejs;' + $env:Path; npm.cmd test`
Expected: `feed-pages.test.js` 和已有测试全部通过。
### Task 4: 更新文档来源与最终验证
**Files:**
- Modify: `tests/pc-scope.test.js`
- Modify: `PC.openapi.yaml`
**Interfaces:**
- Consumes: `PC.openapi2.json` 的接口路径。
- Produces: 明确标记旧 YAML 不再为接口源,并使静态检查以 JSON 为准。
- [ ] **Step 1: 写入失败检查**
```js
const contract = JSON.parse(read('PC.openapi2.json'));
assert.ok(contract.paths['/genealogy/pc/genealogies/{genealogyId}/feeds']);
```
- [ ] **Step 2: 标记旧 YAML 失效**
`PC.openapi.yaml` 文件首行写入中文说明:该文件不再作为代码或测试接口源,正式接口源为 `PC.openapi2.json`。不复制或手改 JSON 中未定义的动态响应 DTO。
- [ ] **Step 3: 全量验证**
Run: `$env:Path = 'C:\\Program Files\\nodejs;' + $env:Path; npm.cmd test`
Expected: 全部测试通过,且所有动态页面加载的脚本文件都存在。
## Self-review
- 范围只覆盖最新文档已交付的家族圈动态接口,没有加入家谱创建、成员或世系树接口。
- 所有请求体字段来自 `FamilyFeedBody``FamilyFeedCommentBody`
- 读取响应不使用历史字段兜底,不把未知字段猜成有效数据。
- 任何 URL 缺少 `genealogyId` 的页面都会明确提示,不使用硬编码示例 ID。
@@ -0,0 +1,140 @@
# 家谱基础页面状态收口 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:** 让尚未获得后端家谱接口的基础页面明确展示开发状态,并阻止表单和操作按钮伪造成功结果。
**Architecture:** 仅在家谱列表、创建/加入、审核、成员管理、邀请、世系和字辈页面加载一个轻量状态脚本。页面通过 `data-feature-status``data-feature-message` 提供文案;脚本统一插入提示条,并拦截页面主内容区的提交与按钮操作。
**Tech Stack:** 原生 JavaScript、静态 HTML/CSS、Node LTS `node:test`
## Global Constraints
- 保留现有页面、布局、视觉类名和导航结构,不删除用户设计。
- 不调用 `PC.openapi2.json` 中不存在的家谱、成员、世系或字辈接口。
- 所有新增注释使用中文;不写入测试账号或密码。
- 当前工作区含用户未提交修改,不执行提交、重置或清理操作。
---
### Task 1: 建立失败测试
**Files:**
- Create: `tests/pending-pages.test.js`
- Modify: `tests/pc-scope.test.js`
**Interfaces:**
- Consumes: `PageAvailability.buildBanner(message)``PageAvailability.isPendingPage(body)`
- Produces: 对状态脚本、页面标记和脚本加载的约束。
- [x] **Step 1: 写入失败测试**
```js
test('待开发页面生成明确且转义后的状态提示', () => {
assert.match(PageAvailability.buildBanner('家谱基础服务正在开发中'), /家谱基础服务正在开发中/);
assert.match(PageAvailability.buildBanner('<script>'), /&lt;script&gt;/);
});
test('家谱基础页面显式加载状态脚本', () => {
assert.equal(read('profile-families.html').includes('data-feature-status="pending"'), true);
assert.equal(read('profile-tree.html').includes('src="public/js/pending-pages.js"'), true);
});
```
- [x] **Step 2: 运行失败测试**
Run: `$env:Path = 'C:\\Program Files\\nodejs;' + $env:Path; node --test tests/pending-pages.test.js tests/pc-scope.test.js`
Expected: `pending-pages.js` 不存在且页面未标记导致失败。
### Task 2: 实现统一状态脚本与样式
**Files:**
- Create: `public/js/pending-pages.js`
- Modify: `public/css/profile-module.css`
- Test: `tests/pending-pages.test.js`
**Interfaces:**
- Consumes: `<body data-feature-status="pending" data-feature-message="..."></body>`
- Produces: `PageAvailability.buildBanner(message)``PageAvailability.init()`
- [x] **Step 1: 实现最小状态脚本**
```js
function buildBanner(message) {
return '<section class="feature-status-banner" role="status"><strong>功能开发中</strong><p>' + escapeHtml(message) + '</p></section>';
}
```
脚本只在 `data-feature-status="pending"` 页面运行,将提示条放在 `.module-main` 首位;拦截该页面 `main` 区域内的表单提交和非重置按钮,显示同一提示文案。
- [x] **Step 2: 增加复用样式**
```css
.feature-status-banner {
border: 1px solid rgba(196, 146, 69, .38);
border-radius: 16px;
background: #fff8e8;
}
```
- [x] **Step 3: 运行测试确认通过**
Run: `$env:Path = 'C:\\Program Files\\nodejs;' + $env:Path; node --test tests/pending-pages.test.js tests/pc-scope.test.js`
Expected: 新增状态脚本和页面标记断言通过。
### Task 3: 标记家谱基础页面
**Files:**
- Modify: `profile-families.html`
- Modify: `profile-create-family.html`
- Modify: `profile-join-family.html`
- Modify: `profile-join-review.html`
- Modify: `profile-family-admin.html`
- Modify: `profile-invite.html`
- Modify: `profile-tree.html`
- Modify: `profile-generation.html`
**Interfaces:**
- Consumes: `pending-pages.js` 和每页 `data-feature-message`
- Produces: 明确状态提示,不提交文档外请求。
- [x] **Step 1: 在 body 标记状态**
```html
<body class="page-profile-module" data-feature-status="pending" data-feature-message="家谱成员与权限服务正在开发中,当前页面仅保留设计预览。">
```
- [x] **Step 2: 在 ApiClient 后加载状态脚本**
```html
<script src="utils/ApiClient.js"></script>
<script src="public/js/pending-pages.js"></script>
<script src="public/js/page-effects.js"></script>
```
- [x] **Step 3: 运行完整测试**
Run: `$env:Path = 'C:\\Program Files\\nodejs;' + $env:Path; npm.cmd test`
Expected: 全部测试通过。
### Task 4: 静态验证
**Files:**
- Test: `tests/pc-scope.test.js`
**Interfaces:**
- Consumes: 8 个已标记页面。
- Produces: 所有待开发页面有状态文案,所有页面脚本路径存在。
- [x] **Step 1: 执行脚本引用检查**
Run: PowerShell 扫描全部 HTML 的 `src="*.js"`,确认每个本地脚本文件存在。
- [x] **Step 2: 执行格式检查**
Run: `git diff --check`
Expected: 无缺失脚本和空白格式错误。
@@ -1,278 +0,0 @@
# PC Endpoint Page Coverage 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:** Make every callable operation in `genealogy-pc-openapi.yaml` traceable to a real PC page flow, an automated regression test, and a manual acceptance step.
**Architecture:** `utils/ApiClient.js` remains the sole network contract owner. Page modules in `public/js` own input validation, loading and success/error state; `tests` prove their request and navigation behavior. The coverage matrix distinguishes a complete user flow from an endpoint that is documented but lacks the contract required to expose it safely.
**Tech Stack:** Static HTML, browser JavaScript, Axios, Layui, Node `assert` tests.
## Global Constraints
- Runtime API base URL is owned only by `config.js`: `http://182.61.18.23:8080`.
- Only PC/H5 routes and scenes defined by `genealogy-pc-openapi.yaml` may be requested.
- Registration never persists a token; password and SMS login persist the returned token and navigate to `profile.html`.
- Logout, unauthenticated profile access and account deactivation navigate to `index.html`.
- Every behavior change starts with a failing Node test and ends with focused plus full test verification.
- No page invents a business table name, business ID, CAPTCHA scene or legacy captcha submission field that is not in the PC contract.
## Endpoint Matrix
| Operation | Page owner | Automated test | Manual acceptance order | Status target |
| --- | --- | --- | --- | --- |
| `GET /captcha/require` | `CaptchaPages` on auth forms | `tests/captcha-pages.test.js` | 1-4 | Complete |
| `POST /captcha/challenge` | `CaptchaPages` on auth forms | `tests/captcha-pages.test.js` | 1-4 | Complete |
| `POST /captcha/verify` | `CaptchaPages` on auth forms | `tests/captcha-pages.test.js` | 1-4 | Complete |
| `GET /auth/code` | no safe consumer | `tests/pc-endpoint-coverage.test.js` | blocked | Backend contract required |
| Register, password login, SMS login, SMS code | `register.html`, `login.html` | `tests/auth-pages.test.js`, `tests/api-client.test.js` | 1-3 | Complete |
| Profile read/update | `profile.html`, `profile-data.html` | `tests/profile-pages.test.js` | 4-5 | Complete |
| Password reset | `forgot-password.html` | `tests/auth-pages.test.js` | 6 | Complete |
| Password change, logout, account deactivation | `profile-security.html` | `tests/security-pages.test.js` | 7-9 | Complete |
| Phone change | `profile-security.html` | `tests/security-pages.test.js` | blocked | PC/H5 phone SMS scene required |
| Single upload | profile avatar and existing upload fields | `tests/upload-pages.test.js` | 10 | Complete |
| Resumable init/chunk/complete | same file input for large files | `tests/upload-pages.test.js`, `tests/api-client.test.js` | 11 | Complete |
| File reference bind/release | no safe business entity contract | `tests/pc-endpoint-coverage.test.js` | blocked | PC business table and ID contract required |
| Region children/path/search/detail | profile address picker and region search control | `tests/region-pages.test.js`, `tests/profile-pages.test.js` | 12 | Complete |
## Task 1: Enforce Endpoint Coverage
**Files:**
- Create: `tests/pc-endpoint-coverage.test.js`
- Modify: `docs/pc-api-page-integration-tracker-2026-07-09.md`
**Interfaces:**
- Consumes: the 25 operations declared under `paths` in `genealogy-pc-openapi.yaml`.
- Produces: a test-owned `ENDPOINTS` list that assigns every operation one of `complete` or `blocked` with a page/test owner.
- [ ] **Step 1: Write the failing coverage test**
```js
assert.deepStrictEqual(missingOperations, []);
assert.deepStrictEqual(unownedOperations, []);
assert.deepStrictEqual(unsupportedBlockedOperations, []);
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `node tests\pc-endpoint-coverage.test.js`
Expected: failure listing the endpoint operations without a page/test/blocker owner.
- [ ] **Step 3: Add the explicit 25-operation matrix**
```js
const ENDPOINTS = {
'GET /captcha/require': { page: 'auth', test: 'captcha-pages', status: 'complete' },
'GET /auth/code': { status: 'blocked', reason: 'legacy captcha has no PC request field' }
};
```
- [ ] **Step 4: Run the test to verify it passes**
Run: `node tests\pc-endpoint-coverage.test.js`
Expected: `pc-endpoint-coverage tests passed`.
## Task 2: Complete and Test the Auth Journey
**Files:**
- Modify: `public/js/auth-pages.js`
- Modify: `public/js/profile-pages.js`
- Modify: `public/js/security-pages.js`
- Modify: `tests/auth-pages.test.js`
- Modify: `tests/profile-pages.test.js`
- Modify: `tests/security-pages.test.js`
**Interfaces:**
- Consumes: `GenealogyApi.login`, `loginBySms`, `register`, `sendSmsCode`, `resetPassword`, `getProfile`, `updateProfile`, `changePassword`, `deactivateAccount`, and `logout`.
- Produces: consistent form validation, token state and redirect behavior for the acceptance steps 1-9.
- [ ] **Step 1: Write failing redirect and request tests**
```js
assert.strictEqual(loginRoot.location.href, 'profile.html');
assert.strictEqual(registerRoot.location.href, 'login.html');
assert.strictEqual(logoutRoot.location.href, 'index.html');
```
- [ ] **Step 2: Run focused tests to verify failure**
Run: `node tests\auth-pages.test.js; node tests\profile-pages.test.js; node tests\security-pages.test.js`
Expected: a failure naming the missing flow or redirect.
- [ ] **Step 3: Implement only the missing flow branch in its page module**
```js
await api.loginBySms(buildSmsLoginBody(values));
root.location.href = 'profile.html';
```
Use the corresponding documented endpoint method for each form; do not create a second token store or a page-local route string owner.
- [ ] **Step 4: Run focused tests**
Run: `node tests\auth-pages.test.js; node tests\profile-pages.test.js; node tests\security-pages.test.js`
Expected: all three scripts print their pass messages.
## Task 3: Make Region Search, Path and Detail Usable
**Files:**
- Modify: `profile-data.html`
- Modify: `public/js/region-pages.js`
- Modify: `tests/region-pages.test.js`
**Interfaces:**
- Consumes: `regionChildren(parentCode)`, `regionSearch({ keyword })`, `regionPath(regionCode)`, and `regionDetail(regionCode)`.
- Produces: a profile-address picker that can search by name/code, select a result, resolve its path and write province/city/district codes before profile save.
- [ ] **Step 1: Write a failing result-normalization and selected-path test**
```js
assert.deepStrictEqual(RegionPages.buildRegionSelection(result), {
provinceCode: '510000',
cityCode: '510100',
districtCode: '510104'
});
```
- [ ] **Step 2: Run the focused test to verify failure**
Run: `node tests\region-pages.test.js`
Expected: failure because `buildRegionSelection` is not exported.
- [ ] **Step 3: Add the profile search control and selection behavior**
```js
var path = await api.regionPath(regionCode);
var selection = buildRegionSelection(path);
applyRegionSelection(picker, selection);
await api.regionDetail(regionCode);
```
- [ ] **Step 4: Run focused tests**
Run: `node tests\region-pages.test.js; node tests\profile-pages.test.js`
Expected: both scripts print their pass messages.
## Task 4: Complete Single and Resumable File Upload
**Files:**
- Modify: `public/js/upload-pages.js`
- Modify: `utils/ApiClient.js`
- Modify: `tests/upload-pages.test.js`
- Modify: `tests/api-client.test.js`
**Interfaces:**
- Consumes: `uploadFile(file)`, `initResumableUpload(body)`, `uploadChunk(body)`, `completeResumableUpload(body)`.
- Produces: `uploadFileForPage(file, options)` which uses single upload for files at or below the configured threshold and the init/chunk/complete sequence for larger files.
- [ ] **Step 1: Write failing upload-strategy tests**
```js
assert.strictEqual(UploadPages.getUploadMode({ size: 1024 }, 4194304), 'single');
assert.strictEqual(UploadPages.getUploadMode({ size: 4194305 }, 4194304), 'resumable');
```
- [ ] **Step 2: Run focused tests to verify failure**
Run: `node tests\upload-pages.test.js; node tests\api-client.test.js`
Expected: failure because `getUploadMode` does not exist.
- [ ] **Step 3: Implement sequential resumable upload**
```js
var init = await api.initResumableUpload(initBody);
for (index = 0; index < chunks.length; index += 1) {
await api.uploadChunk({ uploadId: init.uploadId, chunkIndex: index, chunkMd5: chunkMd5, file: chunks[index] });
}
return api.completeResumableUpload({ uploadId: init.uploadId, fileMd5: fileMd5, fileSize: file.size });
```
- [ ] **Step 4: Run focused tests**
Run: `node tests\upload-pages.test.js; node tests\api-client.test.js`
Expected: both scripts print their pass messages.
## Task 5: Record and Enforce Backend-Contract Blocks
**Files:**
- Modify: `docs/pc-api-page-integration-tracker-2026-07-09.md`
- Modify: `tests/pc-endpoint-coverage.test.js`
**Interfaces:**
- Consumes: the legacy captcha response, phone SMS scene requirements, and file-reference required business fields from the PC YAML.
- Produces: three named blocked operations with an exact missing backend input and no speculative page request.
- [ ] **Step 1: Assert exact blocker reasons**
```js
assert.strictEqual(ENDPOINTS['GET /auth/code'].reason, 'legacy captcha has no PC request field');
assert.strictEqual(ENDPOINTS['PUT /genealogy/pc/auth/phone'].reason, 'PC/H5 phone SMS scene is not documented');
assert.strictEqual(ENDPOINTS['POST /genealogy/pc/files/reference'].reason, 'PC business table and ID contract is not documented');
```
- [ ] **Step 2: Run the test to verify failure**
Run: `node tests\pc-endpoint-coverage.test.js`
Expected: failure until all three reasons are explicit.
- [ ] **Step 3: Update the tracker without adding a fake request**
```md
| Blocked operation | Missing backend contract | Page behavior |
| --- | --- | --- |
| `GET /auth/code` | legacy code submission field | no page action |
```
- [ ] **Step 4: Run the test to verify it passes**
Run: `node tests\pc-endpoint-coverage.test.js`
Expected: `pc-endpoint-coverage tests passed`.
## Task 6: Final Verification and User Test Script
**Files:**
- Modify: `docs/pc-api-page-integration-tracker-2026-07-09.md`
- [ ] **Step 1: Run syntax checks**
Run: `node --check utils\ApiClient.js`
Run: `node --check public\js\auth-pages.js`
Run: `node --check public\js\region-pages.js`
Run: `node --check public\js\upload-pages.js`
Expected: each command exits with code 0.
- [ ] **Step 2: Run the complete Node suite**
Run: `Get-ChildItem -Path tests -Filter *.test.js | ForEach-Object { & node $_.FullName; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } }`
Expected: every test file prints its pass message.
- [ ] **Step 3: Check the diff**
Run: `git diff --check`
Expected: exit code 0; CRLF conversion notices are informational only.
- [ ] **Step 4: Record manual acceptance steps**
Record the precise browser order: register, password login, SMS login, profile display/edit, reset password, password change, logout, account deactivation, address search, single upload, resumable upload, then each documented backend blocker.
## Self-Review
- Coverage: Tasks 1-5 assign every documented operation an implementation owner or an explicit backend block; Task 6 provides the requested sequential test script.
- Contract discipline: `ApiClient` remains the only route owner; page modules do not create undocumented paths, scene codes, business tables or identifiers.
- Scope: pages without callable PC paths remain outside implementation; their schemas are not treated as endpoints.
@@ -0,0 +1,139 @@
# 官网静态页面补齐 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:** 补齐官网新闻、法律文档入口、通用状态与工单页面,使无接口的线上服务不再伪装为可用功能。
**Architecture:** 新增静态 HTML 页面共用 `public/css/site-info.css` 和现有官网头尾布局。新闻页只链接现有静态文章与公告详情;法律页面明确标明待法务确认;工单页面复用 `pending-pages.js` 阻止无接口提交。
**Tech Stack:** 静态 HTML、原生 CSS、原生 JavaScript、Node LTS `node:test`
## Global Constraints
- 保留现有页面、布局、视觉类名和导航结构,不删除用户设计。
- 不新增 `PC.openapi2.json` 外的接口调用,不伪造新闻接口、法律协议、工单列表或工单详情数据。
- 法律页面只能显示“待法务确认”说明,发布前必须替换为主体与法务确认的正式文本。
- 所有新增注释使用中文;不写入测试账号或密码。
- 当前工作区含用户未提交修改,不执行提交、重置或清理操作。
---
### Task 1: 建立静态页面约束测试
**Files:**
- Create: `tests/public-static-pages.test.js`
**Interfaces:**
- Consumes: 静态页面文件、`data-feature-status="pending"` 和本地 `href`
- Produces: 页面存在、关键说明、状态脚本与本地链接的可重复校验。
- [x] **Step 1: 写入失败测试**
测试必须断言存在以下文件:
```js
const staticPages = [
'news.html', 'privacy.html', 'children-privacy.html', 'terms.html',
'not-found.html', 'forbidden.html', 'maintenance.html',
'my-tickets.html', 'ticket-detail.html'
];
```
同时断言:`news.html` 含有新闻分类锚点并链接 `article-detail.html``notice-detail.html`;三个法律页面含“待法务确认”;`submit-ticket.html``my-tickets.html``ticket-detail.html` 有待开发状态和 `pending-pages.js`;新页面中的本地 `href` 全部存在。
- [x] **Step 2: 运行失败测试**
Run: `$env:Path = 'C:\\Program Files\\nodejs;' + $env:Path; node --test tests/public-static-pages.test.js`
Expected: 新页面不存在导致测试失败。
### Task 2: 建立通用静态页面外观与新闻、法律入口
**Files:**
- Create: `public/css/site-info.css`
- Create: `news.html`
- Create: `privacy.html`
- Create: `children-privacy.html`
- Create: `terms.html`
- Modify: `index.html`
**Interfaces:**
- Consumes: 现有 `public/css/public.css``site-header``footer``container``btn``card` 样式。
- Produces: 新闻分类和详情跳转,以及能从首页底部访问的法律入口。
- [x] **Step 1: 创建复用样式**
`site-info.css` 提供 `.site-info-hero``.site-info-layout``.site-info-card``.site-info-status``.site-info-links`,仅处理本轮新增页面的排版和窄屏适配。
- [x] **Step 2: 创建新闻页面**
`news.html` 必须有“平台公告”“家谱文化”分类锚点,并将两条静态内容分别链接到 `notice-detail.html``article-detail.html`
- [x] **Step 3: 创建法律页面并连接首页**
三个法律页面均写明“待法务确认”,并互相提供跳转链接。首页 `footer-legal` 增加三个页面链接。
### Task 3: 建立状态与工单边界
**Files:**
- Create: `not-found.html`
- Create: `forbidden.html`
- Create: `maintenance.html`
- Create: `my-tickets.html`
- Create: `ticket-detail.html`
- Modify: `submit-ticket.html`
**Interfaces:**
- Consumes: `PageAvailability.init()` 读取的待开发页面属性。
- Produces: 三种无接口提交的工单状态,和可返回首页、帮助中心的静态状态页。
- [x] **Step 1: 创建三个通用状态页**
三个页面分别显示“页面不存在”“无访问权限”“系统维护中”,并提供 `index.html` 返回入口。
- [x] **Step 2: 创建工单列表与详情状态页**
`my-tickets.html``ticket-detail.html` 均使用:
```html
<body data-feature-status="pending" data-feature-message="在线工单查询服务正在开发中,当前页面仅保留设计预览。">
```
并在 `public/js/pending-pages.js` 后仅放行帮助中心链接。
- [x] **Step 3: 收束提交工单表单**
`submit-ticket.html` 增加:
```html
<body class="page-submit-ticket" data-feature-status="pending" data-feature-message="在线提交工单服务正在开发中,请先通过帮助中心了解常见问题。">
```
`ApiClient.js` 后加载状态脚本,并为“返回帮助中心”链接添加 `data-feature-link="available"`
### Task 4: 回写进度与验证
**Files:**
- Modify: `docs/规划.md`
- Modify: `docs/superpowers/plans/2026-07-11-public-static-pages.md`
- Test: `tests/*.test.js`
**Interfaces:**
- Consumes: 新增静态页面和全部 HTML 本地脚本引用。
- Produces: 第四轮进度记录和验证结果。
- [x] **Step 1: 写入第四轮完成状态**
`docs/规划.md` 的“实施进度”中增加静态官网页面补齐已完成,并注明法律文本需法务替换。
- [x] **Step 2: 执行完整测试**
Run: `$env:Path = 'C:\\Program Files\\nodejs;' + $env:Path; npm.cmd test`
Expected: 所有 Node 测试通过。
- [x] **Step 3: 执行静态检查**
Run: PowerShell 扫描全部 HTML 的 `src="*.js"`,随后运行 `git diff --check`
Expected: 每个本地脚本文件存在,且没有空白格式错误。
@@ -0,0 +1,112 @@
# 剩余用户 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:** 让剩余未有正式接口支持的用户 PC 业务入口显示明确状态,避免跳转到看似可用的分享、会员或家谱管理流程。
**Architecture:** 复用 `public/js/pending-pages.js` 的状态契约和链接收束规则。仅标记 `profile-family-home.html``profile-share.html``profile-services.html`;账号主页、个人资料、安全设置和家族圈动态继续使用现有真实功能。
**Tech Stack:** 原生 JavaScript、静态 HTML/CSS、Node LTS `node:test`
## Global Constraints
- 保留现有页面、布局、视觉类名和导航结构,不删除用户设计。
- `PC.openapi2.json` 中不存在家谱主页、邀请奖励和会员订单的正式接口,不得伪造请求或静态数据为真实业务。
- `profile-family-home.html` 的家族圈动态入口继续跳转到 `profile-feed.html`
- 所有新增注释使用中文;不写入测试账号或密码。
- 当前工作区含用户未提交修改,不执行提交、重置或清理操作。
---
### Task 1: 扩展入口状态测试
**Files:**
- Modify: `tests/pending-pages.test.js`
**Interfaces:**
- Consumes: 目标页面的 `data-feature-status="pending"`、状态脚本引用与 `data-feature-link="available"`
- Produces: 剩余业务入口的静态约束,及真实账户页面不被误标记的约束。
- [x] **Step 1: 写入失败测试**
新增以下数组并加入现有待开发页面检查:
```js
const residualPendingPages = [
'profile-family-home.html',
'profile-share.html',
'profile-services.html'
];
```
新增断言:`profile-family-home.html``profile-feed.html` 链接必须带有 `data-feature-link="available"``profile.html``profile-data.html``profile-security.html` 不得含待开发状态标记。
- [x] **Step 2: 运行失败测试**
Run: `$env:Path = 'C:\\Program Files\\nodejs;' + $env:Path; node --test tests/pending-pages.test.js`
Expected: 三个剩余入口尚未标记,测试失败。
### Task 2: 标记剩余业务入口
**Files:**
- Modify: `profile-family-home.html`
- Modify: `profile-share.html`
- Modify: `profile-services.html`
- Test: `tests/pending-pages.test.js`
**Interfaces:**
- Consumes: `PageAvailability.init()` 读取的页面状态属性,和 `PageAvailability.shouldBlockLink(link)` 的可用入口约定。
- Produces: 状态提示、业务链接拦截,以及唯一保留的动态入口。
- [x] **Step 1: 标记页面状态**
分别在三个 `body` 中增加 `data-feature-status="pending"` 和对应中文提示:家谱主页使用“家谱概览与管理服务正在开发中,当前页面仅保留设计预览。”;应用分享使用“应用分享与邀请奖励服务正在开发中,当前页面仅保留设计预览。”;帮助与服务使用“会员与在线服务正在开发中,当前页面仅保留设计预览。”
- [x] **Step 2: 加载统一状态脚本并放行动态入口**
每页都在 `utils/ApiClient.js` 后增加:
```html
<script src="utils/ApiClient.js"></script>
<script src="public/js/pending-pages.js"></script>
```
`profile-family-home.html` 中将动态链接改为:
```html
<a class="btn primary magnetic" href="profile-feed.html" data-feature-link="available">发布动态</a>
```
- [x] **Step 3: 运行测试确认通过**
Run: `$env:Path = 'C:\\Program Files\\nodejs;' + $env:Path; node --test tests/pending-pages.test.js`
Expected: 全部状态页面和真实账户页面断言通过。
### Task 3: 更新进度与验证
**Files:**
- Modify: `docs/规划.md`
- Modify: `docs/superpowers/plans/2026-07-11-residual-pc-entry-closure.md`
- Test: `tests/*.test.js`
**Interfaces:**
- Consumes: 全部 HTML 的本地脚本引用。
- Produces: 已完成第三轮进度和可重复验证记录。
- [x] **Step 1: 写入第三轮完成状态**
`docs/规划.md` 的“实施进度”中增加已完成第三轮,说明三类剩余 PC 业务入口已收口,家族圈动态未受影响。
- [x] **Step 2: 执行完整测试**
Run: `$env:Path = 'C:\\Program Files\\nodejs;' + $env:Path; npm.cmd test`
Expected: 所有 Node 测试通过。
- [x] **Step 3: 执行静态检查**
Run: PowerShell 扫描全部 HTML 的 `src="*.js"`,随后运行 `git diff --check`
Expected: 每个本地脚本文件存在,且没有空白格式错误。
@@ -1,29 +0,0 @@
# Registration Token State Design
## Status
Pending written-spec review.
## Goal
Registration creates an account but never creates or retains a browser login state. Only a successful password login or SMS login may persist an authentication token.
## Contract Owner
`utils/ApiClient.js` owns browser token persistence. Pages invoke API methods and redirect according to their existing form configuration; they do not read, write, or clear the token directly.
## Required Behavior
1. `login()` and `loginBySms()` persist the token returned by their successful responses under `genealogy_auth_token`.
2. `register()` posts the existing registration request unchanged and returns its response unchanged.
3. After a successful `register()` response, `register()` clears `genealogy_auth_token`. This applies even when the response includes `token`, `accessToken`, or `tokenValue`.
4. If `register()` fails, token storage is not changed.
5. `register.html` continues redirecting to `login.html` after `register()` resolves.
## Scope
This change does not add a profile-page login guard, 401 redirect handling, or logout UI. Those are separate authentication-lifecycle steps that must consume the same `ApiClient` token contract.
## Verification
The API-client tests must prove that successful registration removes a previously stored token and does not store a token returned by registration. They must also retain coverage showing password and SMS login still store their returned tokens.
@@ -1,47 +0,0 @@
# Auth To Profile Flow Design
## Status
Approved for implementation.
## Goal
Complete the PC journey from registration through login and personal-profile retrieval, with one token owner, deterministic redirects, and a single recorded API base address.
## Endpoint Configuration
`config.js` is the sole runtime owner of the API base address. Both development and production currently use `http://182.61.18.23:8080`. `tests/config.test.js` must assert that value. The PC integration tracker must record the change on 2026-07-11 and label prior test-domain references as historical rather than current configuration.
## Token Contract
`utils/ApiClient.js` owns token persistence under `genealogy_auth_token`.
1. Successful password login and SMS login must receive a nonempty response token, persist it, and then allow navigation to `profile.html`. The current backend response uses `data.access_token`; the client also accepts `token`, `accessToken`, and `tokenValue` for declared compatibility.
2. A login response without a token is an authentication failure. The login page remains in place and no profile navigation occurs.
3. Successful registration removes any existing token and navigates to `login.html`. Registration never persists a response token.
4. A failed registration leaves existing token storage unchanged.
5. `logout()` clears token storage in a `finally` path, so a failed or expired logout request cannot leave a local session behind.
## Page Flow
1. Registration validates fields, obtains `WEB_H5_REGISTER` captcha proof, posts `/genealogy/pc/auth/register`, clears token through `ApiClient.register()`, and redirects to `login.html`.
2. Password and SMS login validate fields, obtain `WEB_H5_LOGIN` captcha proof, post their respective login endpoint, persist the returned token through `ApiClient`, and redirect to `profile.html`.
3. `profile.html` and `profile-data.html` check for a token before requesting `/genealogy/pc/auth/profile`. Without one, they redirect to `index.html` without making the profile request.
4. A profile-read or profile-update error with HTTP status `401` or business code `401` clears token storage and redirects to `index.html`. Other errors remain on the current page and show the existing error message.
5. Confirmed logout calls `/genealogy/pc/auth/logout`, clears token regardless of the request outcome, and redirects to `index.html`.
## Ownership
- `config.js`: API base address.
- `utils/ApiClient.js`: login-token validation, persistence, clearing, and logout cleanup.
- `public/js/profile-pages.js`: profile-page token guard and unauthorized redirect.
- `public/js/profile-common.js`: confirmed logout action and redirect.
- `docs/pc-api-page-integration-tracker-2026-07-09.md`: current address record and end-to-end authentication status.
## Verification
Tests must prove the configured address, registration state, both login token paths, missing-token login rejection, logout cleanup after both success and failure, and profile unauthorized classification. Focused configuration, API-client, profile-page, and authentication tests must pass before the full Node test suite is run.
## Scope
This flow covers registration, password login, SMS login, profile retrieval and update on the two pages that load `/genealogy/pc/auth/profile`, and the existing profile logout action. Password reset, phone change, account deactivation, and profile pages without profile API loading remain outside this change.
+99
View File
@@ -0,0 +1,99 @@
# 规划
## 项目目标
将“代代相传”建设为由官网、用户 PC 管理端、用户 APP 端和平台后台组成的家谱服务。四个端共享家谱、成员、内容和权限规则,但分别交付,不能用用户 PC 页面替代 APP 或平台后台。
## 当前基线
- 当前工作区共有 55 个 HTML 页面,现有页面之间没有指向不存在 HTML 页的站内链接。
- `PC.openapi2.json` 是当前后端正式接口源,包含 37 个操作:认证登录 11、验证中心 4、文件上传 6、行政区划 4、家族圈 12。
- 已完成真实接口闭环:注册、密码登录、短信登录、找回密码、个人资料、安全设置、头像上传、地区选择、家族圈动态。
- 家谱列表、家谱创建/加入、成员、权限、世系、谱文、相册、视频、贺礼、成长、备忘、功德、消息、反馈等页面已保留设计,但尚无对应正式接口,不得伪造请求或静态数据为真实业务。
## 实施进度
- [x] 第一轮:家谱基础页面状态收口。已覆盖家谱列表、创建与加入、审核、家族管理、邀请、世系和字辈页面;未开发功能会显示统一提示并阻止伪造操作。
- [x] 第二轮:内容与协作页面状态收口。已覆盖除家族圈动态外的内容、记录、消息、反馈、管理员权限与资料提醒页面;家族圈动态保持真实功能并保留跳转入口。
- [x] 第三轮:剩余用户 PC 业务入口收口。已覆盖家谱主页、应用分享和帮助与会员服务页面;家族圈动态入口保持真实跳转。
- [x] 第四轮:官网静态页面补齐。已新增新闻、法律文档入口、通用状态和工单页面;在线工单功能明确处于开发中,法律正文必须在发布前由法务替换确认。
## 产品边界
### 官网
保留首页、数字家谱、家谱广场、姓氏百科、家族文化、应用下载、帮助中心、关于我们、搜索、公告和工单入口。
需要补齐的独立页面:
1. 新闻列表、新闻分类、新闻详情。
2. 隐私政策、儿童个人信息处理规则、服务协议。
3. 我的工单、工单详情。
4. 404、无权限、系统维护等通用状态页。
### 用户 PC 管理端
现有页面覆盖了思维导图中的大部分视觉入口:我的家谱、家谱主页、世系图、字辈、谱文、相册、视频、家族圈、贺礼、成长日志、备忘录、功德录、家族管理、管理员权限、入谱审核、消息、个人资料和安全设置。
需要补齐的关键页面:
1. 家族成员名册与成员详情,用于按成员筛选、查看资料、维护角色;世系图不能替代成员名册。
2. 家族圈动态详情,用于承载可分享链接、完整评论和通知跳转。
3. 无家谱首次引导、无权限、空列表等状态视图。
### 用户 APP 端
APP 是独立交付物,负责移动端查看家谱、上传照片/视频、发布内容、消息和个人中心。当前 PC 项目只需共享接口契约和视觉规范,不直接复制为 APP 页面。
### 平台后台
平台后台是独立项目,负责全站家谱审核、内容治理、会员/VIP、系统设置和运营数据。它不应通过隐藏按钮混入用户 PC 端。
## 后端接口开发顺序
### 第一阶段:家谱基础
1. 我的家谱列表、家谱详情、创建家谱、加入家谱、加入审核。
2. 家谱成员列表、成员详情、邀请、移除、角色与管理员权限。
3. 家谱入口必须返回真实 `genealogyId`,让世系、内容和家族圈能从同一上下文进入。
### 第二阶段:世系与资料
1. 世系树查询。
2. 成员新增、编辑、删除。
3. 父母、配偶、子女等关系维护。
4. 字辈列表与维护。
### 第三阶段:内容与协作
1. 谱文及分类。
2. 相册、照片、视频。
3. 贺礼、成长日志、备忘录、功德录。
4. 消息中心、点赞评论通知、生日提醒、入谱审核通知。
5. 意见反馈、工单列表与详情。
### 第四阶段:官网内容与合规
1. 新闻资讯、公告、公开内容检索。
2. 隐私、未成年人保护和服务协议。
3. 客服工单的提交、查询和回复。
## 契约要求
每个业务对象都必须提供明确 DTO,至少包括列表、详情和写入请求模型。禁止前端依赖泛型 `object` 或猜测字段名。
最优先需要后端补充的模型:
1. `GenealogyDTO``GenealogyMemberDTO``MemberRoleDTO`
2. `LineagePersonDTO``LineageRelationDTO``GenerationDTO`
3. `FeedDTO``CommentDTO`
4. `ArticleDTO``AlbumDTO``VideoDTO``CeremonyDTO``GrowthLogDTO``MemoDTO``MeritDTO`
5. `NotificationDTO``TicketDTO``NewsDTO`
## 交付规则
1. 有正式接口和 DTO 的页面,才接入真实读写功能。
2. 未开发接口对应的页面保留设计,但明确显示“功能开发中”或空状态,不伪造成功数据。
3. 家谱业务全部从真实 `genealogyId` 进入,不写死示例家谱编号。
4. 访客、普通成员、家谱管理员、平台管理员必须有独立权限边界。
5. 测试账号仅用于运行时验收;账号和密码不写入仓库、文档、测试、日志或配置。
-1
View File
@@ -101,7 +101,6 @@
<script src="utils/axios.js"></script>
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
<script src="public/js/promotion-pages.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
</html>
-1
View File
@@ -136,7 +136,6 @@
<script src="utils/axios.js"></script>
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
<script src="public/js/genealogy-pages.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
</html>
+53
View File
@@ -0,0 +1,53 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>无访问权限 - 代代相传</title>
<link rel="stylesheet" href="public/css/public.css" />
<link rel="stylesheet" href="public/css/site-info.css" />
</head>
<body>
<header class="site-header">
<div class="container nav">
<a class="brand magnetic" href="index.html"
><img
class="brand-logo"
src="public/images/logo.png"
alt="我们的家谱,代代相传"
/></a>
<nav class="nav-links">
<a href="index.html">首页</a><a href="help.html">帮助中心</a
><a href="about.html">关于我们</a>
</nav>
<div class="nav-actions"><a href="login.html">登录</a></div>
</div>
</header>
<main>
<section class="site-info-section">
<div class="container">
<div class="site-info-status">
<div class="site-info-mark">!</div>
<h2>无访问权限</h2>
<p>
当前账号没有访问此内容的权限。请确认登录账号,或联系家谱管理员处理。
</p>
<div class="site-info-actions">
<a class="btn primary magnetic" href="index.html">返回首页</a
><a class="btn ghost magnetic" href="help.html">查看帮助</a>
</div>
</div>
</div>
</section>
</main>
<footer class="footer">
<div class="container">
<div class="footer-legal">
<div>© 2026 代代相传. All rights reserved.</div>
<a href="terms.html">服务协议</a>
</div>
</div>
</footer>
<script src="public/js/page-effects.js"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
-1
View File
@@ -124,7 +124,6 @@
<script src="utils/axios.js"></script>
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
<script src="public/js/help-pages.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
</html>
+4 -1
View File
@@ -603,7 +603,7 @@
<div>
<h4>文化内容</h4>
<a>家族文化</a><a>姓氏百科</a><a>家谱知识</a
><a href="notice-detail.html">平台公告</a>
><a href="news.html">新闻资讯</a>
</div>
<div>
<h4>联系我们</h4>
@@ -614,6 +614,9 @@
</div>
<div class="footer-legal">
<div>© 2026 代代相传. All rights reserved.</div>
<a href="privacy.html">隐私政策</a>
<a href="children-privacy.html">儿童个人信息处理规则</a>
<a href="terms.html">服务协议</a>
<a href="https://beian.miit.gov.cn" target="_blank" rel="noopener"
>ICP备案:蜀ICP备2024044594号-1</a
>
-1
View File
@@ -36,6 +36,5 @@
<script src="utils/axios.js"></script>
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
<script src="public/js/genealogy-pages.js"></script>
</body>
</html>
+53
View File
@@ -0,0 +1,53 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>系统维护中 - 代代相传</title>
<link rel="stylesheet" href="public/css/public.css" />
<link rel="stylesheet" href="public/css/site-info.css" />
</head>
<body>
<header class="site-header">
<div class="container nav">
<a class="brand magnetic" href="index.html"
><img
class="brand-logo"
src="public/images/logo.png"
alt="我们的家谱,代代相传"
/></a>
<nav class="nav-links">
<a href="index.html">首页</a><a href="help.html">帮助中心</a
><a href="about.html">关于我们</a>
</nav>
<div class="nav-actions"><a href="login.html">登录</a></div>
</div>
</header>
<main>
<section class="site-info-section">
<div class="container">
<div class="site-info-status">
<div class="site-info-mark"></div>
<h2>系统维护中</h2>
<p>
我们正在进行必要的服务维护,请稍后再试。账号与家谱资料不会因维护而丢失。
</p>
<div class="site-info-actions">
<a class="btn primary magnetic" href="index.html">返回首页</a
><a class="btn ghost magnetic" href="news.html">查看新闻资讯</a>
</div>
</div>
</div>
</section>
</main>
<footer class="footer">
<div class="container">
<div class="footer-legal">
<div>© 2026 代代相传. All rights reserved.</div>
<a href="privacy.html">隐私政策</a>
</div>
</div>
</footer>
<script src="public/js/page-effects.js"></script>
</body>
</html>
+69
View File
@@ -0,0 +1,69 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>我的工单 - 代代相传</title>
<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="在线工单查询服务正在开发中,当前页面仅保留设计预览。"
>
<header class="site-header">
<div class="container nav">
<a class="brand magnetic" href="index.html"
><img
class="brand-logo"
src="public/images/logo.png"
alt="我们的家谱,代代相传"
/></a>
<nav class="nav-links">
<a href="index.html">首页</a
><a class="active" href="help.html">帮助中心</a
><a href="news.html">新闻资讯</a>
</nav>
<div class="nav-actions"><a href="login.html">登录</a></div>
</div>
</header>
<main>
<section class="site-info-hero">
<div class="container">
<div>
<div class="site-info-kicker">My Tickets</div>
<h1>我的工单</h1>
<p>工单列表、处理进度与客服回复将在后续工单接口交付后接入。</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-actions">
<a
class="btn ghost magnetic"
href="help.html"
data-feature-link="available"
>返回帮助中心</a
>
</div>
</div>
</div>
</section>
</main>
<footer class="footer">
<div class="container">
<div class="footer-legal">
<div>© 2026 代代相传. All rights reserved.</div>
<a href="privacy.html">隐私政策</a>
</div>
</div>
</footer>
<script src="public/js/pending-pages.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
</html>
+83
View File
@@ -0,0 +1,83 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>新闻资讯 - 代代相传</title>
<link rel="stylesheet" href="public/css/public.css" />
<link rel="stylesheet" href="public/css/site-info.css" />
</head>
<body>
<header class="site-header">
<div class="container nav">
<a class="brand magnetic" href="index.html"
><img
class="brand-logo"
src="public/images/logo.png"
alt="我们的家谱,代代相传"
/></a>
<nav class="nav-links">
<a href="index.html">首页</a><a href="genealogy.html">数字家谱</a
><a href="plaza.html">家谱广场</a
><a class="active" href="news.html">新闻资讯</a
><a href="help.html">帮助中心</a><a href="about.html">关于我们</a>
</nav>
<div class="nav-actions">
<a href="login.html">登录</a><a href="register.html">注册</a>
</div>
</div>
</header>
<main>
<section class="site-info-hero">
<div class="container">
<div>
<div class="site-info-kicker">News Center</div>
<h1>新闻资讯</h1>
<p>
集中阅读平台公告与家谱文化内容。当前为静态展示,后续由新闻内容接口统一提供列表、分类和详情。
</p>
</div>
<div class="site-info-mark"></div>
</div>
</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>
<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>
</div>
</aside>
</div>
</section>
</main>
<footer class="footer">
<div class="container">
<div class="footer-legal">
<div>© 2026 代代相传. All rights reserved.</div>
<a href="privacy.html">隐私政策</a
><a href="children-privacy.html">儿童个人信息处理规则</a
><a href="terms.html">服务协议</a>
</div>
</div>
</footer>
<script src="public/js/page-effects.js"></script>
</body>
</html>
+51
View File
@@ -0,0 +1,51 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>页面不存在 - 代代相传</title>
<link rel="stylesheet" href="public/css/public.css" />
<link rel="stylesheet" href="public/css/site-info.css" />
</head>
<body>
<header class="site-header">
<div class="container nav">
<a class="brand magnetic" href="index.html"
><img
class="brand-logo"
src="public/images/logo.png"
alt="我们的家谱,代代相传"
/></a>
<nav class="nav-links">
<a href="index.html">首页</a><a href="help.html">帮助中心</a
><a href="about.html">关于我们</a>
</nav>
<div class="nav-actions"><a href="login.html">登录</a></div>
</div>
</header>
<main>
<section class="site-info-section">
<div class="container">
<div class="site-info-status">
<div class="site-info-mark">404</div>
<h2>页面不存在</h2>
<p>你访问的页面可能已移动、已删除或地址输入有误。</p>
<div class="site-info-actions">
<a class="btn primary magnetic" href="index.html">返回首页</a
><a class="btn ghost magnetic" href="help.html">前往帮助中心</a>
</div>
</div>
</div>
</section>
</main>
<footer class="footer">
<div class="container">
<div class="footer-legal">
<div>© 2026 代代相传. All rights reserved.</div>
<a href="privacy.html">隐私政策</a>
</div>
</div>
</footer>
<script src="public/js/page-effects.js"></script>
</body>
</html>
-1
View File
@@ -111,7 +111,6 @@
<script src="utils/axios.js"></script>
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
<script src="public/js/promotion-pages.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
</html>
+6
View File
@@ -0,0 +1,6 @@
{
"private": true,
"scripts": {
"test": "node --test tests/*.test.js"
}
}
-1
View File
@@ -143,7 +143,6 @@
<script src="utils/axios.js"></script>
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
<script src="public/js/genealogy-pages.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
</html>
+72
View File
@@ -0,0 +1,72 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>隐私政策 - 代代相传</title>
<link rel="stylesheet" href="public/css/public.css" />
<link rel="stylesheet" href="public/css/site-info.css" />
</head>
<body>
<header class="site-header">
<div class="container nav">
<a class="brand magnetic" href="index.html">
<img class="brand-logo" src="public/images/logo.png" alt="我们的家谱,代代相传" />
</a>
<nav class="nav-links">
<a href="index.html">首页</a>
<a href="news.html">新闻资讯</a>
<a href="help.html">帮助中心</a>
<a href="about.html">关于我们</a>
</nav>
<div class="nav-actions">
<a href="login.html">登录</a>
<a href="register.html">注册</a>
</div>
</div>
</header>
<main>
<section class="site-info-hero">
<div class="container">
<div>
<div class="site-info-kicker">Privacy Policy</div>
<h1>隐私政策</h1>
<p>说明平台处理个人信息的规则、用户权利与联系渠道。</p>
</div>
<div class="site-info-mark"></div>
</div>
</section>
<section class="site-info-section">
<div class="container site-info-layout">
<article class="site-info-panel">
<div class="site-info-notice">
待法务确认:本页面仅建立政策访问入口,不构成已生效的隐私政策。发布前必须由运营主体与法务确认完整文本、版本号和生效日期。
</div>
<h2>正式文本应涵盖</h2>
<p>
个人信息处理目的、收集范围、保存期限、共享规则、用户查阅更正删除权利,以及个人信息保护负责人的联系渠道。
</p>
</article>
<aside class="site-info-panel">
<h2>相关文档</h2>
<div class="site-info-links">
<a href="children-privacy.html">儿童个人信息处理规则</a>
<a href="terms.html">服务协议</a>
<a href="help.html">帮助中心</a>
</div>
</aside>
</div>
</section>
</main>
<footer class="footer">
<div class="container">
<div class="footer-legal">
<div>© 2026 代代相传. All rights reserved.</div>
<a href="news.html">新闻资讯</a>
<a href="terms.html">服务协议</a>
</div>
</div>
</footer>
<script src="public/js/page-effects.js"></script>
</body>
</html>
+2 -2
View File
@@ -8,7 +8,7 @@
<link rel="stylesheet" href="public/layui/css/layui.css" />
<link rel="stylesheet" href="public/css/profile-module.css" />
</head>
<body class="page-profile-module" data-admin-permission-page>
<body class="page-profile-module" data-admin-permission-page data-feature-status="pending" data-feature-message="管理员权限服务正在开发中,当前页面仅保留设计预览。">
<header class="site-header">
<div class="container nav">
<a class="brand magnetic" href="profile.html"
@@ -83,7 +83,7 @@
<script src="utils/axios.js"></script>
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
<script src="public/js/member-admin-pages.js"></script>
<script src="public/js/pending-pages.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
</html>
+2 -3
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-album-page>
<body class="page-profile-module" data-album-page data-feature-status="pending" data-feature-message="家族相册服务正在开发中,当前页面仅保留设计预览。">
<header class="site-header">
<div class="container nav">
<a class="brand magnetic" href="profile.html"
@@ -134,8 +134,7 @@
<script src="utils/axios.js"></script>
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
<script src="public/js/upload-pages.js"></script>
<script src="public/js/album-pages.js"></script>
<script src="public/js/pending-pages.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
</html>
+2 -3
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-article-edit-page>
<body class="page-profile-module" data-article-edit-page data-feature-status="pending" data-feature-message="谱文服务正在开发中,当前页面仅保留设计预览。">
<header class="site-header">
<div class="container nav">
<a class="brand magnetic" href="profile.html"
@@ -123,8 +123,7 @@
<script src="utils/axios.js"></script>
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
<script src="public/js/upload-pages.js"></script>
<script src="public/js/article-pages.js"></script>
<script src="public/js/pending-pages.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
</html>
+2 -2
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-article-page>
<body class="page-profile-module" data-article-page data-feature-status="pending" data-feature-message="谱文服务正在开发中,当前页面仅保留设计预览。">
<header class="site-header">
<div class="container nav">
<a class="brand magnetic" href="profile.html"
@@ -92,7 +92,7 @@
<script src="utils/axios.js"></script>
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
<script src="public/js/article-pages.js"></script>
<script src="public/js/pending-pages.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
</html>
+3 -3
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-content-page>
<body class="page-profile-module" data-content-page data-feature-status="pending" data-feature-message="除家族圈动态外,其他内容服务正在开发中,当前页面仅保留设计预览。">
<header class="site-header">
<div class="container nav">
<a class="brand magnetic" href="profile.html"
@@ -87,7 +87,7 @@
<h3>贺礼邀请</h3>
<p>创建婚礼、升学、生日等贺礼邀请。</p></a
>
<a class="module-card tilt-card" href="profile-feed.html"
<a class="module-card tilt-card" href="profile-feed.html" data-feature-link="available"
><span class="icon"></span>
<h3>发布动态</h3>
<p>向家族圈发布图文动态,可设置置顶和精华。</p></a
@@ -124,7 +124,7 @@
<script src="utils/axios.js"></script>
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
<script src="public/js/article-pages.js"></script>
<script src="public/js/pending-pages.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
</html>
+2 -3
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">
<body class="page-profile-module" data-feature-status="pending" data-feature-message="创建家谱服务正在开发中,当前页面仅保留设计预览。">
<header class="site-header">
<div class="container nav">
<a class="brand magnetic" href="profile.html"
@@ -130,8 +130,7 @@
<script src="utils/axios.js"></script>
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
<script src="public/js/region-pages.js"></script>
<script src="public/js/genealogy-pages.js"></script>
<script src="public/js/pending-pages.js"></script>
<script src="public/js/profile-common.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
+2 -1
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">
<body class="page-profile-module" data-feature-status="pending" data-feature-message="资料提醒服务正在开发中,当前页面仅保留设计预览。">
<header class="site-header">
<div class="container nav">
<a class="brand magnetic" href="profile.html"
@@ -73,6 +73,7 @@
<script src="public/layui/layui.js"></script>
<script src="public/js/lay-config.js"></script>
<script src="public/js/profile-common.js"></script>
<script src="public/js/pending-pages.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
</html>
+2 -2
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">
<body class="page-profile-module" data-feature-status="pending" data-feature-message="家谱列表服务正在开发中,当前页面仅保留设计预览。">
<header class="site-header">
<div class="container nav">
<a class="brand magnetic" href="profile.html"
@@ -107,7 +107,7 @@
<script src="utils/axios.js"></script>
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
<script src="public/js/genealogy-pages.js"></script>
<script src="public/js/pending-pages.js"></script>
<script src="public/js/profile-common.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
+2 -2
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-member-admin-page>
<body class="page-profile-module" data-member-admin-page data-feature-status="pending" data-feature-message="家谱成员与权限服务正在开发中,当前页面仅保留设计预览。">
<header class="site-header">
<div class="container nav">
<a class="brand magnetic" href="profile.html"
@@ -117,7 +117,7 @@
<script src="utils/axios.js"></script>
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
<script src="public/js/member-admin-pages.js"></script>
<script src="public/js/pending-pages.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
</html>
+3 -3
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>
<body class="page-profile-module" data-family-home-page data-feature-status="pending" data-feature-message="家谱概览与管理服务正在开发中,当前页面仅保留设计预览。">
<header class="site-header">
<div class="container nav">
<a class="brand magnetic" href="profile.html"
@@ -23,7 +23,7 @@
</nav>
<div class="nav-actions">
<a href="profile.html">返回中心</a
><a class="btn primary magnetic" href="profile-feed.html">发布动态</a>
><a class="btn primary magnetic" href="profile-feed.html" data-feature-link="available">发布动态</a>
</div>
</div>
</header>
@@ -112,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/lineage-pages.js"></script>
<script src="public/js/pending-pages.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
</html>
+11 -14
View File
@@ -19,7 +19,7 @@
><a class="active" href="profile-feed.html">发布动态</a>
</nav>
<div class="nav-actions">
<a href="profile-feed.html">返回动态</a
<a href="profile-feed.html" data-feed-list-link>返回动态</a
><button class="btn primary magnetic" type="submit" form="feed-edit-form">发布</button>
</div>
</div>
@@ -31,7 +31,7 @@
<div class="module-title-row">
<div>
<h1>发布动态</h1>
<p>面向家族圈发布图文动态,可设置置顶和精华</p>
<p>面向家族圈发布文字和图片动态,与家人交流近况</p>
</div>
</div>
</div>
@@ -48,11 +48,12 @@
<section class="module-panel">
<h2>动态内容</h2>
<form id="feed-edit-form" class="editor-form layui-form" data-feed-form>
<input name="feedType" type="hidden" value="text" />
<div class="editor-field">
<label for="feedContent">内容</label
><textarea
id="feedContent"
name="content"
name="feedContent"
class="js-rich-editor"
placeholder="请输入动态内容"
required
@@ -61,20 +62,12 @@
<div class="editor-field">
<label for="feedMedia">图片 OSS ID</label>
<input id="feedMedia" name="mediaOssIds" type="text" placeholder="多个图片 ID 用英文逗号分隔" />
<!-- 多图上传会追加 OSS ID 到 mediaOssIds 字段 -->
<!-- 当前上传接口一次只接收一个文件;多张图片请使用逗号分隔 OSS ID。 -->
<label class="upload-control" for="feedMediaFile">选择动态图片</label>
<input id="feedMediaFile" class="upload-input" type="file" accept="image/*" data-upload-target="#feedMedia" data-upload-status="#feedMediaStatus" data-upload-multiple="true" />
<input id="feedMediaFile" class="upload-input" type="file" accept="image/*" data-upload-target="#feedMedia" data-upload-status="#feedMediaStatus" />
<p id="feedMediaStatus" class="upload-status">未选择文件</p>
</div>
<div class="editor-two">
<div class="editor-field">
<label for="feedVisibility">可见范围</label>
<select id="feedVisibility" name="visibility">
<option value="2">成员可见</option>
<option value="1">公开</option>
<option value="0">私密</option>
</select>
</div>
<div class="editor-field">
<label for="feedStatus">状态</label>
<select id="feedStatus" name="status">
@@ -82,10 +75,14 @@
<option value="1">草稿</option>
</select>
</div>
<div class="editor-field">
<label for="feedSortOrder">排序值</label>
<input id="feedSortOrder" name="sortOrder" type="number" min="0" value="0" />
</div>
</div>
<div class="bottom-actions">
<button class="btn primary magnetic" type="submit">发布</button
><a class="btn ghost magnetic" href="profile-feed.html"
><a class="btn ghost magnetic" href="profile-feed.html" data-feed-list-link
>取消</a
>
</div>
+11 -25
View File
@@ -21,7 +21,7 @@
</nav>
<div class="nav-actions">
<a href="profile-family-home.html">返回家谱主页</a
><a class="btn primary magnetic" href="profile-feed-edit.html"
><a class="btn primary magnetic" href="profile-feed-edit.html" data-feed-create-link
>发布</a
>
</div>
@@ -34,7 +34,7 @@
<div class="module-title-row">
<div>
<h1>发布动态</h1>
<p>向家族圈发布图文动态,可设置置顶和精华</p>
<p>向家族圈发布文字和图片动态,与家人交流近况</p>
</div>
</div>
</div>
@@ -49,39 +49,25 @@
</aside>
<div class="module-main">
<section class="module-panel">
<h2>动态草稿</h2>
<h2>动态发布</h2>
<div class="form-like">
<p>
<span>内容</span><b>0/200</b
><a class="link-btn" href="profile-feed-edit.html">编辑</a>
<span>内容</span><b>在发布页填写</b
><a class="link-btn" href="profile-feed-edit.html" data-feed-create-link>编辑</a>
</p>
<p>
<span>图片</span><b>可添加多张</b
><a
class="link-btn"
href="#"
data-layer-msg="请选择要上传的图片"
>添加</a
<span>图片</span><b>可添加图片 OSS ID</b
><a class="link-btn" href="profile-feed-edit.html" data-feed-create-link>添加</a
>
</p>
<p>
<span>置顶</span><b></b
><a
class="link-btn"
href="#"
data-layer-select="是|否"
data-select-title="切换状态"
>切换</a
<span>动态类型</span><b>图文动态</b
><a class="link-btn" href="profile-feed-edit.html" data-feed-create-link>编辑</a
>
</p>
<p>
<span>精华</span><b></b
><a
class="link-btn"
href="#"
data-layer-select="是|否"
data-select-title="切换状态"
>切换</a
<span>发布状态</span><b>发布或草稿</b
><a class="link-btn" href="profile-feed-edit.html" data-feed-create-link>设置</a
>
</p>
</div>
+2 -2
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">
<body class="page-profile-module" data-feature-status="pending" data-feature-message="意见反馈服务正在开发中,当前页面仅保留设计预览。">
<header class="site-header">
<div class="container nav">
<a class="brand magnetic" href="profile.html"
@@ -99,7 +99,7 @@
<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/pending-pages.js"></script>
<script src="public/js/profile-common.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
+2 -2
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">
<body class="page-profile-module" data-feature-status="pending" data-feature-message="字辈维护服务正在开发中,当前页面仅保留设计预览。">
<header class="site-header">
<div class="container nav">
<a class="brand magnetic" href="profile.html"
@@ -151,7 +151,7 @@
<script src="utils/axios.js"></script>
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
<script src="public/js/generation-pages.js"></script>
<script src="public/js/pending-pages.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
</html>
+2 -3
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-ceremony-edit-page>
<body class="page-profile-module" data-ceremony-edit-page data-feature-status="pending" data-feature-message="贺礼服务正在开发中,当前页面仅保留设计预览。">
<header class="site-header">
<div class="container nav">
<a class="brand magnetic" href="profile.html"
@@ -123,8 +123,7 @@
<script src="utils/axios.js"></script>
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
<script src="public/js/upload-pages.js"></script>
<script src="public/js/ceremony-pages.js"></script>
<script src="public/js/pending-pages.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
</html>
+2 -2
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-ceremony-page>
<body class="page-profile-module" data-ceremony-page data-feature-status="pending" data-feature-message="贺礼服务正在开发中,当前页面仅保留设计预览。">
<header class="site-header">
<div class="container nav">
<a class="brand magnetic" href="profile.html"
@@ -95,7 +95,7 @@
<script src="utils/axios.js"></script>
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
<script src="public/js/ceremony-pages.js"></script>
<script src="public/js/pending-pages.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
</html>
+2 -3
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-growth-edit-page>
<body class="page-profile-module" data-growth-edit-page data-feature-status="pending" data-feature-message="成长日志服务正在开发中,当前页面仅保留设计预览。">
<header class="site-header">
<div class="container nav">
<a class="brand magnetic" href="profile.html"
@@ -124,8 +124,7 @@
<script src="utils/axios.js"></script>
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
<script src="public/js/upload-pages.js"></script>
<script src="public/js/growth-pages.js"></script>
<script src="public/js/pending-pages.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
</html>
+2 -2
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-growth-page>
<body class="page-profile-module" data-growth-page data-feature-status="pending" data-feature-message="成长日志服务正在开发中,当前页面仅保留设计预览。">
<header class="site-header">
<div class="container nav">
<a class="brand magnetic" href="profile.html"
@@ -84,7 +84,7 @@
<script src="utils/axios.js"></script>
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
<script src="public/js/growth-pages.js"></script>
<script src="public/js/pending-pages.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
</html>
+2 -2
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-invite-page>
<body class="page-profile-module" data-family-invite-page data-feature-status="pending" data-feature-message="家谱成员邀请服务正在开发中,当前页面仅保留设计预览。">
<header class="site-header">
<div class="container nav">
<a class="brand magnetic" href="profile.html"
@@ -72,7 +72,7 @@
<script src="utils/axios.js"></script>
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
<script src="public/js/member-admin-pages.js"></script>
<script src="public/js/pending-pages.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
</html>
+2 -2
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">
<body class="page-profile-module" data-feature-status="pending" data-feature-message="加入家谱服务正在开发中,当前页面仅保留设计预览。">
<header class="site-header">
<div class="container nav">
<a class="brand magnetic" href="profile.html"
@@ -75,7 +75,7 @@
<script src="utils/axios.js"></script>
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
<script src="public/js/join-apply-pages.js"></script>
<script src="public/js/pending-pages.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
</html>
+2 -2
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">
<body class="page-profile-module" data-feature-status="pending" data-feature-message="入谱审核服务正在开发中,当前页面仅保留设计预览。">
<header class="site-header">
<div class="container nav">
<a class="brand magnetic" href="profile.html"
@@ -104,7 +104,7 @@
<script src="utils/axios.js"></script>
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
<script src="public/js/join-apply-pages.js"></script>
<script src="public/js/pending-pages.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
</html>
+2 -3
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-memo-edit-page>
<body class="page-profile-module" data-memo-edit-page data-feature-status="pending" data-feature-message="备忘录服务正在开发中,当前页面仅保留设计预览。">
<header class="site-header">
<div class="container nav">
<a class="brand magnetic" href="profile.html"
@@ -112,8 +112,7 @@
<script src="utils/axios.js"></script>
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
<script src="public/js/upload-pages.js"></script>
<script src="public/js/memo-pages.js"></script>
<script src="public/js/pending-pages.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
</html>
+2 -2
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-memo-page>
<body class="page-profile-module" data-memo-page data-feature-status="pending" data-feature-message="备忘录服务正在开发中,当前页面仅保留设计预览。">
<header class="site-header">
<div class="container nav">
<a class="brand magnetic" href="profile.html"
@@ -89,7 +89,7 @@
<script src="utils/axios.js"></script>
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
<script src="public/js/memo-pages.js"></script>
<script src="public/js/pending-pages.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
</html>
+2 -2
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-merit-edit-page>
<body class="page-profile-module" data-merit-edit-page data-feature-status="pending" data-feature-message="功德录服务正在开发中,当前页面仅保留设计预览。">
<header class="site-header">
<div class="container nav">
<a class="brand magnetic" href="profile.html"
@@ -121,7 +121,7 @@
<script src="utils/axios.js"></script>
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
<script src="public/js/ceremony-pages.js"></script>
<script src="public/js/pending-pages.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
</html>
+2 -2
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-merit-page>
<body class="page-profile-module" data-merit-page data-feature-status="pending" data-feature-message="功德录服务正在开发中,当前页面仅保留设计预览。">
<header class="site-header">
<div class="container nav">
<a class="brand magnetic" href="profile.html"
@@ -90,7 +90,7 @@
<script src="utils/axios.js"></script>
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
<script src="public/js/ceremony-pages.js"></script>
<script src="public/js/pending-pages.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
</html>
+2 -2
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">
<body class="page-profile-module" data-feature-status="pending" data-feature-message="消息与通知服务正在开发中,当前页面仅保留设计预览。">
<header class="site-header">
<div class="container nav">
<a class="brand magnetic" href="profile.html"
@@ -115,7 +115,7 @@
<script src="utils/axios.js"></script>
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
<script src="public/js/notification-pages.js"></script>
<script src="public/js/pending-pages.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
</html>
+5 -4
View File
@@ -63,6 +63,7 @@
<label for="confirmPassword">确认新密码</label>
<input id="confirmPassword" name="confirmPassword" type="password" autocomplete="new-password" required />
</div>
<input name="validToken" type="hidden" />
<div class="bottom-actions">
<button class="btn primary magnetic" type="submit">修改密码</button>
<span class="api-status" data-security-status>等待提交</span>
@@ -81,12 +82,10 @@
<div class="editor-field">
<label for="phoneSmsCode">短信验证码</label>
<input id="phoneSmsCode" name="smsCode" type="text" required />
<button class="btn ghost magnetic" type="button" data-security-send-code>获取验证码</button>
</div>
</div>
<div class="editor-field">
<label for="phoneValidToken">滑动验证票据</label>
<input id="phoneValidToken" name="validToken" type="text" placeholder="可选,由验证组件返回" />
</div>
<input id="phoneValidToken" name="validToken" type="hidden" />
<div class="bottom-actions">
<button class="btn primary magnetic" type="submit">确认换绑</button>
<span class="api-status" data-security-status>等待验证码</span>
@@ -129,6 +128,8 @@
<script src="utils/axios.js"></script>
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
<script src="public/tac/js/tac.min.js"></script>
<script src="public/js/captcha-pages.js"></script>
<script src="public/js/security-pages.js"></script>
<script src="public/js/profile-common.js"></script>
<script src="public/js/page-effects.js"></script>
+2 -2
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>
<body class="page-profile-module" data-vip-page data-feature-status="pending" data-feature-message="会员与在线服务正在开发中,当前页面仅保留设计预览。">
<header class="site-header">
<div class="container nav">
<a class="brand magnetic" href="profile.html"
@@ -162,7 +162,7 @@
<script src="utils/axios.js"></script>
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
<script src="public/js/vip-pages.js"></script>
<script src="public/js/pending-pages.js"></script>
<script src="public/js/profile-common.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
+2 -2
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-share-page>
<body class="page-profile-module" data-family-share-page data-feature-status="pending" data-feature-message="应用分享与邀请奖励服务正在开发中,当前页面仅保留设计预览。">
<header class="site-header">
<div class="container nav">
<a class="brand magnetic" href="profile.html"
@@ -87,7 +87,7 @@
<script src="utils/axios.js"></script>
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
<script src="public/js/member-admin-pages.js"></script>
<script src="public/js/pending-pages.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
</html>
+2 -2
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-lineage-page>
<body class="page-profile-module" data-lineage-page data-feature-status="pending" data-feature-message="世系与成员服务正在开发中,当前页面仅保留设计预览。">
<header class="site-header">
<div class="container nav">
<a class="brand magnetic" href="profile.html"
@@ -166,7 +166,7 @@
<script src="utils/axios.js"></script>
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
<script src="public/js/lineage-pages.js"></script>
<script src="public/js/pending-pages.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
</html>
+2 -1
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">
<body class="page-profile-module" data-feature-status="pending" data-feature-message="家族视频服务正在开发中,当前页面仅保留设计预览。">
<header class="site-header">
<div class="container nav">
<a class="brand magnetic" href="profile.html"
@@ -91,6 +91,7 @@
<script src="public/layui/layui.js"></script>
<script src="public/js/lay-config.js"></script>
<script src="public/js/profile-common.js"></script>
<script src="public/js/pending-pages.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
</html>
-1
View File
@@ -98,7 +98,6 @@
<script src="utils/axios.js"></script>
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
<script src="public/js/album-pages.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
</html>
+20
View File
@@ -101,6 +101,26 @@
gap: 20px;
}
.feature-status-banner {
padding: 18px 20px;
border: 1px solid rgba(196, 146, 69, .38);
border-radius: 16px;
color: #6b542d;
background: #fff8e8;
box-shadow: 0 12px 26px rgba(89, 66, 26, .07);
}
.feature-status-banner strong {
color: var(--red);
font-size: 16px;
}
.feature-status-banner p {
margin: 6px 0 0;
color: inherit;
line-height: 1.75;
}
.module-panel {
padding: 28px;
}
+176
View File
@@ -0,0 +1,176 @@
.site-info-hero {
padding: 78px 0 64px;
background:
radial-gradient(circle at 82% 18%, rgba(196, 146, 69, .2), transparent 24%),
linear-gradient(135deg, #fffaf0, #f3eadc 62%, #edf4ef);
}
.site-info-hero .container {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(220px, .65fr);
align-items: center;
gap: 44px;
}
.site-info-kicker {
margin-bottom: 14px;
color: var(--red);
font-size: 13px;
font-weight: 900;
letter-spacing: .12em;
text-transform: uppercase;
}
.site-info-hero h1 {
margin: 0;
font-size: clamp(36px, 5vw, 58px);
line-height: 1.15;
}
.site-info-hero p {
max-width: 680px;
margin: 20px 0 0;
color: var(--muted);
font-size: 17px;
line-height: 1.9;
}
.site-info-mark {
min-height: 180px;
display: grid;
place-items: center;
border: 1px solid rgba(196, 146, 69, .36);
border-radius: 28px;
color: var(--red);
background: rgba(255, 253, 248, .76);
box-shadow: var(--shadow);
font-size: 62px;
font-weight: 900;
}
.site-info-section {
padding: 72px 0;
}
.site-info-section.alt {
background: rgba(247, 239, 226, .64);
}
.site-info-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(240px, .36fr);
gap: 28px;
}
.site-info-list,
.site-info-panel,
.site-info-status {
padding: 30px;
border: 1px solid var(--line);
border-radius: 22px;
background: var(--white);
box-shadow: 0 16px 46px rgba(69, 53, 35, .08);
}
.site-info-list {
display: grid;
gap: 16px;
}
.site-info-item {
display: block;
padding: 20px;
border: 1px solid rgba(234, 223, 206, .9);
border-radius: 16px;
background: #fffaf2;
transition: transform .2s ease, border-color .2s ease;
}
.site-info-item:hover {
border-color: rgba(200, 59, 50, .4);
transform: translateY(-2px);
}
.site-info-item small,
.site-info-panel small {
color: var(--red);
font-weight: 800;
}
.site-info-item h2,
.site-info-panel h2,
.site-info-status h2 {
margin: 10px 0;
font-size: 22px;
}
.site-info-item p,
.site-info-panel p,
.site-info-status p {
margin: 0;
color: var(--muted);
line-height: 1.8;
}
.site-info-links {
display: grid;
gap: 12px;
margin-top: 18px;
}
.site-info-links a {
color: #8f4b31;
font-weight: 800;
}
.site-info-links a:hover {
color: var(--red);
}
.site-info-notice {
margin-bottom: 22px;
padding: 18px 20px;
border: 1px solid rgba(196, 146, 69, .42);
border-radius: 14px;
color: #775227;
background: #fff6e2;
line-height: 1.75;
}
.site-info-status {
max-width: 760px;
margin: 0 auto;
text-align: center;
}
.site-info-status .site-info-mark {
width: 96px;
min-height: 96px;
margin: 0 auto 22px;
border-radius: 50%;
font-size: 34px;
}
.site-info-actions {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 12px;
margin-top: 26px;
}
@media (max-width: 760px) {
.site-info-hero .container,
.site-info-layout {
grid-template-columns: 1fr;
}
.site-info-hero,
.site-info-section {
padding: 52px 0;
}
.site-info-mark {
min-height: 120px;
}
}
-312
View File
@@ -1,312 +0,0 @@
(function (root, factory) {
// 相册模块同时支持浏览器页面和 Node 单元测试。
if (typeof module === 'object' && module.exports) {
module.exports = factory(root);
return;
}
root.AlbumPages = factory(root);
if (root.document) {
root.document.addEventListener('DOMContentLoaded', function () {
root.AlbumPages.init();
});
}
})(typeof globalThis !== 'undefined' ? globalThis : window, function (root) {
'use strict';
var documentRef = root.document;
var selectedAlbumId = '';
function normalizeList(data) {
// 相册接口列表响应兼容数组、rows、records、list、data。
if (Array.isArray(data)) return data;
if (!data || typeof data !== 'object') return [];
if (Array.isArray(data.rows)) return data.rows;
if (Array.isArray(data.records)) return data.records;
if (Array.isArray(data.list)) return data.list;
if (Array.isArray(data.data)) return data.data;
return [];
}
function pick(item, keys, fallback) {
var source = item || {};
var index;
var value;
for (index = 0; index < keys.length; index += 1) {
value = source[keys[index]];
if (value !== undefined && value !== null && value !== '') return value;
}
return fallback || '';
}
function escapeHtml(value) {
// 相册名称和照片说明进入 innerHTML 前统一转义。
return String(value === undefined || value === null ? '' : value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function getApi() {
return root.GenealogyApi && root.GenealogyApi.defaultClient;
}
function query(selector, rootNode) {
return (rootNode || documentRef).querySelector(selector);
}
function queryAll(selector, rootNode) {
return Array.prototype.slice.call((rootNode || documentRef).querySelectorAll(selector));
}
function showMessage(message) {
if (root.layui && root.layui.layer) {
root.layui.layer.msg(message);
return;
}
root.alert(message);
}
function getQueryParam(search, name) {
var params = new URLSearchParams(String(search || '').replace(/^\?/, ''));
return params.get(name) || '';
}
function getCurrentGenealogyId() {
var search = root.location && root.location.search;
var page = query('[data-album-page], [data-promo-album-page]');
return getQueryParam(search, 'genealogyId') ||
(page && page.getAttribute('data-genealogy-id')) ||
getQueryParam(search, 'id') ||
'';
}
function trimOrUndefined(value) {
var text = String(value === undefined || value === null ? '' : value).trim();
return text || undefined;
}
function toNumber(value, fallback) {
var text = String(value === undefined || value === null ? '' : value).trim();
var number = Number(text);
return text && Number.isFinite(number) ? number : fallback;
}
function buildAlbumBody(values) {
var source = values || {};
return {
albumName: String(source.albumName || '').trim(),
albumDesc: trimOrUndefined(source.albumDesc),
coverOssId: toNumber(source.coverOssId),
sortOrder: toNumber(source.sortOrder, 1),
status: String(source.status || '').trim() || '0'
};
}
function buildPhotoBody(values) {
var source = values || {};
return {
ossId: toNumber(source.ossId),
photoTitle: trimOrUndefined(source.photoTitle),
photoDesc: trimOrUndefined(source.photoDesc),
photographer: trimOrUndefined(source.photographer),
shootTime: trimOrUndefined(source.shootTime),
sortOrder: toNumber(source.sortOrder, 1),
status: String(source.status || '').trim() || '0'
};
}
function getAlbumId(item) {
return pick(item, ['albumId', 'id'], '');
}
function buildAlbumRow(item) {
var albumId = getAlbumId(item);
var name = pick(item, ['albumName', 'name'], '未命名相册');
var photoCount = pick(item, ['photoCount', 'photos'], '0');
var desc = pick(item, ['albumDesc', 'description'], '暂无说明');
return '<button class="module-row album-row" type="button" data-album-id="' + escapeHtml(albumId) + '">' +
'<div><h3>' + escapeHtml(name) + '</h3><p>' + escapeHtml(photoCount) + ' 张照片 · ' + escapeHtml(desc) + '</p></div>' +
'<span class="pill">管理</span></button>';
}
function buildPhotoRow(item) {
var title = pick(item, ['photoTitle', 'title'], '未命名照片');
var desc = pick(item, ['photoDesc', 'description'], '暂无说明');
var photographer = pick(item, ['photographer'], '未记录拍摄人');
return '<div class="module-row album-photo-row"><div><h3>' + escapeHtml(title) + '</h3><p>' + escapeHtml(desc) + ' · ' + escapeHtml(photographer) + '</p></div><span class="pill">照片</span></div>';
}
function renderAlbums(items) {
var container = query('[data-album-list], [data-promo-album-list]');
var list = normalizeList(items);
if (!container) return;
if (!list.length) {
container.innerHTML = '<div class="api-empty">暂无相册</div>';
return;
}
container.innerHTML = list.map(buildAlbumRow).join('');
}
function renderPhotos(items) {
var container = query('[data-album-photo-list]');
var list = normalizeList(items);
if (!container) return;
if (!list.length) {
container.innerHTML = '<div class="api-empty">暂无照片</div>';
return;
}
container.innerHTML = list.map(buildPhotoRow).join('');
}
function getFormValues(form) {
var values = {};
queryAll('[name]', form).forEach(function (field) {
values[field.name] = field.value;
});
return values;
}
async function loadAlbums() {
var api = getApi();
var genealogyId = getCurrentGenealogyId();
if (!api || !genealogyId) return;
try {
renderAlbums(await api.albums(genealogyId));
} catch (error) {
showMessage(error.message || '相册加载失败');
}
}
async function loadPhotos(albumId) {
var api = getApi();
var genealogyId = getCurrentGenealogyId();
if (!api || !genealogyId || !albumId) return;
selectedAlbumId = albumId;
try {
renderPhotos(await api.albumPhotos(genealogyId, albumId));
} catch (error) {
showMessage(error.message || '照片加载失败');
}
}
async function submitAlbum(form) {
var api = getApi();
var genealogyId = getCurrentGenealogyId();
var body = buildAlbumBody(getFormValues(form));
if (!api || !genealogyId) {
showMessage('请从具体家谱进入相册管理');
return;
}
if (!body.albumName) {
showMessage('请填写相册名称');
return;
}
try {
if (selectedAlbumId) {
await api.updateAlbum(genealogyId, selectedAlbumId, body);
} else {
await api.createAlbum(genealogyId, body);
}
form.reset();
selectedAlbumId = '';
showMessage('相册已保存');
loadAlbums();
} catch (error) {
showMessage(error.message || '保存相册失败');
}
}
async function submitPhoto(form) {
var api = getApi();
var genealogyId = getCurrentGenealogyId();
var body = buildPhotoBody(getFormValues(form));
if (!api || !genealogyId || !selectedAlbumId) {
showMessage('请先选择相册');
return;
}
if (!body.ossId) {
showMessage('请填写照片 OSS ID');
return;
}
try {
await api.createAlbumPhoto(genealogyId, selectedAlbumId, body);
form.reset();
showMessage('照片已添加');
loadPhotos(selectedAlbumId);
} catch (error) {
showMessage(error.message || '添加照片失败');
}
}
function bindActions() {
if (!documentRef) return;
documentRef.addEventListener('click', function (event) {
var albumButton = event.target.closest('[data-album-id]');
if (!albumButton) return;
event.preventDefault();
loadPhotos(albumButton.getAttribute('data-album-id'));
});
documentRef.addEventListener('submit', function (event) {
var albumForm = event.target.closest('[data-album-form]');
var photoForm = event.target.closest('[data-album-photo-form]');
if (albumForm) {
event.preventDefault();
submitAlbum(albumForm);
}
if (photoForm) {
event.preventDefault();
submitPhoto(photoForm);
}
});
}
function init() {
if (!documentRef) return;
bindActions();
loadAlbums();
}
return {
normalizeList: normalizeList,
buildAlbumBody: buildAlbumBody,
buildPhotoBody: buildPhotoBody,
buildAlbumRow: buildAlbumRow,
init: init
};
});
-296
View File
@@ -1,296 +0,0 @@
(function (root, factory) {
// 谱文模块同时支持浏览器页面和 Node 单元测试。
if (typeof module === 'object' && module.exports) {
module.exports = factory(root);
return;
}
root.ArticlePages = factory(root);
if (root.document) {
root.document.addEventListener('DOMContentLoaded', function () {
root.ArticlePages.init();
});
}
})(typeof globalThis !== 'undefined' ? globalThis : window, function (root) {
'use strict';
var documentRef = root.document;
function normalizeList(data) {
// 通用列表响应兼容数组、rows、records、list、data。
if (Array.isArray(data)) return data;
if (!data || typeof data !== 'object') return [];
if (Array.isArray(data.rows)) return data.rows;
if (Array.isArray(data.records)) return data.records;
if (Array.isArray(data.list)) return data.list;
if (Array.isArray(data.data)) return data.data;
return [];
}
function pick(item, keys, fallback) {
var source = item || {};
var index;
var value;
for (index = 0; index < keys.length; index += 1) {
value = source[keys[index]];
if (value !== undefined && value !== null && value !== '') return value;
}
return fallback || '';
}
function escapeHtml(value) {
// 标题和摘要进入 innerHTML 前统一转义。
return String(value === undefined || value === null ? '' : value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function getApi() {
return root.GenealogyApi && root.GenealogyApi.defaultClient;
}
function query(selector, rootNode) {
return (rootNode || documentRef).querySelector(selector);
}
function queryAll(selector, rootNode) {
return Array.prototype.slice.call((rootNode || documentRef).querySelectorAll(selector));
}
function showMessage(message) {
if (root.layui && root.layui.layer) {
root.layui.layer.msg(message);
return;
}
root.alert(message);
}
function getQueryParam(search, name) {
var params = new URLSearchParams(String(search || '').replace(/^\?/, ''));
return params.get(name) || '';
}
function getCurrentGenealogyId() {
var search = root.location && root.location.search;
var page = query('[data-article-page], [data-article-edit-page], [data-article-detail-page], [data-content-page]');
return getQueryParam(search, 'genealogyId') ||
getQueryParam(search, 'familyId') ||
(page && page.getAttribute('data-genealogy-id')) ||
getQueryParam(search, 'id') ||
'';
}
function getCurrentArticleId() {
var search = root.location && root.location.search;
return getQueryParam(search, 'articleId') || getQueryParam(search, 'id') || '';
}
function trimOrUndefined(value) {
var text = String(value === undefined || value === null ? '' : value).trim();
return text || undefined;
}
function toNumber(value, fallback) {
var text = String(value === undefined || value === null ? '' : value).trim();
var number = Number(text);
return text && Number.isFinite(number) ? number : fallback;
}
function formatStatus(status) {
var value = String(status === undefined || status === null ? '' : status);
if (value === '0' || value === 'published') return '已发布';
if (value === '1' || value === 'draft') return '草稿';
return value || '未知';
}
function buildArticleBody(values) {
var source = values || {};
return {
categoryId: toNumber(source.categoryId),
articleTitle: String(source.articleTitle || '').trim(),
articleSummary: trimOrUndefined(source.articleSummary),
coverOssId: toNumber(source.coverOssId),
articleContent: String(source.articleContent || '').trim(),
authorName: trimOrUndefined(source.authorName),
sortOrder: toNumber(source.sortOrder, 1),
status: String(source.status || '').trim() || '0'
};
}
function getArticleId(item) {
return pick(item, ['articleId', 'id'], '');
}
function buildArticleRow(item, genealogyId) {
var articleId = getArticleId(item);
var title = pick(item, ['articleTitle', 'title'], '未命名谱文');
var category = pick(item, ['categoryName', 'categoryTitle', 'typeName'], '谱文');
var status = formatStatus(pick(item, ['status'], '0'));
var updateTime = pick(item, ['updateTime', 'publishTime', 'createTime'], '未记录时间');
return '<a class="module-row article-row" href="article-detail.html?id=' + encodeURIComponent(articleId) + '&genealogyId=' + encodeURIComponent(genealogyId || '') + '">' +
'<div><h3>' + escapeHtml(title) + '</h3><p>' + escapeHtml(category) + ' · ' + escapeHtml(status) + ' · ' + escapeHtml(updateTime) + '</p></div>' +
'<span class="pill">查看</span></a>';
}
function buildCategoryOption(item) {
var categoryId = pick(item, ['categoryId', 'id'], '');
var name = pick(item, ['categoryName', 'name', 'title'], '未命名分类');
return '<option value="' + escapeHtml(categoryId) + '">' + escapeHtml(name) + '</option>';
}
function renderArticles(items, genealogyId) {
var container = query('[data-article-list], [data-recent-content]');
var list = normalizeList(items);
if (!container) return;
if (!list.length) {
container.innerHTML = '<div class="api-empty">暂无谱文内容</div>';
return;
}
container.innerHTML = list.map(function (item) {
return buildArticleRow(item, genealogyId);
}).join('');
}
function renderCategories(items) {
var select = query('[data-article-category]');
var list = normalizeList(items);
if (!select) return;
select.innerHTML = '<option value="">请选择分类</option>' + list.map(buildCategoryOption).join('');
}
function renderArticleDetail(article) {
var source = article || {};
var title = query('[data-article-title]');
var summary = query('[data-article-summary]');
var meta = query('[data-article-meta]');
var content = query('[data-article-content]');
if (title) title.textContent = pick(source, ['articleTitle', 'title'], '未命名谱文');
if (summary) summary.textContent = pick(source, ['articleSummary', 'summary'], '暂无摘要');
if (meta) {
meta.innerHTML = '<span>' + escapeHtml(pick(source, ['categoryName'], '谱文')) + '</span><span>' + escapeHtml(pick(source, ['authorName'], '佚名')) + '</span><span>' + escapeHtml(pick(source, ['updateTime', 'publishTime', 'createTime'], '未记录时间')) + '</span>';
}
if (content) content.innerHTML = pick(source, ['articleContent', 'content'], '<p>暂无正文</p>');
}
function fillArticleForm(article) {
var source = article || {};
queryAll('[name]').forEach(function (field) {
var value = pick(source, [field.name], '');
if (value !== '') field.value = value;
});
}
function getFormValues(form) {
var values = {};
queryAll('[name]', form).forEach(function (field) {
values[field.name] = field.value;
});
return values;
}
async function loadArticles() {
var api = getApi();
var genealogyId = getCurrentGenealogyId();
var articleId = getCurrentArticleId();
if (!api || !genealogyId) return;
try {
if (query('[data-article-list], [data-recent-content]')) {
renderArticles(await api.articles(genealogyId), genealogyId);
}
if (query('[data-article-category]')) {
renderCategories(await api.articleCategories(genealogyId));
}
if (articleId && query('[data-article-detail-page], [data-article-edit-page]')) {
if (query('[data-article-detail-page]')) {
renderArticleDetail(await api.articleDetail(genealogyId, articleId));
} else {
fillArticleForm(await api.articleDetail(genealogyId, articleId));
}
}
} catch (error) {
showMessage(error.message || '谱文数据加载失败');
}
}
async function submitArticle(form) {
var api = getApi();
var genealogyId = getCurrentGenealogyId();
var articleId = getCurrentArticleId();
var body = buildArticleBody(getFormValues(form));
if (!api || !genealogyId) {
showMessage('请从具体家谱进入谱文编辑');
return;
}
if (!body.articleTitle || !body.articleContent) {
showMessage('请填写谱文标题和正文');
return;
}
try {
if (articleId) {
await api.updateArticle(genealogyId, articleId, body);
} else {
await api.createArticle(genealogyId, body);
}
showMessage('谱文已保存');
root.location.href = 'profile-article.html?genealogyId=' + encodeURIComponent(genealogyId);
} catch (error) {
showMessage(error.message || '保存谱文失败');
}
}
function bindActions() {
if (!documentRef) return;
documentRef.addEventListener('submit', function (event) {
var form = event.target.closest('[data-article-form]');
if (!form) return;
event.preventDefault();
submitArticle(form);
});
}
function init() {
if (!documentRef) return;
bindActions();
loadArticles();
}
return {
normalizeList: normalizeList,
buildArticleBody: buildArticleBody,
buildArticleRow: buildArticleRow,
formatStatus: formatStatus,
init: init
};
});
-382
View File
@@ -1,382 +0,0 @@
(function (root, factory) {
// 祭祀/贺礼/功德模块同时支持浏览器页面和 Node 单元测试。
if (typeof module === 'object' && module.exports) {
module.exports = factory(root);
return;
}
root.CeremonyPages = factory(root);
if (root.document) {
root.document.addEventListener('DOMContentLoaded', function () {
root.CeremonyPages.init();
});
}
})(typeof globalThis !== 'undefined' ? globalThis : window, function (root) {
'use strict';
var documentRef = root.document;
var selectedCeremonyId = '';
function normalizeList(data) {
// 通用列表响应兼容数组、rows、records、list、data。
if (Array.isArray(data)) return data;
if (!data || typeof data !== 'object') return [];
if (Array.isArray(data.rows)) return data.rows;
if (Array.isArray(data.records)) return data.records;
if (Array.isArray(data.list)) return data.list;
if (Array.isArray(data.data)) return data.data;
return [];
}
function pick(item, keys, fallback) {
var source = item || {};
var index;
var value;
for (index = 0; index < keys.length; index += 1) {
value = source[keys[index]];
if (value !== undefined && value !== null && value !== '') return value;
}
return fallback || '';
}
function escapeHtml(value) {
// 标题、姓名和说明进入 innerHTML 前统一转义。
return String(value === undefined || value === null ? '' : value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function getApi() {
return root.GenealogyApi && root.GenealogyApi.defaultClient;
}
function query(selector, rootNode) {
return (rootNode || documentRef).querySelector(selector);
}
function queryAll(selector, rootNode) {
return Array.prototype.slice.call((rootNode || documentRef).querySelectorAll(selector));
}
function showMessage(message) {
if (root.layui && root.layui.layer) {
root.layui.layer.msg(message);
return;
}
root.alert(message);
}
function getQueryParam(search, name) {
var params = new URLSearchParams(String(search || '').replace(/^\?/, ''));
return params.get(name) || '';
}
function getCurrentGenealogyId() {
var search = root.location && root.location.search;
var page = query('[data-ceremony-page], [data-ceremony-edit-page], [data-merit-page], [data-merit-edit-page]');
return getQueryParam(search, 'genealogyId') ||
(page && page.getAttribute('data-genealogy-id')) ||
getQueryParam(search, 'id') ||
'';
}
function getCurrentCeremonyId() {
var search = root.location && root.location.search;
return getQueryParam(search, 'ceremonyId') || '';
}
function trimOrUndefined(value) {
var text = String(value === undefined || value === null ? '' : value).trim();
return text || undefined;
}
function toNumber(value, fallback) {
var text = String(value === undefined || value === null ? '' : value).trim();
var number = Number(text);
return text && Number.isFinite(number) ? number : fallback;
}
function buildCeremonyBody(values) {
var source = values || {};
return {
ceremonyType: String(source.ceremonyType || '').trim(),
ceremonyTitle: String(source.ceremonyTitle || '').trim(),
ceremonyDesc: trimOrUndefined(source.ceremonyDesc),
ceremonyTime: trimOrUndefined(source.ceremonyTime),
location: trimOrUndefined(source.location),
coverOssId: toNumber(source.coverOssId),
sortOrder: toNumber(source.sortOrder, 1),
status: String(source.status || '').trim() || '0'
};
}
function buildGiftBody(values) {
var source = values || {};
return {
giverName: trimOrUndefined(source.giverName),
giftAmount: toNumber(source.giftAmount, 0),
giftMessage: trimOrUndefined(source.giftMessage)
};
}
function buildMeritBody(values) {
var source = values || {};
return {
donorName: String(source.donorName || '').trim(),
meritType: String(source.meritType || '').trim() || 'donation',
meritTitle: String(source.meritTitle || '').trim(),
meritContent: trimOrUndefined(source.meritContent),
amount: toNumber(source.amount, 0),
meritTime: trimOrUndefined(source.meritTime),
sortOrder: toNumber(source.sortOrder, 1),
status: String(source.status || '').trim() || '0'
};
}
function getCeremonyId(item) {
return pick(item, ['ceremonyId', 'id'], '');
}
function buildCeremonyRow(item) {
var ceremonyId = getCeremonyId(item);
var title = pick(item, ['ceremonyTitle', 'title'], '未命名活动');
var type = pick(item, ['ceremonyType', 'type'], 'ceremony');
var time = pick(item, ['ceremonyTime', 'time', 'createTime'], '未设置时间');
var giftCount = pick(item, ['giftCount', 'gifts'], '0');
return '<button class="module-row ceremony-row" type="button" data-ceremony-id="' + escapeHtml(ceremonyId) + '">' +
'<div><h3>' + escapeHtml(title) + '</h3><p>' + escapeHtml(type) + ' · ' + escapeHtml(time) + ' · ' + escapeHtml(giftCount) + ' 条献礼</p></div>' +
'<span class="pill">管理</span></button>';
}
function buildGiftRow(item) {
var name = pick(item, ['giverName', 'memberName', 'name'], '匿名');
var amount = pick(item, ['giftAmount', 'amount'], '0');
var message = pick(item, ['giftMessage', 'message'], '暂无留言');
return '<div class="module-row ceremony-gift-row"><div><h3>' + escapeHtml(name) + '</h3><p>' + escapeHtml(amount) + ' 元 · ' + escapeHtml(message) + '</p></div><span class="pill">献礼</span></div>';
}
function buildMeritRow(item) {
var name = pick(item, ['donorName', 'name'], '未命名功德人');
var title = pick(item, ['meritTitle', 'title'], '功德记录');
var time = pick(item, ['meritTime', 'createTime'], '未记录时间');
return '<div class="module-row merit-row"><div><h3>' + escapeHtml(name) + '</h3><p>' + escapeHtml(title) + ' · ' + escapeHtml(time) + '</p></div><span class="pill">查看</span></div>';
}
function renderCeremonies(items) {
var container = query('[data-ceremony-list]');
var list = normalizeList(items);
if (!container) return;
if (!list.length) {
container.innerHTML = '<div class="api-empty">暂无贺礼邀请</div>';
return;
}
container.innerHTML = list.map(buildCeremonyRow).join('');
}
function renderGifts(items) {
var container = query('[data-ceremony-gift-list]');
var list = normalizeList(items);
if (!container) return;
if (!list.length) {
container.innerHTML = '<div class="api-empty">暂无献礼记录</div>';
return;
}
container.innerHTML = list.map(buildGiftRow).join('');
}
function renderMerits(items) {
var container = query('[data-merit-list]');
var list = normalizeList(items);
if (!container) return;
if (!list.length) {
container.innerHTML = '<div class="api-empty">暂无功德记录</div>';
return;
}
container.innerHTML = list.map(buildMeritRow).join('');
}
function getFormValues(form) {
var values = {};
queryAll('[name]', form).forEach(function (field) {
values[field.name] = field.value;
});
return values;
}
async function loadCeremonies() {
var api = getApi();
var genealogyId = getCurrentGenealogyId();
var ceremonyId = getCurrentCeremonyId();
if (!api || !genealogyId) return;
try {
if (query('[data-ceremony-list]')) {
renderCeremonies(await api.ceremonies(genealogyId));
}
if (ceremonyId && query('[data-ceremony-gift-list]')) {
selectedCeremonyId = ceremonyId;
renderGifts(await api.ceremonyGifts(genealogyId, ceremonyId));
}
} catch (error) {
showMessage(error.message || '贺礼数据加载失败');
}
}
async function loadMerits() {
var api = getApi();
var genealogyId = getCurrentGenealogyId();
if (!api || !genealogyId || !query('[data-merit-list]')) return;
try {
renderMerits(await api.meritRecords(genealogyId));
} catch (error) {
showMessage(error.message || '功德记录加载失败');
}
}
async function submitCeremony(form) {
var api = getApi();
var genealogyId = getCurrentGenealogyId();
var ceremonyId = getCurrentCeremonyId();
var body = buildCeremonyBody(getFormValues(form));
if (!api || !genealogyId) return;
if (!body.ceremonyType || !body.ceremonyTitle) {
showMessage('请填写贺礼类型和标题');
return;
}
try {
if (ceremonyId) {
await api.updateCeremony(genealogyId, ceremonyId, body);
} else {
await api.createCeremony(genealogyId, body);
}
showMessage('贺礼已保存');
root.location.href = 'profile-gift.html?genealogyId=' + encodeURIComponent(genealogyId);
} catch (error) {
showMessage(error.message || '保存贺礼失败');
}
}
async function submitGift(form) {
var api = getApi();
var genealogyId = getCurrentGenealogyId();
var ceremonyId = selectedCeremonyId || getCurrentCeremonyId();
var body = buildGiftBody(getFormValues(form));
if (!api || !genealogyId || !ceremonyId) {
showMessage('请先选择贺礼邀请');
return;
}
try {
await api.createCeremonyGift(genealogyId, ceremonyId, body);
form.reset();
showMessage('献礼已保存');
renderGifts(await api.ceremonyGifts(genealogyId, ceremonyId));
} catch (error) {
showMessage(error.message || '保存献礼失败');
}
}
async function submitMerit(form) {
var api = getApi();
var genealogyId = getCurrentGenealogyId();
var body = buildMeritBody(getFormValues(form));
if (!api || !genealogyId) return;
if (!body.donorName || !body.meritTitle) {
showMessage('请填写功德人和功德标题');
return;
}
try {
await api.createMeritRecord(genealogyId, body);
showMessage('功德记录已保存');
root.location.href = 'profile-merit.html?genealogyId=' + encodeURIComponent(genealogyId);
} catch (error) {
showMessage(error.message || '保存功德记录失败');
}
}
function bindActions() {
if (!documentRef) return;
documentRef.addEventListener('click', function (event) {
var ceremonyButton = event.target.closest('[data-ceremony-id]');
if (!ceremonyButton) return;
selectedCeremonyId = ceremonyButton.getAttribute('data-ceremony-id');
if (getApi() && getCurrentGenealogyId()) {
getApi().ceremonyGifts(getCurrentGenealogyId(), selectedCeremonyId).then(renderGifts).catch(function (error) {
showMessage(error.message || '献礼加载失败');
});
}
});
documentRef.addEventListener('submit', function (event) {
var ceremonyForm = event.target.closest('[data-ceremony-form]');
var giftForm = event.target.closest('[data-ceremony-gift-form]');
var meritForm = event.target.closest('[data-merit-form]');
if (ceremonyForm) {
event.preventDefault();
submitCeremony(ceremonyForm);
}
if (giftForm) {
event.preventDefault();
submitGift(giftForm);
}
if (meritForm) {
event.preventDefault();
submitMerit(meritForm);
}
});
}
function init() {
if (!documentRef) return;
bindActions();
loadCeremonies();
loadMerits();
}
return {
normalizeList: normalizeList,
buildCeremonyBody: buildCeremonyBody,
buildMeritBody: buildMeritBody,
buildCeremonyRow: buildCeremonyRow,
init: init
};
});
+285 -148
View File
@@ -16,50 +16,16 @@
var documentRef = root.document;
function normalizeList(data) {
// 动态分页响应兼容 rows、records、list、data。
if (Array.isArray(data)) return data;
if (!data || typeof data !== 'object') return [];
if (Array.isArray(data.rows)) return data.rows;
if (Array.isArray(data.records)) return data.records;
if (Array.isArray(data.list)) return data.list;
if (Array.isArray(data.data)) return data.data;
return [];
}
function pick(item, keys, fallback) {
var source = item || {};
var index;
var value;
for (index = 0; index < keys.length; index += 1) {
value = source[keys[index]];
if (value !== undefined && value !== null && value !== '') return value;
}
return fallback || '';
}
function escapeHtml(value) {
// 动态内容进入 innerHTML 前转义,详情富文本不在这里处理。
return String(value === undefined || value === null ? '' : value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function getApi() {
return root.GenealogyApi && root.GenealogyApi.defaultClient;
}
function query(selector, rootNode) {
return (rootNode || documentRef).querySelector(selector);
return documentRef ? (rootNode || documentRef).querySelector(selector) : null;
}
function queryAll(selector, rootNode) {
return Array.prototype.slice.call((rootNode || documentRef).querySelectorAll(selector));
return documentRef ? Array.prototype.slice.call((rootNode || documentRef).querySelectorAll(selector)) : [];
}
function showMessage(message) {
@@ -68,28 +34,26 @@
return;
}
root.alert(message);
if (root.alert) root.alert(message);
}
function getQueryParam(search, name) {
var params = new URLSearchParams(String(search || '').replace(/^\?/, ''));
return params.get(name) || '';
}
function getCurrentGenealogyId() {
var search = root.location && root.location.search;
function getCurrentGenealogyId(search) {
var page = query('[data-feed-page], [data-feed-edit-page]');
var source = search === undefined && root.location ? root.location.search : search;
return getQueryParam(search, 'genealogyId') ||
(page && page.getAttribute('data-genealogy-id')) ||
getQueryParam(search, 'id') ||
'';
return getQueryParam(source, 'genealogyId') || (page && page.getAttribute('data-genealogy-id')) || '';
}
function getCurrentFeedId() {
var search = root.location && root.location.search;
function getCurrentFeedId(search) {
var source = search === undefined && root.location ? root.location.search : search;
return getQueryParam(search, 'feedId') || '';
return getQueryParam(source, 'feedId');
}
function trimOrUndefined(value) {
@@ -98,187 +62,338 @@
return text || undefined;
}
function toNumber(value) {
var text = String(value === undefined || value === null ? '' : value).trim();
var number = Number(text);
function toNumberOrUndefined(value) {
var text = trimOrUndefined(value);
var number;
return text && Number.isFinite(number) ? number : undefined;
if (!text) return undefined;
number = Number(text);
return Number.isFinite(number) ? number : undefined;
}
function buildFeedBody(values) {
// FamilyFeedBody 只提交最新接口文档定义的字段。
var source = values || {};
return {
content: String(source.content || '').trim(),
var body = {
feedType: trimOrUndefined(source.feedType) || 'text',
feedContent: String(source.feedContent || '').trim(),
mediaOssIds: trimOrUndefined(source.mediaOssIds),
visibility: String(source.visibility || '').trim() || '2',
status: String(source.status || '').trim() || '0'
status: trimOrUndefined(source.status)
};
var sortOrder = toNumberOrUndefined(source.sortOrder);
if (sortOrder !== undefined) body.sortOrder = sortOrder;
return body;
}
function buildCommentBody(values) {
// FamilyFeedCommentBody 要求 content 必填
// FamilyFeedCommentBody content 是唯一必填字段,其余字段按需携带
var source = values || {};
return {
parentCommentId: toNumber(source.parentCommentId),
replyUserId: toNumber(source.replyUserId),
var body = {
content: String(source.content || '').trim()
};
var parentCommentId = toNumberOrUndefined(source.parentCommentId);
var replyUserId = toNumberOrUndefined(source.replyUserId);
if (parentCommentId !== undefined) body.parentCommentId = parentCommentId;
if (replyUserId !== undefined) body.replyUserId = replyUserId;
return body;
}
function getFeedId(item) {
return pick(item, ['feedId', 'id'], '');
var value = item && item.feedId;
var text;
if (value === undefined || value === null || value === '') return '';
text = String(value);
return /^\d+$/.test(text) ? text : '';
}
function buildFeedRow(item) {
function getCommentId(item) {
var value = item && item.commentId;
var text;
if (value === undefined || value === null || value === '') return '';
text = String(value);
return /^\d+$/.test(text) ? text : '';
}
function normalizeFeed(item) {
var feedId = getFeedId(item);
var author = pick(item, ['authorName', 'nickName', 'memberName'], '家族成员');
var content = pick(item, ['content', 'feedContent'], '暂无内容');
var likeCount = pick(item, ['likeCount', 'likes'], '0');
var commentCount = pick(item, ['commentCount', 'comments'], '0');
var createTime = pick(item, ['createTime', 'publishTime', 'updateTime'], '未记录时间');
var feedContent = item && item.feedContent;
var feed;
return '<div class="module-row feed-row" data-feed-id="' + escapeHtml(feedId) + '">' +
'<div><h3>' + escapeHtml(author) + '</h3><p>' + escapeHtml(content) + '</p><small>' + escapeHtml(createTime) + ' · ' + escapeHtml(likeCount) + ' 赞 · ' + escapeHtml(commentCount) + ' 评论</small></div>' +
'<div class="row-actions"><button class="pill" type="button" data-feed-like="' + escapeHtml(feedId) + '">点赞</button><button class="pill" type="button" data-feed-comment="' + escapeHtml(feedId) + '">评论</button></div>' +
'</div>';
if (!feedId || feedContent === undefined || feedContent === null) return null;
feed = {
feedId: feedId,
feedContent: String(feedContent)
};
if (trimOrUndefined(item.mediaOssIds)) feed.mediaOssIds = trimOrUndefined(item.mediaOssIds);
if (trimOrUndefined(item.feedType)) feed.feedType = trimOrUndefined(item.feedType);
if (trimOrUndefined(item.status)) feed.status = trimOrUndefined(item.status);
return feed;
}
function renderFeeds(items) {
function normalizeComment(item) {
var commentId = getCommentId(item);
var content = item && item.content;
if (!commentId || content === undefined || content === null) return null;
return {
commentId: commentId,
content: String(content)
};
}
function normalizeList(data) {
// AxiosRequestUtil 已解包 ListResult 与 PageResult,这里只接收对应的 data 或 rows。
if (Array.isArray(data)) return data;
if (data && Array.isArray(data.rows)) return data.rows;
return [];
}
function escapeHtml(value) {
return String(value === undefined || value === null ? '' : value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function getListUrl(genealogyId, feedId) {
var params = new URLSearchParams();
params.set('genealogyId', genealogyId);
if (feedId) params.set('feedId', feedId);
return 'profile-feed' + (feedId ? '-edit' : '') + '.html?' + params.toString();
}
function renderFeedRow(feed, genealogyId) {
return '<article class="module-row feed-row" data-feed-id="' + escapeHtml(feed.feedId) + '">' +
'<div><h3>家族动态</h3><p>' + escapeHtml(feed.feedContent) + '</p></div>' +
'<div class="row-actions">' +
'<button class="pill" type="button" data-feed-like="' + escapeHtml(feed.feedId) + '">点赞</button>' +
'<button class="pill" type="button" data-feed-unlike="' + escapeHtml(feed.feedId) + '">取消点赞</button>' +
'<button class="pill" type="button" data-feed-comments="' + escapeHtml(feed.feedId) + '">查看评论</button>' +
'<button class="pill" type="button" data-feed-comment="' + escapeHtml(feed.feedId) + '">发表评论</button>' +
'<a class="pill" href="' + escapeHtml(getListUrl(genealogyId, feed.feedId)) + '">编辑</a>' +
'<button class="pill" type="button" data-feed-delete="' + escapeHtml(feed.feedId) + '">删除</button>' +
'</div></article>';
}
function renderFeeds(data, genealogyId) {
var container = query('[data-feed-list]');
var list = normalizeList(items);
var items = normalizeList(data).map(normalizeFeed);
var invalidCount = items.filter(function (item) { return !item; }).length;
var feeds = items.filter(Boolean);
if (!container) return;
if (!list.length) {
if (invalidCount) {
container.innerHTML = '<div class="api-empty">动态响应缺少 feedId 或 feedContent,请联系后端补充 DTO。</div>';
return;
}
if (!feeds.length) {
container.innerHTML = '<div class="api-empty">暂无家族动态</div>';
return;
}
container.innerHTML = list.map(buildFeedRow).join('');
container.innerHTML = feeds.map(function (feed) {
return renderFeedRow(feed, genealogyId);
}).join('');
}
function renderComments(feedId, data) {
var row = query('[data-feed-id="' + feedId + '"]');
var comments = normalizeList(data).map(normalizeComment);
var invalidCount = comments.filter(function (item) { return !item; }).length;
var list = comments.filter(Boolean);
var previous = row && row.nextElementSibling;
if (!row) return;
if (previous && previous.getAttribute('data-feed-comment-list') === feedId) previous.remove();
if (invalidCount) {
row.insertAdjacentHTML('afterend', '<div class="api-empty" data-feed-comment-list="' + escapeHtml(feedId) + '">评论响应缺少 commentId 或 content,请联系后端补充 DTO。</div>');
return;
}
if (!list.length) {
row.insertAdjacentHTML('afterend', '<div class="api-empty" data-feed-comment-list="' + escapeHtml(feedId) + '">暂无评论</div>');
return;
}
row.insertAdjacentHTML('afterend', '<div class="module-list" data-feed-comment-list="' + escapeHtml(feedId) + '">' + list.map(function (comment) {
return '<div class="module-row"><p>' + escapeHtml(comment.content) + '</p><button class="pill" type="button" data-feed-comment-delete="' + escapeHtml(comment.commentId) + '" data-feed-id="' + escapeHtml(feedId) + '">删除评论</button></div>';
}).join('') + '</div>');
}
function getFormValues(form) {
var values = {};
queryAll('[name]', form).forEach(function (field) {
if (field.type === 'checkbox') {
values[field.name] = field.checked ? '1' : '';
return;
}
values[field.name] = field.value;
});
return values;
}
function fillFeedForm(feed) {
var source = feed || {};
function fillFeedForm(data) {
var feed = normalizeFeed(data);
queryAll('[name]').forEach(function (field) {
var value = pick(source, [field.name], '');
if (field.type === 'checkbox') {
field.checked = value === '1' || value === true;
return;
}
if (value !== '') field.value = value;
if (!feed) throw new Error('动态响应缺少 feedId 或 feedContent,请联系后端补充 DTO。');
queryAll('[data-feed-form] [name]').forEach(function (field) {
if (feed[field.name] !== undefined && feed[field.name] !== null) field.value = feed[field.name];
});
}
function syncGenealogyLinks(genealogyId) {
queryAll('[data-feed-create-link], [data-feed-list-link]').forEach(function (link) {
link.href = getListUrl(genealogyId);
});
}
function requireGenealogyId() {
var genealogyId = getCurrentGenealogyId();
var container;
if (!genealogyId) {
container = query('[data-feed-list]');
if (container) container.innerHTML = '<div class="api-empty">请从具体家谱进入家族圈动态</div>';
showMessage('请从具体家谱进入家族圈动态');
}
return genealogyId;
}
async function loadFeeds() {
var api = getApi();
var genealogyId = getCurrentGenealogyId();
var feedId = getCurrentFeedId();
var genealogyId = requireGenealogyId();
if (!api || !genealogyId) return;
syncGenealogyLinks(genealogyId);
try {
if (query('[data-feed-list]')) {
renderFeeds(await api.feedsPage(genealogyId, {
pageNum: 1,
pageSize: 10
}));
}
if (feedId && query('[data-feed-edit-page]')) {
fillFeedForm(await api.feedDetail(genealogyId, feedId));
}
renderFeeds(await api.feedsPage(genealogyId, { pageNum: 1, pageSize: 20 }), genealogyId);
} catch (error) {
showMessage(error.message || '家族动态加载失败');
}
}
async function loadFeedForEdit() {
var api = getApi();
var genealogyId = requireGenealogyId();
var feedId = getCurrentFeedId();
if (!api || !genealogyId) return;
syncGenealogyLinks(genealogyId);
if (!feedId) return;
try {
fillFeedForm(await api.feedDetail(genealogyId, feedId));
} catch (error) {
showMessage(error.message || '动态详情加载失败');
}
}
async function submitFeed(form) {
var api = getApi();
var genealogyId = getCurrentGenealogyId();
var genealogyId = requireGenealogyId();
var feedId = getCurrentFeedId();
var body = buildFeedBody(getFormValues(form));
var body;
if (!api || !genealogyId) {
showMessage('请从具体家谱进入动态发布');
return;
}
if (!body.content) {
if (!api || !genealogyId) return;
// 富文本编辑器在提交前同步回 textarea,保证发送的是用户当前输入。
if (root.KindEditor && root.KindEditor.sync) root.KindEditor.sync('#feedContent');
body = buildFeedBody(getFormValues(form));
if (!body.feedContent) {
showMessage('请填写动态内容');
return;
}
try {
if (feedId) {
await api.updateFeed(genealogyId, feedId, body);
} else {
await api.createFeed(genealogyId, body);
}
showMessage('动态已保存');
root.location.href = 'profile-feed.html?genealogyId=' + encodeURIComponent(genealogyId);
root.location.href = getListUrl(genealogyId);
} catch (error) {
showMessage(error.message || '保存动态失败');
showMessage(error.message || '动态保存失败');
}
}
async function likeFeed(feedId) {
async function likeFeed(feedId, shouldLike) {
var api = getApi();
var genealogyId = getCurrentGenealogyId();
var genealogyId = requireGenealogyId();
if (!api || !genealogyId || !feedId) return;
try {
await api.likeFeed(genealogyId, feedId);
showMessage('已点赞');
loadFeeds();
if (shouldLike) {
await api.likeFeed(genealogyId, feedId);
} else {
await api.unlikeFeed(genealogyId, feedId);
}
await loadFeeds();
} catch (error) {
showMessage(error.message || '点赞失败');
showMessage(error.message || (shouldLike ? '点赞失败' : '取消点赞失败'));
}
}
async function commentFeed(feedId) {
async function deleteFeed(feedId) {
var api = getApi();
var genealogyId = getCurrentGenealogyId();
var genealogyId = requireGenealogyId();
if (!api || !genealogyId || !feedId) return;
if (root.confirm && !root.confirm('确认删除这条动态吗?')) return;
try {
await api.deleteFeed(genealogyId, feedId);
await loadFeeds();
} catch (error) {
showMessage(error.message || '删除动态失败');
}
}
async function showComments(feedId) {
var api = getApi();
var genealogyId = requireGenealogyId();
if (!api || !genealogyId || !feedId) return;
try {
renderComments(feedId, await api.feedComments(genealogyId, feedId));
} catch (error) {
showMessage(error.message || '评论加载失败');
}
}
async function createComment(feedId) {
var api = getApi();
var genealogyId = requireGenealogyId();
var content;
var body;
if (!api || !genealogyId || !feedId) return;
content = root.prompt('请输入评论内容', '');
if (!content) return;
content = root.prompt ? root.prompt('请输入评论内容', '') : '';
body = buildCommentBody({ content: content });
if (!body.content) return;
try {
await api.createFeedComment(genealogyId, feedId, buildCommentBody({
content: content
}));
showMessage('评论已发布');
loadFeeds();
await api.createFeedComment(genealogyId, feedId, body);
await showComments(feedId);
} catch (error) {
showMessage(error.message || '评论失败');
showMessage(error.message || '评论发布失败');
}
}
async function deleteComment(feedId, commentId) {
var api = getApi();
var genealogyId = requireGenealogyId();
if (!api || !genealogyId || !feedId || !commentId) return;
if (root.confirm && !root.confirm('确认删除这条评论吗?')) return;
try {
await api.deleteFeedComment(genealogyId, feedId, commentId);
await showComments(feedId);
} catch (error) {
showMessage(error.message || '删除评论失败');
}
}
function bindActions() {
if (!documentRef) return;
documentRef.addEventListener('submit', function (event) {
var form = event.target.closest('[data-feed-form]');
@@ -286,35 +401,57 @@
event.preventDefault();
submitFeed(form);
});
documentRef.addEventListener('click', function (event) {
var likeButton = event.target.closest('[data-feed-like]');
var commentButton = event.target.closest('[data-feed-comment]');
var target = event.target;
var feedId;
var commentId;
if (likeButton) {
if (target.closest('[data-feed-like]')) {
event.preventDefault();
likeFeed(likeButton.getAttribute('data-feed-like'));
likeFeed(target.closest('[data-feed-like]').getAttribute('data-feed-like'), true);
}
if (commentButton) {
if (target.closest('[data-feed-unlike]')) {
event.preventDefault();
commentFeed(commentButton.getAttribute('data-feed-comment'));
likeFeed(target.closest('[data-feed-unlike]').getAttribute('data-feed-unlike'), false);
}
if (target.closest('[data-feed-comments]')) {
event.preventDefault();
feedId = target.closest('[data-feed-comments]').getAttribute('data-feed-comments');
showComments(feedId);
}
if (target.closest('[data-feed-comment]')) {
event.preventDefault();
feedId = target.closest('[data-feed-comment]').getAttribute('data-feed-comment');
createComment(feedId);
}
if (target.closest('[data-feed-delete]')) {
event.preventDefault();
deleteFeed(target.closest('[data-feed-delete]').getAttribute('data-feed-delete'));
}
if (target.closest('[data-feed-comment-delete]')) {
event.preventDefault();
commentId = target.closest('[data-feed-comment-delete]').getAttribute('data-feed-comment-delete');
feedId = target.closest('[data-feed-comment-delete]').getAttribute('data-feed-id');
deleteComment(feedId, commentId);
}
});
}
function init() {
if (!documentRef) return;
bindActions();
loadFeeds();
if (query('[data-feed-page]')) loadFeeds();
if (query('[data-feed-edit-page]')) loadFeedForEdit();
}
return {
normalizeList: normalizeList,
getCurrentGenealogyId: getCurrentGenealogyId,
getCurrentFeedId: getCurrentFeedId,
buildFeedBody: buildFeedBody,
buildCommentBody: buildCommentBody,
buildFeedRow: buildFeedRow,
normalizeFeed: normalizeFeed,
normalizeComment: normalizeComment,
normalizeList: normalizeList,
init: init
};
});
-177
View File
@@ -1,177 +0,0 @@
(function (root, factory) {
// 反馈模块同时支持浏览器页面和 Node 测试。
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 documentRef = root.document;
function normalizeList(data) {
// 兼容分页 rows、records、list 和直接数组。
if (Array.isArray(data)) return data;
if (!data || typeof data !== 'object') return [];
if (Array.isArray(data.rows)) return data.rows;
if (Array.isArray(data.records)) return data.records;
if (Array.isArray(data.list)) return data.list;
if (Array.isArray(data.data)) return data.data;
return [];
}
function pick(item, keys, fallback) {
var source = item || {};
var index;
var value;
for (index = 0; index < keys.length; index += 1) {
value = source[keys[index]];
if (value !== undefined && value !== null && value !== '') return value;
}
return fallback || '';
}
function buildFeedbackBody(values) {
// 接口只接收类型、内容、联系方式;页面上的标题合并到内容前面。
var title = values.feedbackTitle ? '【' + values.feedbackTitle + '】' : '';
return {
feedbackType: values.feedbackType || 'suggestion',
feedbackContent: title + (values.feedbackContent || ''),
contactInfo: values.contactInfo || ''
};
}
function buildFeedbackRow(item) {
var type = pick(item, ['feedbackType', 'typeName'], '反馈');
var content = pick(item, ['feedbackContent', 'content'], '暂无描述');
var status = pick(item, ['status', 'statusName'], '已提交');
var time = pick(item, ['createTime', 'createDate', 'createdAt'], '时间待确认');
return '<div class="module-row"><div><h3>' + type + '</h3><p>' +
status + ' · ' + time + ' · ' + content +
'</p></div><span class="pill">查看</span></div>';
}
function getApi() {
return root.GenealogyApi && root.GenealogyApi.defaultClient;
}
function query(selector, rootNode) {
return (rootNode || documentRef).querySelector(selector);
}
function queryAll(selector, rootNode) {
return Array.prototype.slice.call((rootNode || documentRef).querySelectorAll(selector));
}
function readForm(form) {
// name 属性和反馈接口字段保持一致,减少页面文案变化带来的影响。
var values = {};
queryAll('input[name], textarea[name], select[name]', form).forEach(function (field) {
values[field.name] = String(field.value || '').trim();
});
return values;
}
function setBusy(button, busy) {
// 加载态只切换 class,具体样式在 CSS 中维护。
if (!button) return;
button.classList.toggle('is-loading', busy);
if ('disabled' in button) button.disabled = busy;
button.setAttribute('aria-busy', busy ? 'true' : 'false');
}
function showMessage(message) {
if (root.layui && root.layui.layer) {
root.layui.layer.msg(message);
return;
}
root.alert(message);
}
function renderFeedbackList(items) {
var container = query('[data-feedback-list]');
if (!container) return;
if (!items.length) {
container.innerHTML = '<div class="api-empty">暂无历史反馈</div>';
return;
}
container.innerHTML = items.map(buildFeedbackRow).join('');
}
async function loadFeedbackList() {
var api = getApi();
var container = query('[data-feedback-list]');
if (!api || !container) return;
container.classList.add('is-loading');
try {
renderFeedbackList(normalizeList(await api.feedbackList()));
} catch (error) {
showMessage(error.message || '历史反馈加载失败');
} finally {
container.classList.remove('is-loading');
}
}
async function submitFeedback(form, button) {
var api = getApi();
var values = readForm(form);
if (!api) return;
if (!values.feedbackContent) {
showMessage('请填写反馈内容');
return;
}
setBusy(button, true);
try {
await api.submitFeedback(buildFeedbackBody(values));
form.reset();
showMessage('反馈已提交');
loadFeedbackList();
} catch (error) {
showMessage(error.message || '反馈提交失败');
} finally {
setBusy(button, false);
}
}
function bindFeedbackForms() {
queryAll('[data-feedback-form]').forEach(function (form) {
form.addEventListener('submit', function (event) {
event.preventDefault();
submitFeedback(form, query('[type="submit"]', form));
});
});
}
function init() {
if (!documentRef) return;
bindFeedbackForms();
loadFeedbackList();
}
return {
normalizeList: normalizeList,
buildFeedbackBody: buildFeedbackBody,
buildFeedbackRow: buildFeedbackRow,
init: init
};
});
-407
View File
@@ -1,407 +0,0 @@
(function (root, factory) {
// 家谱模块脚本同时支持浏览器运行和 Node 单元测试。
if (typeof module === 'object' && module.exports) {
module.exports = factory(root);
return;
}
root.GenealogyPages = factory(root);
if (root.document) {
root.document.addEventListener('DOMContentLoaded', function () {
root.GenealogyPages.init();
});
}
})(typeof globalThis !== 'undefined' ? globalThis : window, function (root) {
'use strict';
var documentRef = root.document;
function normalizeList(data) {
// 列表接口可能返回数组、rows、records 或 list,这里统一成数组供页面渲染。
if (Array.isArray(data)) return data;
if (!data || typeof data !== 'object') return [];
if (Array.isArray(data.rows)) return data.rows;
if (Array.isArray(data.records)) return data.records;
if (Array.isArray(data.list)) return data.list;
if (Array.isArray(data.data)) return data.data;
return [];
}
function pick(item, keys, fallback) {
// 后端字段名存在少量差异时,按优先级取第一个可用字段。
var source = item || {};
var index;
var value;
for (index = 0; index < keys.length; index += 1) {
value = source[keys[index]];
if (value !== undefined && value !== null && value !== '') return value;
}
return fallback || '';
}
function buildGenealogyBody(values) {
// 创建家谱接口必填 genealogyName、surname、regionCode,地区编码由地区选择器回填。
var source = values || {};
return {
genealogyName: source.genealogyName || '',
surname: source.surname || '',
regionCode: source.regionCode || '',
addressDetail: source.addressDetail || '',
intro: source.intro || '',
visibility: source.visibility || '1',
joinMode: source.joinMode || '1'
};
}
function getApi() {
return root.GenealogyApi && root.GenealogyApi.defaultClient;
}
function query(selector, rootNode) {
return (rootNode || documentRef).querySelector(selector);
}
function queryAll(selector, rootNode) {
return Array.prototype.slice.call((rootNode || documentRef).querySelectorAll(selector));
}
function readForm(form) {
// 页面表单 name 与接口字段对齐,后续加字段只需要补 HTML。
var values = {};
queryAll('input[name], textarea[name], select[name]', form).forEach(function (field) {
values[field.name] = String(field.value || '').trim();
});
return values;
}
function getQueryParam(search, name) {
// 详情页和加入页用 query 参数传递 genealogyId,测试环境也可直接传 search 字符串。
var params = new URLSearchParams(String(search || '').replace(/^\?/, ''));
return params.get(name) || '';
}
function getCurrentGenealogyId() {
var search = root.location && root.location.search;
return getQueryParam(search, 'id') || getQueryParam(search, 'genealogyId');
}
function escapeHtml(value) {
// 接口内容进入 innerHTML 前统一转义,避免公开页面渲染异常内容。
return String(value === undefined || value === null ? '' : value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function setBusy(button, busy) {
// 加载状态只加 class,样式统一写在 CSS 中。
if (!button) return;
button.classList.toggle('is-loading', busy);
if ('disabled' in button) button.disabled = busy;
button.setAttribute('aria-busy', busy ? 'true' : 'false');
}
function showMessage(message) {
if (root.layui && root.layui.layer) {
root.layui.layer.msg(message);
return;
}
root.alert(message);
}
function requireValue(value, message) {
if (value) return true;
showMessage(message);
return false;
}
function buildFamilyHeroTitle(detail) {
return pick(detail, ['genealogyName', 'name', 'title'], '未命名家谱');
}
function formatJoinMode(value) {
var map = {
0: '关闭加入',
1: '审核加入',
2: '邀请码加入'
};
return map[value] || value || '多人共修';
}
function buildFamilySummary(detail) {
// 后端详情字段可能来自统计字段或普通展示字段,这里集中做字段映射。
var memberCount = pick(detail, ['memberCount', 'membersCount'], '0');
var generationCount = pick(detail, ['generationCount', 'generationTotal'], '0');
var articleCount = pick(detail, ['articleCount', 'articlesCount'], '0');
var albumCount = pick(detail, ['albumCount', 'albumsCount'], '0');
return [
{ label: '成员', value: memberCount },
{ label: '世代', value: generationCount },
{ label: '谱文', value: articleCount },
{ label: '相册', value: albumCount }
];
}
function buildJoinApplyBody(values) {
// 接口文档要求 applicantName、phone、relationDesc、applyReason,可选 inviterUserId。
var source = values || {};
var inviterUserId = source.inviterUserId ? Number(source.inviterUserId) : undefined;
return {
applicantName: String(source.applicantName || '').trim(),
phone: String(source.phone || '').trim(),
relationDesc: String(source.relationDesc || '').trim(),
applyReason: String(source.applyReason || '').trim() || '申请加入家谱',
inviterUserId: inviterUserId
};
}
function renderPublicGenealogies(items) {
// 广场页复用现有卡片结构,只替换接口返回的数据。
var container = query('[data-genealogy-list="public"]');
var html;
if (!container) return;
if (!items.length) {
container.innerHTML = '<div class="api-empty">暂无公开家谱</div>';
return;
}
html = items.slice(0, 6).map(function (item, index) {
// 第一条沿用推荐卡片样式,其余走普通家谱卡片样式。
var id = pick(item, ['genealogyId', 'id'], '');
var name = pick(item, ['genealogyName', 'name', 'title'], '未命名家谱');
var surname = pick(item, ['surname'], '家谱');
var region = pick(item, ['addressDetail', 'regionName', 'originPlace'], '地区待完善');
var intro = pick(item, ['intro', 'description', 'summary'], '家谱资料正在完善中。');
var className = index === 0 ? 'family-card featured tilt-card' : 'card family-lite tilt-card';
return '<a class="' + className + '" href="family-detail.html?id=' + encodeURIComponent(id) + '">' +
'<h3>' + name + '</h3>' +
'<p>' + intro + '</p>' +
'<div class="tag-row"><span class="tag">' + region + '</span><span class="tag">' + surname + '</span></div>' +
'</a>';
}).join('');
container.innerHTML = html;
}
function buildMyGenealogyRow(item) {
// 我的家谱列表保持 profile-module.css 的 module-row 结构。
var id = pick(item, ['genealogyId', 'id'], '');
var name = pick(item, ['genealogyName', 'name'], '未命名家谱');
var role = pick(item, ['roleName', 'memberRole'], '成员');
var count = pick(item, ['memberCount', 'membersCount'], '0');
return '<a class="module-row" href="profile-family-home.html?id=' + encodeURIComponent(id) + '">' +
'<div><h3>' + name + '</h3><p>' + role + ' · 共修入 ' + count + ' 人</p></div>' +
'<span class="pill">进入主页</span></a>';
}
function renderMyGenealogies(items) {
// 没有数据时显示公共空状态,避免保留旧静态示例误导用户。
var container = query('[data-genealogy-list="mine"]');
if (!container) return;
if (!items.length) {
container.innerHTML = '<div class="api-empty">你还没有创建或加入家谱</div>';
return;
}
container.innerHTML = items.map(buildMyGenealogyRow).join('');
}
function renderFamilyDetail(detail) {
var page = query('[data-family-detail]');
var summaryGrid = query('[data-family-summary]');
var joinLink = query('[data-family-join-link]');
var id;
if (!page) return;
id = pick(detail, ['genealogyId', 'id'], getCurrentGenealogyId());
queryAll('[data-family-text]').forEach(function (node) {
var field = node.getAttribute('data-family-text');
var fallback = node.getAttribute('data-fallback') || '';
var value = field === 'title'
? buildFamilyHeroTitle(detail)
: pick(detail, field.split('|'), fallback);
node.textContent = field.indexOf('joinMode') !== -1 ? formatJoinMode(value) : value;
});
if (summaryGrid) {
summaryGrid.innerHTML = buildFamilySummary(detail).map(function (item) {
return '<span><b>' + escapeHtml(item.value) + '</b>' + escapeHtml(item.label) + '</span>';
}).join('');
}
if (joinLink && id) {
joinLink.href = 'join-genealogy.html?id=' + encodeURIComponent(id);
}
}
async function loadFamilyDetail() {
// 只有详情页存在 data-family-detail,且 URL 带 id/genealogyId 时才请求接口。
var api = getApi();
var page = query('[data-family-detail]');
var genealogyId = getCurrentGenealogyId();
if (!api || !page) return;
if (!genealogyId) {
showMessage('缺少家谱ID');
return;
}
page.classList.add('is-loading');
try {
renderFamilyDetail(await api.genealogyDetail(genealogyId));
} catch (error) {
showMessage(error.message || '家谱详情加载失败');
} finally {
page.classList.remove('is-loading');
}
}
async function submitJoinApply(form, button) {
var api = getApi();
var values = readForm(form);
var genealogyId = values.genealogyId || getCurrentGenealogyId();
var body = buildJoinApplyBody(values);
if (!api) return;
if (!requireValue(genealogyId, '请填写家谱ID或邀请码')) return;
if (!requireValue(body.applicantName, '请填写你的姓名')) return;
if (!requireValue(body.phone, '请填写手机号')) return;
if (!requireValue(body.relationDesc, '请填写关系说明')) return;
setBusy(button, true);
try {
await api.applyJoinGenealogy(genealogyId, body);
showMessage('申请已提交,请等待管理员审核');
root.location.href = form.getAttribute('data-success-url') || 'profile-join-family.html';
} catch (error) {
showMessage(error.message || '申请提交失败');
} finally {
setBusy(button, false);
}
}
async function loadPublicGenealogies() {
// 只有带 data-genealogy-list="public" 的页面才会真正发起请求。
var api = getApi();
var container = query('[data-genealogy-list="public"]');
if (!api || !container) return;
container.classList.add('is-loading');
try {
renderPublicGenealogies(normalizeList(await api.publicGenealogies()));
} catch (error) {
showMessage(error.message || '公开家谱加载失败');
} finally {
container.classList.remove('is-loading');
}
}
async function loadMyGenealogies() {
// 只有个人中心“我的家谱”页面会命中这个容器。
var api = getApi();
var container = query('[data-genealogy-list="mine"]');
if (!api || !container) return;
container.classList.add('is-loading');
try {
renderMyGenealogies(normalizeList(await api.myGenealogies()));
} catch (error) {
showMessage(error.message || '我的家谱加载失败');
} finally {
container.classList.remove('is-loading');
}
}
async function submitGenealogy(form, button) {
// 创建成功后跳到我的家谱列表,后续继续维护成员和内容。
var api = getApi();
var body = buildGenealogyBody(readForm(form));
if (!api) return;
if (!requireValue(body.genealogyName, '请填写家谱名称')) return;
if (!requireValue(body.surname, '请填写主要姓氏')) return;
if (!requireValue(body.regionCode, '请选择家族所在地')) return;
setBusy(button, true);
try {
await api.createGenealogy(body);
showMessage('家谱创建成功');
root.location.href = form.getAttribute('data-success-url') || 'profile-families.html';
} catch (error) {
showMessage(error.message || '家谱创建失败');
} finally {
setBusy(button, false);
}
}
function bindCreateForm() {
// 创建表单用 submit 事件,兼容按钮点击和键盘回车提交。
var form = query('[data-genealogy-form="create"]');
if (!form) return;
form.addEventListener('submit', function (event) {
event.preventDefault();
submitGenealogy(form, query('[type="submit"]', form));
});
}
function bindJoinForm() {
// 加入页用同一个 submit 入口,兼容详情页带 id 跳转和用户手动输入家谱ID。
var form = query('[data-genealogy-form="join"]');
var idField;
var currentId;
if (!form) return;
idField = query('[name="genealogyId"]', form);
currentId = getCurrentGenealogyId();
if (idField && currentId && !idField.value) idField.value = currentId;
form.addEventListener('submit', function (event) {
event.preventDefault();
submitJoinApply(form, query('[type="submit"]', form));
});
}
function init() {
// 一个模块脚本服务多个家谱页面,通过 data 属性决定当前页面要做什么。
if (!documentRef) return;
bindCreateForm();
bindJoinForm();
loadPublicGenealogies();
loadMyGenealogies();
loadFamilyDetail();
}
return {
normalizeList: normalizeList,
pick: pick,
buildGenealogyBody: buildGenealogyBody,
getQueryParam: getQueryParam,
buildFamilyHeroTitle: buildFamilyHeroTitle,
buildFamilySummary: buildFamilySummary,
formatJoinMode: formatJoinMode,
buildJoinApplyBody: buildJoinApplyBody,
buildMyGenealogyRow: buildMyGenealogyRow,
init: init
};
});
-349
View File
@@ -1,349 +0,0 @@
(function (root, factory) {
// 字辈谱模块同时支持浏览器页面和 Node 单元测试。
if (typeof module === 'object' && module.exports) {
module.exports = factory(root);
return;
}
root.GenerationPages = factory(root);
if (root.document) {
root.document.addEventListener('DOMContentLoaded', function () {
root.GenerationPages.init();
});
}
})(typeof globalThis !== 'undefined' ? globalThis : window, function (root) {
'use strict';
var documentRef = root.document;
function normalizeList(data) {
// 通用列表响应可能是数组,也可能包在 rows、records、list 或 data 中。
if (Array.isArray(data)) return data;
if (!data || typeof data !== 'object') return [];
if (Array.isArray(data.rows)) return data.rows;
if (Array.isArray(data.records)) return data.records;
if (Array.isArray(data.list)) return data.list;
if (Array.isArray(data.data)) return data.data;
return [];
}
function pick(item, keys, fallback) {
var source = item || {};
var index;
var value;
for (index = 0; index < keys.length; index += 1) {
value = source[keys[index]];
if (value !== undefined && value !== null && value !== '') return value;
}
return fallback || '';
}
function escapeHtml(value) {
// 字辈文本进入 innerHTML 前统一转义。
return String(value === undefined || value === null ? '' : value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function getApi() {
return root.GenealogyApi && root.GenealogyApi.defaultClient;
}
function query(selector, rootNode) {
if (!documentRef) return null;
return (rootNode || documentRef).querySelector(selector);
}
function queryAll(selector, rootNode) {
if (!documentRef) return [];
return Array.prototype.slice.call((rootNode || documentRef).querySelectorAll(selector));
}
function showMessage(message) {
if (root.layui && root.layui.layer) {
root.layui.layer.msg(message);
return;
}
if (root.alert) {
root.alert(message);
}
}
function getQueryParam(search, name) {
var params = new URLSearchParams(String(search || '').replace(/^\?/, ''));
return params.get(name) || '';
}
function getCurrentGenealogyId() {
var search = root.location && root.location.search;
var page = query('[data-generation-page]');
return getQueryParam(search, 'id') ||
getQueryParam(search, 'genealogyId') ||
(page && page.getAttribute('data-genealogy-id')) ||
'';
}
function toNumber(value, fallback) {
var number = Number(String(value || '').trim());
return Number.isFinite(number) && number > 0 ? number : fallback;
}
function toBoolean(value) {
return value === true || value === 'true' || value === '1' || value === 'on';
}
function buildGenerationBody(values) {
// GenerationPoemBody 必填 generationNo、generationText,其余字段给接口默认值。
var source = values || {};
var generationNo = toNumber(source.generationNo, 1);
return {
generationNo: generationNo,
generationText: String(source.generationText || '').trim(),
description: String(source.description || '').trim(),
sortOrder: toNumber(source.sortOrder, generationNo),
status: String(source.status || '').trim() || '0'
};
}
function buildGenerationBatchBody(values) {
// GenerationPoemBatchBody 只包含批量文本和是否遇到断代即停止。
var source = values || {};
return {
content: String(source.content || '').trim(),
stopMissingOldGeneration: toBoolean(source.stopMissingOldGeneration)
};
}
function getFormValues(form) {
var values = {};
queryAll('[name]', form).forEach(function (field) {
if (field.type === 'checkbox') {
values[field.name] = field.checked ? 'on' : '';
return;
}
values[field.name] = field.value;
});
return values;
}
function setBatchStatus(message) {
var statusNode = query('[data-generation-batch-status]');
if (statusNode) {
statusNode.textContent = message || '';
}
}
function buildGenerationRow(item, genealogyId) {
var poemId = pick(item, ['poemId', 'id'], '');
var generationNo = pick(item, ['generationNo', 'generation', 'sortOrder'], '1');
var text = pick(item, ['generationText', 'content', 'text'], '未设置');
var memberCount = pick(item, ['memberCount', 'personCount', 'count'], '0');
var description = pick(item, ['description', 'remark'], '暂无备注');
return '<div class="module-row generation-row" data-generation-id="' + escapeHtml(poemId) + '">' +
'<div><h3>第 ' + escapeHtml(generationNo) + ' 代:' + escapeHtml(text) + '</h3><p>' + escapeHtml(memberCount) + ' 人 · ' + escapeHtml(description) + '</p></div>' +
'<button class="pill" type="button" data-generation-edit="' + escapeHtml(poemId) + '" data-generation-no="' + escapeHtml(generationNo) + '" data-generation-text="' + escapeHtml(text) + '" data-genealogy-id="' + escapeHtml(genealogyId) + '">编辑</button>' +
'</div>';
}
function renderGenerations(items, genealogyId) {
var container = query('[data-generation-list]');
if (!container) return;
if (!items.length) {
container.innerHTML = '<div class="api-empty">暂无字辈记录</div>';
return;
}
container.innerHTML = items.map(function (item) {
return buildGenerationRow(item, genealogyId);
}).join('');
}
function renderBatchPreview(data) {
var container = query('[data-generation-batch-preview]');
var items = normalizeList(data);
if (!container) return;
if (!items.length) {
container.innerHTML = '<div class="api-empty">预览完成,接口未返回明细</div>';
return;
}
container.innerHTML = items.map(function (item) {
return buildGenerationRow(item, getCurrentGenealogyId());
}).join('');
}
async function loadGenerations() {
var api = getApi();
var container = query('[data-generation-list]');
var genealogyId = getCurrentGenealogyId();
if (!api || !container) return;
if (!genealogyId) {
container.innerHTML = '<div class="api-empty">请从具体家谱进入字辈谱</div>';
return;
}
container.classList.add('is-loading');
try {
renderGenerations(normalizeList(await api.generationPoems(genealogyId)), genealogyId);
} catch (error) {
showMessage(error.message || '字辈谱加载失败');
} finally {
container.classList.remove('is-loading');
}
}
async function createGeneration() {
var api = getApi();
var genealogyId = getCurrentGenealogyId();
var generationNo;
var generationText;
var body;
if (!api || !genealogyId) {
showMessage('请从具体家谱进入字辈谱');
return;
}
generationNo = root.prompt('请输入世代序号', '');
if (!generationNo) return;
generationText = root.prompt('请输入字辈内容', '');
if (!generationText) return;
body = buildGenerationBody({
generationNo: generationNo,
generationText: generationText
});
try {
await api.createGenerationPoem(genealogyId, body);
showMessage('字辈已新增');
loadGenerations();
} catch (error) {
showMessage(error.message || '新增字辈失败');
}
}
async function editGeneration(button) {
var api = getApi();
var genealogyId = button.getAttribute('data-genealogy-id') || getCurrentGenealogyId();
var poemId = button.getAttribute('data-generation-edit');
var currentText = button.getAttribute('data-generation-text') || '';
var generationNo = button.getAttribute('data-generation-no') || '1';
var nextText;
if (!api || !genealogyId || !poemId) return;
nextText = root.prompt('请输入新的字辈', currentText);
if (!nextText) return;
button.classList.add('is-loading');
try {
await api.updateGenerationPoem(genealogyId, poemId, buildGenerationBody({
generationNo: generationNo,
generationText: nextText
}));
showMessage('字辈已更新');
loadGenerations();
} catch (error) {
showMessage(error.message || '更新字辈失败');
} finally {
button.classList.remove('is-loading');
}
}
async function submitGenerationBatch(action) {
var api = getApi();
var genealogyId = getCurrentGenealogyId();
var form = query('[data-generation-batch-form]');
var body;
var result;
if (!api || !genealogyId || !form) {
showMessage('请从具体家谱进入字辈谱');
return;
}
body = buildGenerationBatchBody(getFormValues(form));
if (!body.content) {
showMessage('请填写批量字辈内容');
return;
}
try {
setBatchStatus(action === 'save' ? '批量保存中...' : '批量预览中...');
if (action === 'save') {
await api.saveGenerationPoemsBatch(genealogyId, body);
setBatchStatus('批量保存完成');
showMessage('批量字辈已保存');
loadGenerations();
return;
}
result = await api.previewGenerationPoems(genealogyId, body);
renderBatchPreview(result);
setBatchStatus('批量预览完成');
} catch (error) {
setBatchStatus('批量处理失败');
showMessage(error.message || '批量字辈处理失败');
}
}
function bindActions() {
if (!documentRef) return;
documentRef.addEventListener('click', function (event) {
var addButton = event.target.closest('[data-generation-add]');
var editButton = event.target.closest('[data-generation-edit]');
var batchButton = event.target.closest('[data-generation-batch-action]');
if (addButton) {
event.preventDefault();
createGeneration();
}
if (editButton) {
event.preventDefault();
editGeneration(editButton);
}
if (batchButton) {
event.preventDefault();
submitGenerationBatch(batchButton.getAttribute('data-generation-batch-action'));
}
});
}
function init() {
if (!documentRef) return;
bindActions();
loadGenerations();
}
return {
normalizeList: normalizeList,
buildGenerationBody: buildGenerationBody,
buildGenerationBatchBody: buildGenerationBatchBody,
buildGenerationRow: buildGenerationRow,
init: init
};
});
-244
View File
@@ -1,244 +0,0 @@
(function (root, factory) {
// 成长记录模块同时支持浏览器页面和 Node 单元测试。
if (typeof module === 'object' && module.exports) {
module.exports = factory(root);
return;
}
root.GrowthPages = factory(root);
if (root.document) {
root.document.addEventListener('DOMContentLoaded', function () {
root.GrowthPages.init();
});
}
})(typeof globalThis !== 'undefined' ? globalThis : window, function (root) {
'use strict';
var documentRef = root.document;
function normalizeList(data) {
// 成长记录列表兼容数组、rows、records、list、data。
if (Array.isArray(data)) return data;
if (!data || typeof data !== 'object') return [];
if (Array.isArray(data.rows)) return data.rows;
if (Array.isArray(data.records)) return data.records;
if (Array.isArray(data.list)) return data.list;
if (Array.isArray(data.data)) return data.data;
return [];
}
function pick(item, keys, fallback) {
var source = item || {};
var index;
var value;
for (index = 0; index < keys.length; index += 1) {
value = source[keys[index]];
if (value !== undefined && value !== null && value !== '') return value;
}
return fallback || '';
}
function escapeHtml(value) {
// 成长记录标题和内容进入 innerHTML 前统一转义。
return String(value === undefined || value === null ? '' : value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function getApi() {
return root.GenealogyApi && root.GenealogyApi.defaultClient;
}
function query(selector, rootNode) {
return (rootNode || documentRef).querySelector(selector);
}
function queryAll(selector, rootNode) {
return Array.prototype.slice.call((rootNode || documentRef).querySelectorAll(selector));
}
function showMessage(message) {
if (root.layui && root.layui.layer) {
root.layui.layer.msg(message);
return;
}
root.alert(message);
}
function getQueryParam(search, name) {
var params = new URLSearchParams(String(search || '').replace(/^\?/, ''));
return params.get(name) || '';
}
function getCurrentGenealogyId() {
var search = root.location && root.location.search;
var page = query('[data-growth-page], [data-growth-edit-page]');
return getQueryParam(search, 'genealogyId') ||
(page && page.getAttribute('data-genealogy-id')) ||
getQueryParam(search, 'id') ||
'';
}
function getCurrentRecordId() {
var search = root.location && root.location.search;
return getQueryParam(search, 'recordId') || '';
}
function trimOrUndefined(value) {
var text = String(value === undefined || value === null ? '' : value).trim();
return text || undefined;
}
function toNumber(value, fallback) {
var text = String(value === undefined || value === null ? '' : value).trim();
var number = Number(text);
return text && Number.isFinite(number) ? number : fallback;
}
function buildGrowthBody(values) {
var source = values || {};
return {
lineagePersonId: toNumber(source.lineagePersonId),
recordType: String(source.recordType || '').trim() || 'growth',
recordTitle: String(source.recordTitle || '').trim(),
recordContent: trimOrUndefined(source.recordContent),
recordDate: trimOrUndefined(source.recordDate),
remindTime: trimOrUndefined(source.remindTime),
mediaOssIds: trimOrUndefined(source.mediaOssIds),
sortOrder: toNumber(source.sortOrder, 1),
status: String(source.status || '').trim() || '0'
};
}
function getRecordId(item) {
return pick(item, ['recordId', 'id'], '');
}
function buildGrowthRow(item, genealogyId) {
var recordId = getRecordId(item);
var title = pick(item, ['recordTitle', 'title'], '未命名成长记录');
var type = pick(item, ['recordType', 'type'], 'growth');
var date = pick(item, ['recordDate', 'createTime'], '未记录日期');
return '<a class="module-row growth-row" href="profile-growth-edit.html?recordId=' + encodeURIComponent(recordId) + '&genealogyId=' + encodeURIComponent(genealogyId || '') + '">' +
'<div><h3>' + escapeHtml(title) + '</h3><p>' + escapeHtml(type) + ' · ' + escapeHtml(date) + '</p></div>' +
'<span class="pill">编辑</span></a>';
}
function renderGrowthRecords(items, genealogyId) {
var container = query('[data-growth-list]');
var list = normalizeList(items);
if (!container) return;
if (!list.length) {
container.innerHTML = '<div class="api-empty">暂无成长记录</div>';
return;
}
container.innerHTML = list.map(function (item) {
return buildGrowthRow(item, genealogyId);
}).join('');
}
function getFormValues(form) {
var values = {};
queryAll('[name]', form).forEach(function (field) {
values[field.name] = field.value;
});
return values;
}
function fillGrowthForm(record) {
var source = record || {};
queryAll('[name]').forEach(function (field) {
var value = pick(source, [field.name], '');
if (value !== '') field.value = value;
});
}
async function loadGrowthRecords() {
var api = getApi();
var genealogyId = getCurrentGenealogyId();
var recordId = getCurrentRecordId();
if (!api || !genealogyId) return;
try {
if (query('[data-growth-list]')) {
renderGrowthRecords(await api.growthRecords(genealogyId), genealogyId);
}
if (recordId && query('[data-growth-edit-page]')) {
fillGrowthForm(await api.growthRecordDetail(genealogyId, recordId));
}
} catch (error) {
showMessage(error.message || '成长记录加载失败');
}
}
async function submitGrowth(form) {
var api = getApi();
var genealogyId = getCurrentGenealogyId();
var recordId = getCurrentRecordId();
var body = buildGrowthBody(getFormValues(form));
if (!api || !genealogyId) return;
if (!body.recordTitle) {
showMessage('请填写成长记录标题');
return;
}
try {
if (recordId) {
await api.updateGrowthRecord(genealogyId, recordId, body);
} else {
await api.createGrowthRecord(genealogyId, body);
}
showMessage('成长记录已保存');
root.location.href = 'profile-growth.html?genealogyId=' + encodeURIComponent(genealogyId);
} catch (error) {
showMessage(error.message || '保存成长记录失败');
}
}
function bindActions() {
if (!documentRef) return;
documentRef.addEventListener('submit', function (event) {
var form = event.target.closest('[data-growth-form]');
if (!form) return;
event.preventDefault();
submitGrowth(form);
});
}
function init() {
if (!documentRef) return;
bindActions();
loadGrowthRecords();
}
return {
normalizeList: normalizeList,
buildGrowthBody: buildGrowthBody,
buildGrowthRow: buildGrowthRow,
init: init
};
});
-156
View File
@@ -1,156 +0,0 @@
(function (root, factory) {
// 帮助中心模块同时支持浏览器页面和 Node 单元测试。
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';
var documentRef = root.document;
function normalizeList(data) {
// 通用列表响应可能是数组,也可能包在 rows、records、list 或 data 中。
if (Array.isArray(data)) return data;
if (!data || typeof data !== 'object') return [];
if (Array.isArray(data.rows)) return data.rows;
if (Array.isArray(data.records)) return data.records;
if (Array.isArray(data.list)) return data.list;
if (Array.isArray(data.data)) return data.data;
return [];
}
function pick(item, keys, fallback) {
var source = item || {};
var index;
var value;
for (index = 0; index < keys.length; index += 1) {
value = source[keys[index]];
if (value !== undefined && value !== null && value !== '') return value;
}
return fallback || '';
}
function escapeHtml(value) {
// 帮助文章来自接口,进入 innerHTML 前先转义。
return String(value === undefined || value === null ? '' : value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function getApi() {
return root.GenealogyApi && root.GenealogyApi.defaultClient;
}
function query(selector, rootNode) {
return (rootNode || documentRef).querySelector(selector);
}
function showMessage(message) {
if (root.layui && root.layui.layer) {
root.layui.layer.msg(message);
return;
}
root.alert(message);
}
function buildHelpItem(item, index) {
// 列表接口字段未在文档中细化,这里兼容常见标题和摘要字段。
var id = pick(item, ['helpId', 'id', 'articleId'], '');
var title = pick(item, ['title', 'articleTitle', 'name'], '帮助文章');
var content = pick(item, ['summary', 'description', 'content', 'articleContent'], '暂无详细说明');
var hasFullContent = Boolean(pick(item, ['content', 'articleContent'], ''));
var open = index === 0 ? ' open' : '';
return '<details' + open + ' class="tilt-card" data-help-id="' + escapeHtml(id) + '" data-help-loaded="' + (hasFullContent ? 'true' : 'false') + '">' +
'<summary>' + escapeHtml(title) + '</summary>' +
'<p>' + escapeHtml(content) + '</p>' +
'</details>';
}
function renderHelpArticles(items) {
var container = query('[data-help-list]');
if (!container) return;
if (!items.length) {
container.innerHTML = '<div class="api-empty">暂无帮助文章</div>';
return;
}
container.innerHTML = items.map(buildHelpItem).join('');
}
async function loadHelpArticles() {
var api = getApi();
var container = query('[data-help-list]');
if (!api || !container) return;
container.classList.add('is-loading');
try {
renderHelpArticles(normalizeList(await api.helpArticles()));
} catch (error) {
showMessage(error.message || '帮助文章加载失败');
} finally {
container.classList.remove('is-loading');
}
}
async function loadHelpDetail(details) {
var api = getApi();
var helpId = details && details.getAttribute('data-help-id');
var paragraph = details && query('p', details);
var content;
if (!api || !details || !helpId || details.getAttribute('data-help-loaded') === 'true') return;
details.classList.add('is-loading');
try {
content = await api.helpArticleDetail(helpId);
paragraph.textContent = pick(content, ['content', 'articleContent', 'summary', 'description'], paragraph.textContent);
details.setAttribute('data-help-loaded', 'true');
} catch (error) {
showMessage(error.message || '帮助详情加载失败');
} finally {
details.classList.remove('is-loading');
}
}
function bindDetailLoad() {
var container = query('[data-help-list]');
if (!container) return;
container.addEventListener('toggle', function (event) {
if (event.target && event.target.matches('details') && event.target.open) {
loadHelpDetail(event.target);
}
}, true);
}
function init() {
if (!documentRef) return;
bindDetailLoad();
loadHelpArticles();
}
return {
normalizeList: normalizeList,
buildHelpItem: buildHelpItem,
renderHelpArticles: renderHelpArticles,
init: init
};
});
-271
View File
@@ -1,271 +0,0 @@
(function (root, factory) {
// 加入申请模块同时支持浏览器页面和 Node 单元测试。
if (typeof module === 'object' && module.exports) {
module.exports = factory(root);
return;
}
root.JoinApplyPages = factory(root);
if (root.document) {
root.document.addEventListener('DOMContentLoaded', function () {
root.JoinApplyPages.init();
});
}
})(typeof globalThis !== 'undefined' ? globalThis : window, function (root) {
'use strict';
var documentRef = root.document;
function normalizeList(data) {
// 列表响应可能直接是数组,也可能使用 rows、records、list 或 data 包裹。
if (Array.isArray(data)) return data;
if (!data || typeof data !== 'object') return [];
if (Array.isArray(data.rows)) return data.rows;
if (Array.isArray(data.records)) return data.records;
if (Array.isArray(data.list)) return data.list;
if (Array.isArray(data.data)) return data.data;
return [];
}
function pick(item, keys, fallback) {
var source = item || {};
var index;
var value;
for (index = 0; index < keys.length; index += 1) {
value = source[keys[index]];
if (value !== undefined && value !== null && value !== '') return value;
}
return fallback || '';
}
function escapeHtml(value) {
// 申请人信息来自接口,渲染前统一转义。
return String(value === undefined || value === null ? '' : value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function getApi() {
return root.GenealogyApi && root.GenealogyApi.defaultClient;
}
function query(selector, rootNode) {
return (rootNode || documentRef).querySelector(selector);
}
function showMessage(message) {
if (root.layui && root.layui.layer) {
root.layui.layer.msg(message);
return;
}
root.alert(message);
}
function getQueryParam(search, name) {
var params = new URLSearchParams(String(search || '').replace(/^\?/, ''));
return params.get(name) || '';
}
function getCurrentGenealogyId() {
var search = root.location && root.location.search;
var fromQuery = getQueryParam(search, 'id') || getQueryParam(search, 'genealogyId');
var fromPage = query('[data-genealogy-id]');
return fromQuery || (fromPage && fromPage.getAttribute('data-genealogy-id')) || '';
}
function formatApplyStatus(status) {
// 审核接口要求 status,按常见字典值做页面中文展示。
var map = {
0: '待审核',
1: '已通过',
2: '已拒绝'
};
return map[status] || status || '待审核';
}
function buildAuditBody(action, remark) {
var isReject = action === 'reject';
return {
status: isReject ? '2' : '1',
auditRemark: remark || (isReject ? '信息核验未通过' : '信息核验通过')
};
}
function buildPendingRow(item, genealogyId) {
var applyId = pick(item, ['applyId', 'id'], '');
var name = pick(item, ['applicantName', 'name', 'nickName'], '未命名申请人');
var relation = pick(item, ['relationDesc', 'applyReason', 'remark'], '关系说明待确认');
var time = pick(item, ['createTime', 'createdAt', 'applyTime'], '提交时间待确认');
return '<div class="module-row join-apply-row" data-join-apply-id="' + escapeHtml(applyId) + '">' +
'<div><h3>' + escapeHtml(name) + '申请加入家谱</h3><p>' + escapeHtml(time) + ' · ' + escapeHtml(relation) + '</p></div>' +
'<div class="bottom-actions">' +
'<button class="btn primary" type="button" data-join-audit="approve" data-genealogy-id="' + escapeHtml(genealogyId) + '" data-apply-id="' + escapeHtml(applyId) + '">同意</button>' +
'<button class="btn ghost" type="button" data-join-audit="reject" data-genealogy-id="' + escapeHtml(genealogyId) + '" data-apply-id="' + escapeHtml(applyId) + '">拒绝</button>' +
'</div></div>';
}
function buildMineRow(item) {
var applyId = pick(item, ['applyId', 'id'], '');
var genealogyName = pick(item, ['genealogyName', 'familyName', 'name'], '未命名家谱');
var status = formatApplyStatus(pick(item, ['status', 'auditStatus'], '0'));
var relation = pick(item, ['relationDesc', 'applyReason', 'auditRemark'], '关系说明待确认');
var action = status === '待审核'
? '<button class="btn ghost" type="button" data-join-cancel="' + escapeHtml(applyId) + '">撤销申请</button>'
: '<span class="pill">' + escapeHtml(status) + '</span>';
return '<div class="module-row join-apply-row" data-join-apply-id="' + escapeHtml(applyId) + '">' +
'<div><h3>' + escapeHtml(genealogyName) + '</h3><p>' + escapeHtml(status) + ' · ' + escapeHtml(relation) + '</p></div>' +
action +
'</div>';
}
function renderPending(items, genealogyId) {
var container = query('[data-join-apply-list="pending"]');
if (!container) return;
if (!items.length) {
container.innerHTML = '<div class="api-empty">暂无待审核申请</div>';
return;
}
container.innerHTML = items.map(function (item) {
return buildPendingRow(item, genealogyId);
}).join('');
}
function renderMine(items) {
var container = query('[data-join-apply-list="mine"]');
if (!container) return;
if (!items.length) {
container.innerHTML = '<div class="api-empty">暂无加入申请记录</div>';
return;
}
container.innerHTML = items.map(buildMineRow).join('');
}
async function loadPending() {
var api = getApi();
var container = query('[data-join-apply-list="pending"]');
var genealogyId = getCurrentGenealogyId();
if (!api || !container) return;
if (!genealogyId) {
container.innerHTML = '<div class="api-empty">请从具体家谱进入审核页</div>';
return;
}
container.classList.add('is-loading');
try {
renderPending(normalizeList(await api.pendingJoinApplies(genealogyId)), genealogyId);
} catch (error) {
showMessage(error.message || '待审核申请加载失败');
} finally {
container.classList.remove('is-loading');
}
}
async function loadMine() {
var api = getApi();
var container = query('[data-join-apply-list="mine"]');
if (!api || !container) return;
container.classList.add('is-loading');
try {
renderMine(normalizeList(await api.myJoinApplies()));
} catch (error) {
showMessage(error.message || '加入申请记录加载失败');
} finally {
container.classList.remove('is-loading');
}
}
async function audit(button) {
var api = getApi();
var action = button.getAttribute('data-join-audit');
var genealogyId = button.getAttribute('data-genealogy-id');
var applyId = button.getAttribute('data-apply-id');
var remark = action === 'reject' ? root.prompt('请输入拒绝原因', '') : '';
if (!api || !genealogyId || !applyId) return;
if (action === 'reject' && remark === null) return;
button.classList.add('is-loading');
try {
await api.auditJoinApply(genealogyId, applyId, buildAuditBody(action, remark));
showMessage(action === 'reject' ? '已拒绝申请' : '已同意申请');
loadPending();
} catch (error) {
showMessage(error.message || '审核申请失败');
} finally {
button.classList.remove('is-loading');
}
}
async function cancel(button) {
var api = getApi();
var applyId = button.getAttribute('data-join-cancel');
if (!api || !applyId) return;
if (root.confirm && !root.confirm('确认撤销当前加入申请?')) return;
button.classList.add('is-loading');
try {
await api.cancelJoinApply(applyId);
showMessage('已撤销加入申请');
loadMine();
} catch (error) {
showMessage(error.message || '撤销申请失败');
} finally {
button.classList.remove('is-loading');
}
}
function bindActions() {
if (!documentRef) return;
documentRef.addEventListener('click', function (event) {
var auditButton = event.target.closest('[data-join-audit]');
var cancelButton = event.target.closest('[data-join-cancel]');
if (auditButton) {
event.preventDefault();
audit(auditButton);
}
if (cancelButton) {
event.preventDefault();
cancel(cancelButton);
}
});
}
function init() {
if (!documentRef) return;
bindActions();
loadPending();
loadMine();
}
return {
normalizeList: normalizeList,
formatApplyStatus: formatApplyStatus,
buildAuditBody: buildAuditBody,
buildPendingRow: buildPendingRow,
buildMineRow: buildMineRow,
init: init
};
});
-399
View File
@@ -1,399 +0,0 @@
(function (root, factory) {
// 世系模块同时支持浏览器页面和 Node 单元测试。
if (typeof module === 'object' && module.exports) {
module.exports = factory(root);
return;
}
root.LineagePages = factory(root);
if (root.document) {
root.document.addEventListener('DOMContentLoaded', function () {
root.LineagePages.init();
});
}
})(typeof globalThis !== 'undefined' ? globalThis : window, function (root) {
'use strict';
var documentRef = root.document;
var selectedPersonId = '';
function normalizeList(data) {
// 通用列表响应可能是数组,也可能包在 rows、records、list、data 中。
if (Array.isArray(data)) return data;
if (!data || typeof data !== 'object') return [];
if (Array.isArray(data.rows)) return data.rows;
if (Array.isArray(data.records)) return data.records;
if (Array.isArray(data.list)) return data.list;
if (Array.isArray(data.data)) return data.data;
return [];
}
function pick(item, keys, fallback) {
var source = item || {};
var index;
var value;
for (index = 0; index < keys.length; index += 1) {
value = source[keys[index]];
if (value !== undefined && value !== null && value !== '') return value;
}
return fallback || '';
}
function escapeHtml(value) {
// 世系人物信息进入 innerHTML 前统一转义,避免姓名和简介污染页面结构。
return String(value === undefined || value === null ? '' : value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function getApi() {
return root.GenealogyApi && root.GenealogyApi.defaultClient;
}
function query(selector, rootNode) {
return (rootNode || documentRef).querySelector(selector);
}
function queryAll(selector, rootNode) {
return Array.prototype.slice.call((rootNode || documentRef).querySelectorAll(selector));
}
function showMessage(message) {
if (root.layui && root.layui.layer) {
root.layui.layer.msg(message);
return;
}
root.alert(message);
}
function getQueryParam(search, name) {
var params = new URLSearchParams(String(search || '').replace(/^\?/, ''));
return params.get(name) || '';
}
function getCurrentGenealogyId() {
var search = root.location && root.location.search;
var page = query('[data-lineage-page], [data-family-home-page]');
return getQueryParam(search, 'id') ||
getQueryParam(search, 'genealogyId') ||
(page && page.getAttribute('data-genealogy-id')) ||
'';
}
function toNumber(value) {
var text = String(value === undefined || value === null ? '' : value).trim();
var number = Number(text);
return text && Number.isFinite(number) ? number : undefined;
}
function trimOrUndefined(value) {
var text = String(value === undefined || value === null ? '' : value).trim();
return text || undefined;
}
function formatSex(value) {
var text = String(value === undefined || value === null ? '' : value);
if (text === '0' || text === '男') return '男';
if (text === '1' || text === '女') return '女';
return '未知';
}
function getPersonId(item) {
return pick(item, ['personId', 'lineagePersonId', 'id'], '');
}
function buildLineagePersonBody(values) {
var source = values || {};
return {
appUserId: toNumber(source.appUserId),
personNo: trimOrUndefined(source.personNo),
personName: String(source.personName || '').trim(),
aliasName: trimOrUndefined(source.aliasName),
sex: String(source.sex || '').trim() || '0',
generationNo: toNumber(source.generationNo),
generationName: trimOrUndefined(source.generationName),
avatarOssId: toNumber(source.avatarOssId),
birthDate: trimOrUndefined(source.birthDate),
deathDate: trimOrUndefined(source.deathDate),
introduction: trimOrUndefined(source.introduction)
};
}
function buildPersonRow(item) {
var personId = getPersonId(item);
var name = pick(item, ['personName', 'name', 'realName'], '未命名成员');
var generationNo = pick(item, ['generationNo', 'generation', 'generationIndex'], '-');
var generationName = pick(item, ['generationName', 'generationText'], '未设置');
var sex = formatSex(pick(item, ['sex', 'gender'], ''));
var bound = pick(item, ['boundAccount', 'bindAccount', 'hasAccount', 'appUserId'], '') ? '已绑定账号' : '未绑定账号';
return '<button class="module-row lineage-row" type="button" data-lineage-person="' + escapeHtml(personId) + '">' +
'<div><h3>' + escapeHtml(name) + '</h3><p>第 ' + escapeHtml(generationNo) + ' 世 · ' + escapeHtml(generationName) + '字辈 · ' + escapeHtml(sex) + ' · ' + escapeHtml(bound) + '</p></div>' +
'<span class="pill">查看资料</span>' +
'</button>';
}
function getChildren(item) {
var source = item || {};
return normalizeList(source.children || source.childList || source.descendants);
}
function buildTreeNode(item) {
var personId = getPersonId(item);
var name = pick(item, ['personName', 'name', 'realName'], '未命名成员');
var generationNo = pick(item, ['generationNo', 'generation', 'generationIndex'], '-');
var children = getChildren(item);
var html = '<li><button type="button" class="lineage-node" data-lineage-person="' + escapeHtml(personId) + '">' +
'<strong>' + escapeHtml(name) + '</strong><span>第 ' + escapeHtml(generationNo) + ' 世</span></button>';
if (children.length) {
html += '<ul>' + children.map(buildTreeNode).join('') + '</ul>';
}
return html + '</li>';
}
function buildHomeSummary(detail, rootCount) {
// 家谱主页摘要优先展示后端家谱信息,世系根节点数量来自树接口。
var source = detail || {};
var genealogyNo = pick(source, ['genealogyNo', 'genealogyCode', 'code'], '未设置编号');
var hallName = pick(source, ['hallName', 'ancestralHall', 'tanghao'], '未设置');
var memberCount = pick(source, ['memberCount', 'personCount', 'members'], '0');
return genealogyNo + ' · 堂号 ' + hallName + ' · 共 ' + memberCount + ' 人 · ' + rootCount + ' 个世系根节点';
}
function renderTree(items) {
var container = query('[data-lineage-tree], [data-lineage-home-tree]');
var list = normalizeList(items);
if (!container) return;
if (!list.length) {
container.innerHTML = '<div class="api-empty">暂无世系树数据</div>';
return;
}
container.innerHTML = '<ul class="lineage-tree">' + list.map(buildTreeNode).join('') + '</ul>';
}
function renderPersons(items) {
var container = query('[data-lineage-list]');
var list = normalizeList(items);
if (!container) return;
if (!list.length) {
container.innerHTML = '<div class="api-empty">暂无世系人物</div>';
return;
}
container.innerHTML = list.map(buildPersonRow).join('');
}
function renderHomeDetail(detail, rootCount) {
var title = query('[data-family-home-title]');
var summary = query('[data-family-home-summary]');
var source = detail || {};
if (title) title.textContent = pick(source, ['genealogyName', 'name'], title.textContent || '家谱主页');
if (summary) summary.textContent = buildHomeSummary(source, rootCount);
}
function renderDetail(item) {
var container = query('[data-lineage-detail]');
var source = item || {};
var name = pick(source, ['personName', 'name', 'realName'], '未命名成员');
var intro = pick(source, ['introduction', 'intro', 'remark'], '暂无简介');
if (!container) return;
container.innerHTML = '<div class="form-like">' +
'<p><span>姓名</span><b>' + escapeHtml(name) + '</b><em>' + escapeHtml(formatSex(pick(source, ['sex', 'gender'], ''))) + '</em></p>' +
'<p><span>字辈</span><b>' + escapeHtml(pick(source, ['generationName', 'generationText'], '未设置')) + '</b><em>第 ' + escapeHtml(pick(source, ['generationNo', 'generation'], '-')) + ' 世</em></p>' +
'<p><span>生卒</span><b>' + escapeHtml(pick(source, ['birthDate'], '未填写')) + '</b><em>' + escapeHtml(pick(source, ['deathDate'], '健在或未填')) + '</em></p>' +
'<p><span>简介</span><b>' + escapeHtml(intro) + '</b><em>世系资料</em></p>' +
'</div>';
}
function getFormValues(form) {
var values = {};
queryAll('[name]', form).forEach(function (field) {
values[field.name] = field.value;
});
return values;
}
async function loadLineage() {
var api = getApi();
var genealogyId = getCurrentGenealogyId();
var keyword = query('[data-lineage-keyword]');
var homeSummary = query('[data-family-home-summary]');
var treeData;
var treeList;
var queryData = {
pageNum: 1,
pageSize: 50
};
if (!api) return;
if (!genealogyId) {
renderTree([]);
renderPersons([]);
showMessage('请从具体家谱进入世系图');
return;
}
if (keyword && keyword.value.trim()) queryData.keyword = keyword.value.trim();
try {
treeData = await api.lineageTree(genealogyId);
treeList = normalizeList(treeData);
renderTree(treeData);
if (homeSummary && api.genealogyDetail) {
renderHomeDetail(await api.genealogyDetail(genealogyId), treeList.length);
}
if (query('[data-lineage-list]')) {
renderPersons(await api.lineagePersonsPage(genealogyId, queryData));
}
} catch (error) {
showMessage(error.message || '世系数据加载失败');
}
}
async function loadPersonDetail(personId) {
var api = getApi();
var genealogyId = getCurrentGenealogyId();
if (!api || !genealogyId || !personId) return;
selectedPersonId = personId;
queryAll('[data-lineage-person]').forEach(function (item) {
item.classList.toggle('is-selected', item.getAttribute('data-lineage-person') === String(personId));
});
try {
renderDetail(await api.lineagePersonDetail(genealogyId, personId));
} catch (error) {
showMessage(error.message || '人物详情加载失败');
}
}
async function submitPerson(form) {
var api = getApi();
var genealogyId = getCurrentGenealogyId();
var body = buildLineagePersonBody(getFormValues(form));
if (!api || !genealogyId) {
showMessage('请从具体家谱进入世系图');
return;
}
if (!body.personName) {
showMessage('请填写成员姓名');
return;
}
try {
await api.createLineagePerson(genealogyId, body);
form.reset();
showMessage('世系人物已新增');
loadLineage();
} catch (error) {
showMessage(error.message || '新增世系人物失败');
}
}
async function createRelation(relationType) {
var api = getApi();
var genealogyId = getCurrentGenealogyId();
var relationNames = {
parents: '父母',
spouses: '配偶',
children: '子女',
siblings: '兄弟姐妹'
};
var personName;
if (!api || !genealogyId || !selectedPersonId) {
showMessage('请先在成员列表中选择一个成员');
return;
}
personName = root.prompt('请输入要添加的' + (relationNames[relationType] || '亲属') + '姓名', '');
if (!personName) return;
try {
await api.addLineageRelation(genealogyId, selectedPersonId, relationType, buildLineagePersonBody({
personName: personName
}));
showMessage('亲属关系已新增');
loadLineage();
} catch (error) {
showMessage(error.message || '新增亲属关系失败');
}
}
function bindActions() {
if (!documentRef) return;
documentRef.addEventListener('submit', function (event) {
var form = event.target.closest('[data-lineage-form]');
if (!form) return;
event.preventDefault();
submitPerson(form);
});
documentRef.addEventListener('click', function (event) {
var personButton = event.target.closest('[data-lineage-person]');
var searchButton = event.target.closest('[data-lineage-search]');
var relationButton = event.target.closest('[data-lineage-relation]');
if (personButton) {
event.preventDefault();
loadPersonDetail(personButton.getAttribute('data-lineage-person'));
}
if (searchButton) {
event.preventDefault();
loadLineage();
}
if (relationButton) {
event.preventDefault();
createRelation(relationButton.getAttribute('data-lineage-relation'));
}
});
}
function init() {
if (!documentRef) return;
bindActions();
loadLineage();
}
return {
normalizeList: normalizeList,
buildLineagePersonBody: buildLineagePersonBody,
formatSex: formatSex,
buildPersonRow: buildPersonRow,
buildTreeNode: buildTreeNode,
buildHomeSummary: buildHomeSummary,
init: init
};
});
-364
View File
@@ -1,364 +0,0 @@
(function (root, factory) {
// 成员与家族管理模块同时支持浏览器页面和 Node 单元测试。
if (typeof module === 'object' && module.exports) {
module.exports = factory(root);
return;
}
root.MemberAdminPages = factory(root);
if (root.document) {
root.document.addEventListener('DOMContentLoaded', function () {
root.MemberAdminPages.init();
});
}
})(typeof globalThis !== 'undefined' ? globalThis : window, function (root) {
'use strict';
var documentRef = root.document;
function normalizeList(data) {
// 成员接口返回可能是数组,也可能包在 rows、records、list、data 中。
if (Array.isArray(data)) return data;
if (!data || typeof data !== 'object') return [];
if (Array.isArray(data.rows)) return data.rows;
if (Array.isArray(data.records)) return data.records;
if (Array.isArray(data.list)) return data.list;
if (Array.isArray(data.data)) return data.data;
return [];
}
function pick(item, keys, fallback) {
var source = item || {};
var index;
var value;
for (index = 0; index < keys.length; index += 1) {
value = source[keys[index]];
if (value !== undefined && value !== null && value !== '') return value;
}
return fallback || '';
}
function escapeHtml(value) {
// 成员姓名、关系名进入 innerHTML 前统一转义。
return String(value === undefined || value === null ? '' : value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function getApi() {
return root.GenealogyApi && root.GenealogyApi.defaultClient;
}
function query(selector, rootNode) {
return (rootNode || documentRef).querySelector(selector);
}
function queryAll(selector, rootNode) {
return Array.prototype.slice.call((rootNode || documentRef).querySelectorAll(selector));
}
function showMessage(message) {
if (root.layui && root.layui.layer) {
root.layui.layer.msg(message);
return;
}
root.alert(message);
}
function getQueryParam(search, name) {
var params = new URLSearchParams(String(search || '').replace(/^\?/, ''));
return params.get(name) || '';
}
function getCurrentGenealogyId() {
var search = root.location && root.location.search;
var page = query('[data-member-admin-page], [data-admin-permission-page], [data-family-invite-page], [data-family-share-page]');
return getQueryParam(search, 'id') ||
getQueryParam(search, 'genealogyId') ||
(page && page.getAttribute('data-genealogy-id')) ||
'';
}
function trimOrUndefined(value) {
var text = String(value === undefined || value === null ? '' : value).trim();
return text || undefined;
}
function toNumber(value) {
var text = String(value === undefined || value === null ? '' : value).trim();
var number = Number(text);
return text && Number.isFinite(number) ? number : undefined;
}
function formatRole(roleType) {
var role = String(roleType || '').trim();
var roleMap = {
owner: '创建者',
creator: '创建者',
admin: '管理员',
manager: '管理员',
member: '普通成员'
};
return roleMap[role] || role || '普通成员';
}
function buildMemberUpdateBody(values) {
var source = values || {};
return {
memberName: trimOrUndefined(source.memberName),
relationName: trimOrUndefined(source.relationName),
roleType: String(source.roleType || '').trim() || 'member',
lineagePersonId: toNumber(source.lineagePersonId)
};
}
function buildOverviewSummary(detail) {
var source = detail || {};
var name = pick(source, ['genealogyName', 'name'], '当前家谱');
var memberCount = pick(source, ['memberCount', 'members', 'personCount'], '0');
var articleCount = pick(source, ['articleCount', 'articles'], '0');
var albumCount = pick(source, ['albumCount', 'albums'], '0');
return name + ' · ' + memberCount + ' 位成员 · ' + articleCount + ' 篇谱文 · ' + albumCount + ' 个相册';
}
function getMemberId(item) {
return pick(item, ['memberId', 'id', 'genealogyMemberId'], '');
}
function buildMemberRow(item) {
var memberId = getMemberId(item);
var name = pick(item, ['memberName', 'nickName', 'name'], '未命名成员');
var relation = pick(item, ['relationName', 'relation'], '族亲');
var roleType = pick(item, ['roleType', 'role'], 'member');
var bindName = pick(item, ['lineagePersonName', 'personName'], '未绑定');
return '<div class="module-row member-admin-row" data-member-id="' + escapeHtml(memberId) + '">' +
'<div><h3>' + escapeHtml(name) + '</h3><p>' + escapeHtml(relation) + ' · ' + escapeHtml(formatRole(roleType)) + ' · 绑定:' + escapeHtml(bindName) + '</p></div>' +
'<div class="row-actions"><button class="pill" type="button" data-member-edit="' + escapeHtml(memberId) + '" data-member-name="' + escapeHtml(name) + '" data-member-relation="' + escapeHtml(relation) + '" data-member-role="' + escapeHtml(roleType) + '">权限</button><button class="pill is-danger" type="button" data-member-remove="' + escapeHtml(memberId) + '">移除</button></div>' +
'</div>';
}
function buildOption(item) {
var memberId = getMemberId(item);
var name = pick(item, ['memberName', 'label', 'name'], '未命名成员');
return '<option value="' + escapeHtml(memberId) + '">' + escapeHtml(name) + '</option>';
}
function buildInviteLink(currentUrl, genealogyId) {
var url = new URL(currentUrl || 'http://localhost/profile-invite.html');
url.pathname = url.pathname.replace(/[^/]*$/, 'join-genealogy.html');
url.search = '?id=' + encodeURIComponent(genealogyId || '');
url.hash = '';
return url.toString();
}
function renderOverview(detail) {
var summary = query('[data-member-overview]');
var title = query('[data-member-title]');
if (title) title.textContent = pick(detail, ['genealogyName', 'name'], title.textContent || '家族管理');
if (summary) summary.textContent = buildOverviewSummary(detail);
}
function renderMembers(items) {
var container = query('[data-member-list]');
var list = normalizeList(items);
if (!container) return;
if (!list.length) {
container.innerHTML = '<div class="api-empty">暂无家谱成员</div>';
return;
}
container.innerHTML = list.map(buildMemberRow).join('');
}
function renderMemberOptions(items) {
var select = query('[data-member-select]');
var list = normalizeList(items);
if (!select) return;
select.innerHTML = '<option value="">请选择成员</option>' + list.map(buildOption).join('');
}
function renderInvite(detail, genealogyId) {
var code = query('[data-invite-code]');
var link = query('[data-invite-link]');
var currentUrl = root.location && root.location.href;
var inviteCode = pick(detail, ['inviteCode', 'genealogyNo', 'genealogyCode'], genealogyId || '未获取');
if (code) code.textContent = inviteCode;
if (link) link.textContent = buildInviteLink(currentUrl, genealogyId);
}
async function loadMemberAdmin() {
var api = getApi();
var genealogyId = getCurrentGenealogyId();
var detail;
if (!api) return;
if (!genealogyId) {
showMessage('请从具体家谱进入管理页面');
return;
}
try {
detail = await api.genealogyOverview(genealogyId);
renderOverview(detail);
renderInvite(detail, genealogyId);
if (query('[data-member-list]')) {
renderMembers(await api.genealogyMembers(genealogyId));
}
if (query('[data-member-select]')) {
renderMemberOptions(await api.genealogyMemberOptions(genealogyId));
}
} catch (error) {
showMessage(error.message || '家族管理数据加载失败');
}
}
async function updateMemberFromPrompt(button) {
var api = getApi();
var genealogyId = getCurrentGenealogyId();
var memberId = button.getAttribute('data-member-edit');
var roleType = root.prompt('请输入角色类型:owner/admin/member', button.getAttribute('data-member-role') || 'member');
if (!api || !genealogyId || !memberId || !roleType) return;
try {
await api.updateGenealogyMember(genealogyId, memberId, buildMemberUpdateBody({
memberName: button.getAttribute('data-member-name'),
relationName: button.getAttribute('data-member-relation'),
roleType: roleType
}));
showMessage('成员权限已更新');
loadMemberAdmin();
} catch (error) {
showMessage(error.message || '更新成员失败');
}
}
async function removeMember(memberId) {
var api = getApi();
var genealogyId = getCurrentGenealogyId();
if (!api || !genealogyId || !memberId) return;
if (!root.confirm('确定移除该成员?')) return;
try {
await api.removeGenealogyMember(genealogyId, memberId);
showMessage('成员已移除');
loadMemberAdmin();
} catch (error) {
showMessage(error.message || '移除成员失败');
}
}
async function submitPermission(form) {
var api = getApi();
var genealogyId = getCurrentGenealogyId();
var memberId = query('[data-member-select]', form).value;
var roleType = query('[name="roleType"]', form).value;
if (!api || !genealogyId || !memberId) {
showMessage('请选择要设置的成员');
return;
}
try {
await api.updateGenealogyMember(genealogyId, memberId, buildMemberUpdateBody({
roleType: roleType
}));
showMessage('管理员权限已保存');
} catch (error) {
showMessage(error.message || '保存权限失败');
}
}
async function transferOwner() {
var api = getApi();
var genealogyId = getCurrentGenealogyId();
var select = query('[data-member-select]');
if (!api || !genealogyId || !select || !select.value) {
showMessage('请选择要转让的成员');
return;
}
if (!root.confirm('确定转让家谱所有者?')) return;
try {
await api.transferGenealogyOwner(genealogyId, {
targetMemberId: Number(select.value)
});
showMessage('家谱所有者已转让');
} catch (error) {
showMessage(error.message || '转让失败');
}
}
function bindActions() {
if (!documentRef) return;
documentRef.addEventListener('click', function (event) {
var editButton = event.target.closest('[data-member-edit]');
var removeButton = event.target.closest('[data-member-remove]');
var transferButton = event.target.closest('[data-owner-transfer]');
if (editButton) {
event.preventDefault();
updateMemberFromPrompt(editButton);
}
if (removeButton) {
event.preventDefault();
removeMember(removeButton.getAttribute('data-member-remove'));
}
if (transferButton) {
event.preventDefault();
transferOwner();
}
});
documentRef.addEventListener('submit', function (event) {
var form = event.target.closest('[data-permission-form]');
if (!form) return;
event.preventDefault();
submitPermission(form);
});
}
function init() {
if (!documentRef) return;
bindActions();
loadMemberAdmin();
}
return {
normalizeList: normalizeList,
buildMemberUpdateBody: buildMemberUpdateBody,
buildOverviewSummary: buildOverviewSummary,
buildMemberRow: buildMemberRow,
buildInviteLink: buildInviteLink,
init: init
};
});
-246
View File
@@ -1,246 +0,0 @@
(function (root, factory) {
// 备忘录模块同时支持浏览器页面和 Node 单元测试。
if (typeof module === 'object' && module.exports) {
module.exports = factory(root);
return;
}
root.MemoPages = factory(root);
if (root.document) {
root.document.addEventListener('DOMContentLoaded', function () {
root.MemoPages.init();
});
}
})(typeof globalThis !== 'undefined' ? globalThis : window, function (root) {
'use strict';
var documentRef = root.document;
function normalizeList(data) {
// 备忘录列表兼容数组、rows、records、list、data。
if (Array.isArray(data)) return data;
if (!data || typeof data !== 'object') return [];
if (Array.isArray(data.rows)) return data.rows;
if (Array.isArray(data.records)) return data.records;
if (Array.isArray(data.list)) return data.list;
if (Array.isArray(data.data)) return data.data;
return [];
}
function pick(item, keys, fallback) {
var source = item || {};
var index;
var value;
for (index = 0; index < keys.length; index += 1) {
value = source[keys[index]];
if (value !== undefined && value !== null && value !== '') return value;
}
return fallback || '';
}
function escapeHtml(value) {
// 备忘标题和内容进入 innerHTML 前统一转义。
return String(value === undefined || value === null ? '' : value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function getApi() {
return root.GenealogyApi && root.GenealogyApi.defaultClient;
}
function query(selector, rootNode) {
return (rootNode || documentRef).querySelector(selector);
}
function queryAll(selector, rootNode) {
return Array.prototype.slice.call((rootNode || documentRef).querySelectorAll(selector));
}
function showMessage(message) {
if (root.layui && root.layui.layer) {
root.layui.layer.msg(message);
return;
}
root.alert(message);
}
function getQueryParam(search, name) {
var params = new URLSearchParams(String(search || '').replace(/^\?/, ''));
return params.get(name) || '';
}
function getCurrentGenealogyId() {
var search = root.location && root.location.search;
var page = query('[data-memo-page], [data-memo-edit-page]');
return getQueryParam(search, 'genealogyId') ||
(page && page.getAttribute('data-genealogy-id')) ||
getQueryParam(search, 'id') ||
'';
}
function getCurrentMemoId() {
var search = root.location && root.location.search;
return getQueryParam(search, 'memoId') || '';
}
function trimOrUndefined(value) {
var text = String(value === undefined || value === null ? '' : value).trim();
return text || undefined;
}
function toNumber(value, fallback) {
var text = String(value === undefined || value === null ? '' : value).trim();
var number = Number(text);
return text && Number.isFinite(number) ? number : fallback;
}
function formatCompleted(value) {
return String(value || '0') === '1' ? '已完成' : '未完成';
}
function buildMemoBody(values) {
var source = values || {};
return {
memoTitle: String(source.memoTitle || '').trim(),
memoContent: trimOrUndefined(source.memoContent),
remindTime: trimOrUndefined(source.remindTime),
completed: String(source.completed || '').trim() || '0',
mediaOssIds: trimOrUndefined(source.mediaOssIds),
sortOrder: toNumber(source.sortOrder, 1),
status: String(source.status || '').trim() || '0'
};
}
function getMemoId(item) {
return pick(item, ['memoId', 'id'], '');
}
function buildMemoRow(item, genealogyId) {
var memoId = getMemoId(item);
var title = pick(item, ['memoTitle', 'title'], '未命名备忘');
var completed = formatCompleted(pick(item, ['completed'], '0'));
var remindTime = pick(item, ['remindTime', 'createTime'], '未设置提醒');
return '<a class="module-row memo-row" href="profile-memo-edit.html?memoId=' + encodeURIComponent(memoId) + '&genealogyId=' + encodeURIComponent(genealogyId || '') + '">' +
'<div><h3>' + escapeHtml(title) + '</h3><p>' + escapeHtml(completed) + ' · ' + escapeHtml(remindTime) + '</p></div>' +
'<span class="pill">编辑</span></a>';
}
function renderMemos(items, genealogyId) {
var container = query('[data-memo-list]');
var list = normalizeList(items);
if (!container) return;
if (!list.length) {
container.innerHTML = '<div class="api-empty">暂无备忘录</div>';
return;
}
container.innerHTML = list.map(function (item) {
return buildMemoRow(item, genealogyId);
}).join('');
}
function getFormValues(form) {
var values = {};
queryAll('[name]', form).forEach(function (field) {
values[field.name] = field.value;
});
return values;
}
function fillMemoForm(memo) {
var source = memo || {};
queryAll('[name]').forEach(function (field) {
var value = pick(source, [field.name], '');
if (value !== '') field.value = value;
});
}
async function loadMemos() {
var api = getApi();
var genealogyId = getCurrentGenealogyId();
var memoId = getCurrentMemoId();
if (!api || !genealogyId) return;
try {
if (query('[data-memo-list]')) {
renderMemos(await api.memos(genealogyId), genealogyId);
}
if (memoId && query('[data-memo-edit-page]')) {
fillMemoForm(await api.memoDetail(genealogyId, memoId));
}
} catch (error) {
showMessage(error.message || '备忘录加载失败');
}
}
async function submitMemo(form) {
var api = getApi();
var genealogyId = getCurrentGenealogyId();
var memoId = getCurrentMemoId();
var body = buildMemoBody(getFormValues(form));
if (!api || !genealogyId) return;
if (!body.memoTitle) {
showMessage('请填写备忘标题');
return;
}
try {
if (memoId) {
await api.updateMemo(genealogyId, memoId, body);
} else {
await api.createMemo(genealogyId, body);
}
showMessage('备忘录已保存');
root.location.href = 'profile-memo.html?genealogyId=' + encodeURIComponent(genealogyId);
} catch (error) {
showMessage(error.message || '保存备忘录失败');
}
}
function bindActions() {
if (!documentRef) return;
documentRef.addEventListener('submit', function (event) {
var form = event.target.closest('[data-memo-form]');
if (!form) return;
event.preventDefault();
submitMemo(form);
});
}
function init() {
if (!documentRef) return;
bindActions();
loadMemos();
}
return {
normalizeList: normalizeList,
buildMemoBody: buildMemoBody,
buildMemoRow: buildMemoRow,
init: init
};
});
-198
View File
@@ -1,198 +0,0 @@
(function (root, factory) {
// 消息模块同时支持浏览器页面和 Node 单元测试。
if (typeof module === 'object' && module.exports) {
module.exports = factory(root);
return;
}
root.NotificationPages = factory(root);
if (root.document) {
root.document.addEventListener('DOMContentLoaded', function () {
root.NotificationPages.init();
});
}
})(typeof globalThis !== 'undefined' ? globalThis : window, function (root) {
'use strict';
var documentRef = root.document;
function normalizeList(data) {
// 列表响应在不同接口中可能使用 rows、records、list 或 data 包裹。
if (Array.isArray(data)) return data;
if (!data || typeof data !== 'object') return [];
if (Array.isArray(data.rows)) return data.rows;
if (Array.isArray(data.records)) return data.records;
if (Array.isArray(data.list)) return data.list;
if (Array.isArray(data.data)) return data.data;
return [];
}
function pick(item, keys, fallback) {
var source = item || {};
var index;
var value;
for (index = 0; index < keys.length; index += 1) {
value = source[keys[index]];
if (value !== undefined && value !== null && value !== '') return value;
}
return fallback || '';
}
function escapeHtml(value) {
// 通知标题和正文来自接口,进入 innerHTML 前统一转义。
return String(value === undefined || value === null ? '' : value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function getApi() {
return root.GenealogyApi && root.GenealogyApi.defaultClient;
}
function query(selector, rootNode) {
return (rootNode || documentRef).querySelector(selector);
}
function queryAll(selector, rootNode) {
return Array.prototype.slice.call((rootNode || documentRef).querySelectorAll(selector));
}
function showMessage(message) {
if (root.layui && root.layui.layer) {
root.layui.layer.msg(message);
return;
}
root.alert(message);
}
function isRead(item) {
var readStatus = pick(item, ['readStatus', 'status'], '');
var readFlag = pick(item, ['readFlag', 'isRead', 'read'], '');
if (readFlag === true || readFlag === 'true' || readFlag === 1 || readFlag === '1') return true;
return readStatus === '1' || readStatus === 1 || readStatus === '已读';
}
function formatNotificationStatus(item) {
return isRead(item) ? '已读' : '未读';
}
function buildNotificationRow(item) {
// 通知字段名按接口常见命名做兼容,页面只关心标题、内容、时间和已读状态。
var id = pick(item, ['notificationId', 'id'], '');
var title = pick(item, ['title', 'noticeTitle', 'messageTitle'], '系统通知');
var content = pick(item, ['content', 'message', 'noticeContent', 'summary'], '暂无通知内容');
var time = pick(item, ['createTime', 'createdAt', 'time'], '');
var status = formatNotificationStatus(item);
var className = isRead(item)
? 'module-row notification-row'
: 'module-row notification-row is-unread';
var action = isRead(item)
? '<span class="pill">已读</span>'
: '<button class="btn ghost" type="button" data-notification-read="' + escapeHtml(id) + '">标记已读</button>';
return '<div class="' + className + '" data-notification-id="' + escapeHtml(id) + '">' +
'<div><h3>' + escapeHtml(title) + '</h3><p>' + escapeHtml(time) + ' · ' + escapeHtml(content) + '</p></div>' +
action +
'</div>';
}
function renderNotifications(items) {
var container = query('[data-notification-list]');
if (!container) return;
if (!items.length) {
container.innerHTML = '<div class="api-empty">暂无消息通知</div>';
return;
}
container.innerHTML = items.map(buildNotificationRow).join('');
}
async function loadNotifications() {
var api = getApi();
var container = query('[data-notification-list]');
if (!api || !container) return;
container.classList.add('is-loading');
try {
renderNotifications(normalizeList(await api.notifications()));
} catch (error) {
showMessage(error.message || '消息通知加载失败');
} finally {
container.classList.remove('is-loading');
}
}
async function markRead(notificationId) {
var api = getApi();
if (!api || !notificationId) return;
try {
await api.markNotificationRead(notificationId);
showMessage('已标记为已读');
loadNotifications();
} catch (error) {
showMessage(error.message || '标记已读失败');
}
}
async function markAllRead() {
var api = getApi();
if (!api) return;
try {
await api.markAllNotificationsRead();
showMessage('已全部标记为已读');
loadNotifications();
} catch (error) {
showMessage(error.message || '全部标记已读失败');
}
}
function bindActions() {
if (!documentRef) return;
documentRef.addEventListener('click', function (event) {
var readButton = event.target.closest('[data-notification-read]');
var readAllButton = event.target.closest('[data-notification-read-all]');
var refreshButton = event.target.closest('[data-notification-refresh]');
if (readButton) {
event.preventDefault();
markRead(readButton.getAttribute('data-notification-read'));
}
if (readAllButton) {
event.preventDefault();
markAllRead();
}
if (refreshButton) {
event.preventDefault();
loadNotifications();
}
});
}
function init() {
if (!documentRef) return;
bindActions();
loadNotifications();
}
return {
normalizeList: normalizeList,
formatNotificationStatus: formatNotificationStatus,
buildNotificationRow: buildNotificationRow,
renderNotifications: renderNotifications,
init: init
};
});
+112
View File
@@ -0,0 +1,112 @@
(function (root, factory) {
// 待开发页面状态模块同时支持浏览器页面和 Node 单元测试。
if (typeof module === 'object' && module.exports) {
module.exports = factory(root);
return;
}
root.PageAvailability = factory(root);
if (root.document) {
root.document.addEventListener('DOMContentLoaded', function () {
root.PageAvailability.init();
});
}
})(typeof globalThis !== 'undefined' ? globalThis : window, function (root) {
'use strict';
var documentRef = root.document;
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 buildBanner(message) {
return '<section class="feature-status-banner" role="status">' +
'<strong>功能开发中</strong>' +
'<p>' + escapeHtml(message || '该功能正在开发中,当前页面保留设计预览。') + '</p>' +
'</section>';
}
function isPendingPage(body) {
return Boolean(body && body.getAttribute('data-feature-status') === 'pending');
}
function getMessage(body) {
return body && body.getAttribute('data-feature-message') || '该功能正在开发中,当前页面保留设计预览。';
}
function showMessage(message) {
if (root.layui && root.layui.layer) {
root.layui.layer.msg(message);
return;
}
if (root.alert) root.alert(message);
}
function containsTarget(container, target) {
return Boolean(container && target && container.contains(target));
}
function shouldBlockLink(link) {
var href;
if (!link || link.getAttribute('data-feature-link') === 'available') return false;
href = link.getAttribute('href') || '';
return Boolean(href) && href.charAt(0) !== '#' && !/^(?:https?:|mailto:|tel:)/i.test(href);
}
function init() {
var body;
var main;
var target;
var message;
if (!documentRef) return;
body = documentRef.body;
if (!isPendingPage(body)) return;
main = documentRef.querySelector('main');
target = main && (main.querySelector('.module-main') || main);
message = getMessage(body);
if (target && !target.querySelector('.feature-status-banner')) {
target.insertAdjacentHTML('afterbegin', buildBanner(message));
}
documentRef.addEventListener('submit', function (event) {
if (!containsTarget(main, event.target)) return;
event.preventDefault();
showMessage(message);
});
documentRef.addEventListener('click', function (event) {
var source = event.target;
var button = source && source.closest && source.closest('button');
var link = source && source.closest && source.closest('a');
if (button && containsTarget(main, button) && button.type !== 'reset') {
event.preventDefault();
showMessage(message);
return;
}
// 侧栏用于浏览已设计页面,主内容区的业务入口必须显式声明可用后才能跳转。
if (!link || !containsTarget(main, link) || link.closest('.module-nav') || !shouldBlockLink(link)) return;
event.preventDefault();
showMessage(message);
});
}
return {
buildBanner: buildBanner,
isPendingPage: isPendingPage,
shouldBlockLink: shouldBlockLink,
init: init
};
});
-220
View File
@@ -1,220 +0,0 @@
(function (root, factory) {
// 推广内容模块同时服务应用介绍、下载页和公告详情页。
if (typeof module === 'object' && module.exports) {
module.exports = factory(root);
return;
}
root.PromotionPages = factory(root);
if (root.document) {
root.document.addEventListener('DOMContentLoaded', function () {
root.PromotionPages.init();
});
}
})(typeof globalThis !== 'undefined' ? globalThis : window, function (root) {
'use strict';
var documentRef = root.document;
function normalizeList(data) {
// 推广接口使用通用列表响应,兼容后端常见数组包裹字段。
if (Array.isArray(data)) return data;
if (!data || typeof data !== 'object') return [];
if (Array.isArray(data.rows)) return data.rows;
if (Array.isArray(data.records)) return data.records;
if (Array.isArray(data.list)) return data.list;
if (Array.isArray(data.data)) return data.data;
return [];
}
function pick(item, keys, fallback) {
var source = item || {};
var index;
var value;
for (index = 0; index < keys.length; index += 1) {
value = source[keys[index]];
if (value !== undefined && value !== null && value !== '') return value;
}
return fallback || '';
}
function escapeHtml(value) {
// 标题、摘要、链接等普通文本进入 HTML 前统一转义。
return String(value === undefined || value === null ? '' : value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function getApi() {
return root.GenealogyApi && root.GenealogyApi.defaultClient;
}
function query(selector, rootNode) {
return (rootNode || documentRef).querySelector(selector);
}
function showMessage(message) {
if (root.layui && root.layui.layer) {
root.layui.layer.msg(message);
return;
}
root.alert(message);
}
function getQueryParam(search, name) {
var params = new URLSearchParams(String(search || '').replace(/^\?/, ''));
return params.get(name) || '';
}
function getPromotionId(item) {
return String(pick(item, ['promotionId', 'id', 'articleId'], ''));
}
function getTitle(item) {
return pick(item, ['promotionTitle', 'title', 'name'], '推广内容');
}
function getSummary(item) {
return pick(item, ['summary', 'description', 'promotionDesc', 'remark'], '了解平台最新内容与应用服务。');
}
function getContent(item) {
return pick(item, ['promotionContent', 'content', 'articleContent'], '<p>暂无详细内容</p>');
}
function getTime(item) {
return pick(item, ['publishTime', 'createTime', 'updateTime'], '');
}
function getLink(item, fallback) {
return pick(item, ['linkUrl', 'url', 'targetUrl'], fallback || 'notice-detail.html?id=' + encodeURIComponent(getPromotionId(item)));
}
function getCover(item) {
return pick(item, ['coverUrl', 'coverImage', 'imageUrl', 'bannerUrl'], 'public/images/app-home.png');
}
function buildPromotionCard(item) {
var title = getTitle(item);
var summary = getSummary(item);
var time = getTime(item);
var link = getLink(item);
return '<a class="promotion-card" href="' + escapeHtml(link) + '" data-promotion-id="' + escapeHtml(getPromotionId(item)) + '">' +
'<img src="' + escapeHtml(getCover(item)) + '" alt="' + escapeHtml(title) + '" />' +
'<div><h3>' + escapeHtml(title) + '</h3><p>' + escapeHtml(summary) + '</p><span>' + escapeHtml(time) + '</span></div></a>';
}
function buildDownloadCard(item) {
var title = getTitle(item);
var summary = getSummary(item);
var link = getLink(item, 'download.html');
return '<a class="card soft download-card tilt-card" href="' + escapeHtml(link) + '" data-promotion-id="' + escapeHtml(getPromotionId(item)) + '">' +
'<h3>' + escapeHtml(title) + '</h3><p>' + escapeHtml(summary) + '</p></a>';
}
function pickPromotion(list, promotionId) {
var items = normalizeList(list);
var id = String(promotionId || '');
var index;
if (!items.length) return null;
if (!id) return items[0];
for (index = 0; index < items.length; index += 1) {
if (getPromotionId(items[index]) === id) return items[index];
}
return items[0];
}
function buildNoticeDetail(item) {
return {
title: getTitle(item),
summary: getSummary(item),
content: getContent(item),
time: getTime(item)
};
}
function renderPromotionCards(items) {
var container = query('[data-promotion-list]');
var list = normalizeList(items);
if (!container) return;
if (!list.length) {
container.innerHTML = '<div class="api-empty">暂无推广内容</div>';
return;
}
container.innerHTML = list.map(buildPromotionCard).join('');
}
function renderDownloadCards(items) {
var container = query('[data-promotion-download-list]');
var list = normalizeList(items);
if (!container) return;
if (!list.length) {
container.innerHTML = '<div class="api-empty">暂无下载推广内容</div>';
return;
}
container.innerHTML = list.map(buildDownloadCard).join('');
}
function renderNoticeDetail(items) {
var search = root.location && root.location.search;
var selected = pickPromotion(items, getQueryParam(search, 'id'));
var detail = selected ? buildNoticeDetail(selected) : null;
var title = query('[data-promotion-title]');
var summary = query('[data-promotion-summary]');
var content = query('[data-promotion-content]');
var time = query('[data-promotion-time]');
if (!detail) return;
if (title) title.textContent = detail.title;
if (summary) summary.textContent = detail.summary;
if (content) content.innerHTML = detail.content;
if (time) time.textContent = detail.time || '发布时间待定';
}
async function loadPromotions() {
var api = getApi();
var data;
if (!api) return;
try {
data = await api.promotions();
renderPromotionCards(data);
renderDownloadCards(data);
renderNoticeDetail(data);
} catch (error) {
showMessage(error.message || '推广内容加载失败');
}
}
function init() {
if (!documentRef || !query('[data-promotion-page]')) return;
loadPromotions();
}
return {
normalizeList: normalizeList,
buildPromotionCard: buildPromotionCard,
buildDownloadCard: buildDownloadCard,
pickPromotion: pickPromotion,
buildNoticeDetail: buildNoticeDetail,
init: init
};
});
+71 -2
View File
@@ -15,6 +15,14 @@
'use strict';
var documentRef = root.document;
var CAPTCHA_SCENES = {
password: 'WEB_H5_CHANGE_PASSWORD',
phone: 'WEB_H5_CHANGE_PHONE'
};
function getCaptchaScene(formType) {
return CAPTCHA_SCENES[formType] || '';
}
function hashPassword(value) {
// 接口文档要求密码提交 32 位 MD5,测试环境可传入 hashFn 替代。
@@ -35,7 +43,8 @@
return {
oldPassword: hash(String(source.oldPassword || '').trim()),
newPassword: hash(String(source.newPassword || '').trim())
newPassword: hash(String(source.newPassword || '').trim()),
validToken: trimOrUndefined(source.validToken)
};
}
@@ -67,6 +76,23 @@
return root.GenealogyApi && root.GenealogyApi.defaultClient;
}
async function ensureCaptcha(form, formType, subject) {
// 改密和换绑必须先取得与当前业务场景绑定的验证码票据。
var captcha = root.CaptchaPages;
var sceneCode = getCaptchaScene(formType);
if (!sceneCode) return true;
if (!captcha || !captcha.ensureToken) {
showMessage('验证码组件未加载,请刷新后重试');
return false;
}
return Boolean(await captcha.ensureToken(form, {
sceneCode: sceneCode,
subject: subject || ''
}));
}
function query(selector, rootNode) {
return (rootNode || documentRef).querySelector(selector);
}
@@ -114,6 +140,9 @@
return;
}
if (!await ensureCaptcha(form, 'password')) return;
values = getFormValues(form);
try {
await api.changePassword(buildPasswordChangeBody(values));
setStatus(form, '密码已修改');
@@ -134,6 +163,9 @@
return;
}
if (!await ensureCaptcha(form, 'phone', body.newPhone)) return;
body = buildPhoneChangeBody(getFormValues(form));
try {
await api.changePhone(body);
setStatus(form, '手机号已换绑');
@@ -144,6 +176,36 @@
}
}
async function sendPhoneCode(button) {
// 换绑手机号使用独立短信场景,避免复用登录或找回密码票据。
var api = getApi();
var form = query('[data-security-form="phone"]');
var values;
if (!api || !form) return;
values = getFormValues(form);
if (!/^1[3-9]\d{9}$/.test(String(values.newPhone || '').trim())) {
showMessage('请输入正确的新手机号');
return;
}
if (!await ensureCaptcha(form, 'phone', values.newPhone)) return;
values = getFormValues(form);
button.disabled = true;
try {
await api.sendSmsCode({
phone: values.newPhone,
sceneCode: getCaptchaScene('phone'),
validToken: values.validToken || ''
});
showMessage('验证码已发送');
} catch (error) {
showMessage(error.message || '验证码发送失败');
} finally {
button.disabled = false;
}
}
async function submitDeactivate(form) {
var api = getApi();
var values = getFormValues(form);
@@ -176,7 +238,7 @@
try {
if (api) await api.logout();
} catch (error) {
// ApiClient.logout 会在请求失败时清理本地 token,页面仍应离开受保护区域。
// 退出请求失败时客户端仍会清理本地令牌,页面必须离开受保护区域。
} finally {
root.location.href = 'index.html';
}
@@ -199,8 +261,14 @@
});
documentRef.addEventListener('click', function (event) {
var codeButton = event.target.closest('[data-security-send-code]');
var button = event.target.closest('[data-security-logout]');
if (codeButton) {
event.preventDefault();
sendPhoneCode(codeButton);
return;
}
if (!button) return;
event.preventDefault();
logout();
@@ -217,6 +285,7 @@
buildPasswordChangeBody: buildPasswordChangeBody,
buildPhoneChangeBody: buildPhoneChangeBody,
buildDeactivateBody: buildDeactivateBody,
getCaptchaScene: getCaptchaScene,
isDangerConfirmed: isDangerConfirmed,
init: init
};

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