修改注册功能问题

This commit is contained in:
rain
2026-07-10 17:08:21 +08:00
parent 66f3204349
commit a549ca5ca3
15 changed files with 524 additions and 84 deletions
+2 -2
View File
@@ -11,11 +11,11 @@
var ENVIRONMENTS = { var ENVIRONMENTS = {
development: { development: {
apiBaseUrl: 'http://test-genealogy-api.ddxcjp.cn' apiBaseUrl: 'http://182.61.18.23:8080'
}, },
production: { production: {
// 后端尚未提供正式生产域名,暂时保持用户指定的测试地址。 // 后端尚未提供正式生产域名,暂时保持用户指定的测试地址。
apiBaseUrl: 'http://test-genealogy-api.ddxcjp.cn' apiBaseUrl: 'http://182.61.18.23:8080'
} }
}; };
@@ -49,7 +49,7 @@
4. `captcha-pages.js` 先请求 `GET /captcha/require?tenantId=000000&clientId=ced7e5f0498645c6ec642dcf450b036f&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, ...)` 5. 如果返回 `required=false`,业务表单可继续提交;如果需要验证,则创建 `new CaptchaConfig(...)``new TAC(config, ...)`
6. TAC 内部请求 `POST /captcha/challenge` 获取 `captchaType/challengeId/payload`,适配为 TAC 需要的 `data.type/data.id/backgroundImage/templateImage` 6. TAC 内部请求 `POST /captcha/challenge` 获取 `captchaType/challengeId/payload`,适配为 TAC 需要的 `data.type/data.id/backgroundImage/templateImage`
7. 用户拖动完成后,TAC 请求 `POST /captcha/verify`,提交 `challengeId/providerCode/captchaType/payload.data.trackList` 7. 用户拖动完成后,TAC 请求 `POST /captcha/verify`,提交 `challengeId/providerCode/captchaType/payload.track`,其中 `left/top` 由轨迹首尾点计算
8. 验证成功后从返回 `data.validToken` 取票据,写回表单隐藏字段 `validToken`,再提交 PC 登录接口。 8. 验证成功后从返回 `data.validToken` 取票据,写回表单隐藏字段 `validToken`,再提交 PC 登录接口。
- PC/H5 注册使用 `WEB_H5_REGISTER`,找回密码和找回密码短信验证码使用 `WEB_H5_FORGOT_PASSWORD`;换绑手机、注销账号暂未看到明确 PC/H5 场景编码,仍待后端补充后再接。 - PC/H5 注册使用 `WEB_H5_REGISTER`,找回密码和找回密码短信验证码使用 `WEB_H5_FORGOT_PASSWORD`;换绑手机、注销账号暂未看到明确 PC/H5 场景编码,仍待后端补充后再接。
@@ -227,6 +227,56 @@
- 修改文件:`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` - 修改文件:`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 视觉配置。 - 修改结果:`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 换行提示,没有空白错误。 - 验证结果:先运行 `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 修正。 - 复核结论:该项只解决 `d.parents is not a function`;用户 2026-07-10 10:59 左右浏览器复测显示 Layui 遮罩出现、`/captcha/challenge` 返回 200,但 TAC 本体未显示,因此真实挂载点问题进入 PC-019 修正。
## PC-019 TAC Layui 实际挂载点修复 ## PC-019 TAC Layui 实际挂载点修复
@@ -0,0 +1,26 @@
# 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 换行提示。
@@ -0,0 +1,20 @@
# 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 换行提示。
@@ -0,0 +1,26 @@
# 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 换行提示。
@@ -0,0 +1,128 @@
# 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"
```
+2 -2
View File
@@ -21,7 +21,7 @@
<div class="eyebrow">Password Recovery</div> <div class="eyebrow">Password Recovery</div>
<h1 id="recover-title">找回密码</h1> <h1 id="recover-title">找回密码</h1>
<p class="lead">输入注册手机号,获取验证码后重设登录密码。</p> <p class="lead">输入注册手机号,获取验证码后重设登录密码。</p>
<form class="form-grid" data-auth-form="password-reset" data-captcha-scene="WEB_H5_FORGOT_PASSWORD" data-success-url="login.html"> <form id="password-reset-form" class="form-grid">
<input <input
class="input" class="input"
name="phone" name="phone"
@@ -38,7 +38,7 @@
autocomplete="one-time-code" autocomplete="one-time-code"
placeholder="验证码" placeholder="验证码"
/> />
<button class="btn ghost" type="button" data-api-send-code data-scene-code="WEB_H5_FORGOT_PASSWORD">获取验证码</button> <button class="btn ghost" type="button" data-api-send-code>获取验证码</button>
</div> </div>
<input <input
class="input" class="input"
+5 -11
View File
@@ -19,19 +19,16 @@
<h1 id="login-title">登录账号</h1> <h1 id="login-title">登录账号</h1>
<p class="lead">登录后可创建、加入和维护家谱。</p> <p class="lead">登录后可创建、加入和维护家谱。</p>
<div class="login-mode-tabs" role="tablist" aria-label="登录方式"> <div class="login-mode-tabs" role="tablist" aria-label="登录方式">
<button class="login-mode-tab is-active" type="button" role="tab" aria-selected="true" data-login-mode-tab="password"> <button class="login-mode-tab is-active" type="button" role="tab" aria-selected="true" aria-controls="login-password-form">
密码登录 密码登录
</button> </button>
<button class="login-mode-tab" type="button" role="tab" aria-selected="false" data-login-mode-tab="sms"> <button class="login-mode-tab" type="button" role="tab" aria-selected="false" aria-controls="login-sms-form">
短信登录 短信登录
</button> </button>
</div> </div>
<form <form
id="login-password-form"
class="form-grid login-mode-panel" class="form-grid login-mode-panel"
data-auth-form="login"
data-login-mode-panel="password"
data-captcha-scene="WEB_H5_LOGIN"
data-success-url="profile.html"
> >
<input <input
class="input" class="input"
@@ -53,11 +50,8 @@
<button class="btn primary" type="submit">登录</button> <button class="btn primary" type="submit">登录</button>
</form> </form>
<form <form
id="login-sms-form"
class="form-grid login-mode-panel" class="form-grid login-mode-panel"
data-auth-form="sms-login"
data-login-mode-panel="sms"
data-captcha-scene="WEB_H5_LOGIN"
data-success-url="profile.html"
hidden hidden
> >
<input <input
@@ -76,7 +70,7 @@
autocomplete="one-time-code" autocomplete="one-time-code"
placeholder="短信验证码" placeholder="短信验证码"
/> />
<button class="btn ghost" type="button" data-api-send-code data-scene-code="WEB_H5_LOGIN"> <button class="btn ghost" type="button" data-api-send-code>
获取验证码 获取验证码
</button> </button>
</div> </div>
+20 -10
View File
@@ -6,29 +6,35 @@
.login-mode-tabs { .login-mode-tabs {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px; gap: 4px;
margin: 18px 0 16px; margin: 18px 0 18px;
padding: 4px; padding: 4px;
border: 1px solid rgba(138, 64, 42, .14); border: 1px solid rgba(200, 59, 50, .16);
border-radius: 8px; border-radius: 999px;
background: rgba(255, 255, 255, .72); background: rgba(255, 250, 241, .88);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .78);
} }
/* 登录方式按钮只负责状态呈现,切换逻辑在 auth-pages.js */ /* 登录方式按钮只负责状态呈现,切换逻辑在 auth-pages.js */
.login-mode-tab { .login-mode-tab {
min-height: 38px; min-height: 42px;
border: 0; border: 0;
border-radius: 6px; border-radius: 999px;
background: transparent; background: transparent;
color: var(--muted); color: var(--muted);
font: inherit; font: inherit;
font-weight: 800;
text-align: center;
cursor: pointer; cursor: pointer;
transition: color .22s ease, background .22s ease, box-shadow .22s ease;
} }
.login-mode-tab.is-active { .login-mode-tab.is-active {
background: var(--brand); background: rgba(200, 59, 50, .11);
color: #fff; color: var(--red);
box-shadow: 0 8px 20px rgba(138, 64, 42, .18); box-shadow:
inset 0 0 0 1px rgba(200, 59, 50, .16),
0 8px 18px rgba(138, 64, 42, .08);
} }
.login-mode-panel[hidden] { .login-mode-panel[hidden] {
@@ -41,6 +47,10 @@
gap: 10px; gap: 10px;
} }
.page-login .auth-links {
margin-top: 14px;
}
@media (max-width: 720px) { @media (max-width: 720px) {
.page-login .auth-layout { .page-login .auth-layout {
align-items: start; align-items: start;
+137 -46
View File
@@ -15,6 +15,28 @@
'use strict'; 'use strict';
var documentRef = root.document; var documentRef = root.document;
var FORM_CONFIG = {
login: {
selector: '#login-password-form',
captchaScene: 'WEB_H5_LOGIN',
successUrl: 'profile.html'
},
'sms-login': {
selector: '#login-sms-form',
captchaScene: 'WEB_H5_LOGIN',
successUrl: 'profile.html'
},
register: {
selector: '#register-form',
captchaScene: 'WEB_H5_REGISTER',
successUrl: 'login.html'
},
'password-reset': {
selector: '#password-reset-form',
captchaScene: 'WEB_H5_FORGOT_PASSWORD',
successUrl: 'login.html'
}
};
function hashPassword(value) { function hashPassword(value) {
// 接口文档要求密码传 32 位 MD5;没有加载 md5.js 时保留原值,便于测试定位。 // 接口文档要求密码传 32 位 MD5;没有加载 md5.js 时保留原值,便于测试定位。
@@ -77,20 +99,22 @@
function getCaptchaScene(formType) { function getCaptchaScene(formType) {
// PC/H5 认证场景来自后台验证码配置,客户端 Key 为 web_pc。 // PC/H5 认证场景来自后台验证码配置,客户端 Key 为 web_pc。
var map = { var config = FORM_CONFIG[formType] || {};
login: 'WEB_H5_LOGIN',
register: 'WEB_H5_REGISTER',
'password-reset': 'WEB_H5_FORGOT_PASSWORD'
};
return map[formType] || ''; return config.captchaScene || '';
}
function getSuccessUrl(formType) {
var config = FORM_CONFIG[formType] || {};
return config.successUrl || '';
} }
function getFormCaptchaScene(form, formType) { function getFormCaptchaScene(form, formType) {
// 页面可通过 data-captcha-scene 显式声明后台配置的验证码场景 // 场景码由当前脚本统一管理,避免散落在 HTML 属性中
var sceneCode = form && form.getAttribute && form.getAttribute('data-captcha-scene'); var sceneCode = getCaptchaScene(formType);
return sceneCode || getCaptchaScene(formType); return sceneCode;
} }
async function ensureCaptcha(form, sceneCode, subject) { async function ensureCaptcha(form, sceneCode, subject) {
@@ -142,8 +166,73 @@
if (root.console && root.console.warn) root.console.warn(message); if (root.console && root.console.warn) root.console.warn(message);
} }
function requireValue(value, message) { function isPhone(value) {
if (value) return true; return /^1\d{10}$/.test(value || '');
}
function isSmsCode(value) {
return /^\d{4,6}$/.test(value || '');
}
function validatePassword(value, message) {
if (!value) return message;
if (value.length < 6) return '密码至少需要 6 位';
return '';
}
function validateAuthValues(formType, values) {
var data = values || {};
if (!data.phone) return '请填写手机号';
if (!isPhone(data.phone)) return '请输入正确的手机号';
if (formType === 'login') {
return validatePassword(data.password, '请填写密码');
}
if (formType === 'sms-login') {
if (!data.smsCode) return '请填写短信验证码';
if (!isSmsCode(data.smsCode)) return '请输入正确的短信验证码';
return '';
}
if (formType === 'register') {
return validatePassword(data.password, '请填写密码');
}
if (formType === 'password-reset') {
if (!data.smsCode) return '请填写验证码';
if (!isSmsCode(data.smsCode)) return '请输入正确的短信验证码';
var passwordMessage = validatePassword(data.newPassword, '请填写新密码');
if (passwordMessage) return passwordMessage;
if (!data.confirmPassword) return '请再次输入新密码';
if (data.newPassword !== data.confirmPassword) return '两次输入的新密码不一致';
}
return '';
}
function validateBeforeSubmit(formType, values) {
var message = validateAuthValues(formType, values);
if (!message) return true;
showMessage(message);
return false;
}
function validatePhoneOnly(values) {
var data = values || {};
if (!data.phone) return '请填写手机号';
if (!isPhone(data.phone)) return '请输入正确的手机号';
return '';
}
function validateBeforeSendCode(values) {
var message = validatePhoneOnly(values);
if (!message) return true;
showMessage(message); showMessage(message);
return false; return false;
} }
@@ -159,30 +248,29 @@
}); });
} }
function setLoginMode(activeMode) { function setLoginMode(activePanelId) {
// 登录方式切换只更新状态,具体样式由 login.css 控制。 // 登录方式切换只更新状态,具体样式由 login.css 控制。
queryAll('[data-login-mode-tab]').forEach(function (button) { queryAll('.login-mode-tab').forEach(function (button) {
var isActive = button.getAttribute('data-login-mode-tab') === activeMode; var isActive = button.getAttribute('aria-controls') === activePanelId;
button.classList.toggle('is-active', isActive); button.classList.toggle('is-active', isActive);
button.setAttribute('aria-selected', isActive ? 'true' : 'false'); button.setAttribute('aria-selected', isActive ? 'true' : 'false');
}); });
queryAll('[data-login-mode-panel]').forEach(function (panel) { queryAll('.login-mode-panel').forEach(function (panel) {
var isActive = panel.getAttribute('data-login-mode-panel') === activeMode; var isActive = panel.id === activePanelId;
panel.hidden = !isActive; panel.hidden = !isActive;
}); });
} }
async function submitLogin(form, button) { async function submitLogin(form, button) {
// 登录成功后跳转地址由 HTML 的 data-success-url 控制,页面可单独调整 // 登录成功后跳转地址由 FORM_CONFIG 统一控制,HTML 只保留表单结构
var api = getApi(); var api = getApi();
var values = readForm(form); var values = readForm(form);
if (!api) return; if (!api) return;
if (!requireValue(values.phone, '请填写手机号')) return; if (!validateBeforeSubmit('login', values)) return;
if (!requireValue(values.password, '请填写密码')) return;
if (!await ensureCaptcha(form, getFormCaptchaScene(form, 'login'), values.phone)) return; if (!await ensureCaptcha(form, getFormCaptchaScene(form, 'login'), values.phone)) return;
values = readForm(form); values = readForm(form);
@@ -190,7 +278,7 @@
setBusy(button, true); setBusy(button, true);
try { try {
await api.login(buildLoginBody(values)); await api.login(buildLoginBody(values));
root.location.href = form.getAttribute('data-success-url') || 'profile.html'; root.location.href = getSuccessUrl('login');
} catch (error) { } catch (error) {
showMessage(error.message || '登录失败'); showMessage(error.message || '登录失败');
} finally { } finally {
@@ -204,16 +292,15 @@
var values = readForm(form); var values = readForm(form);
if (!api) return; if (!api) return;
if (!requireValue(values.phone, '请填写手机号')) return; if (!validateBeforeSubmit('sms-login', values)) return;
if (!requireValue(values.smsCode, '请填写短信验证码')) return; if (!await ensureCaptcha(form, getFormCaptchaScene(form, 'sms-login'), values.phone)) return;
if (!await ensureCaptcha(form, getFormCaptchaScene(form, 'login'), values.phone)) return;
values = readForm(form); values = readForm(form);
setBusy(button, true); setBusy(button, true);
try { try {
await api.loginBySms(buildSmsLoginBody(values)); await api.loginBySms(buildSmsLoginBody(values));
root.location.href = form.getAttribute('data-success-url') || 'profile.html'; root.location.href = getSuccessUrl('sms-login');
} catch (error) { } catch (error) {
showMessage(error.message || '短信登录失败'); showMessage(error.message || '短信登录失败');
} finally { } finally {
@@ -222,13 +309,12 @@
} }
async function submitRegister(form, button) { async function submitRegister(form, button) {
// 注册成功后默认进入创建家谱流程 // 注册完成后回到登录页,创建家谱必须在登录后进行
var api = getApi(); var api = getApi();
var values = readForm(form); var values = readForm(form);
if (!api) return; if (!api) return;
if (!requireValue(values.phone, '请填写手机号')) return; if (!validateBeforeSubmit('register', values)) return;
if (!requireValue(values.password, '请填写密码')) return;
if (!await ensureCaptcha(form, getFormCaptchaScene(form, 'register'), values.phone)) return; if (!await ensureCaptcha(form, getFormCaptchaScene(form, 'register'), values.phone)) return;
values = readForm(form); values = readForm(form);
@@ -236,7 +322,7 @@
setBusy(button, true); setBusy(button, true);
try { try {
await api.register(buildRegisterBody(values)); await api.register(buildRegisterBody(values));
root.location.href = form.getAttribute('data-success-url') || 'create-genealogy.html'; root.location.href = getSuccessUrl('register');
} catch (error) { } catch (error) {
showMessage(error.message || '注册失败'); showMessage(error.message || '注册失败');
} finally { } finally {
@@ -245,14 +331,15 @@
} }
async function sendCode(button) { async function sendCode(button) {
// 当前只接找回密码验证码;后续注册短信可复用 data-scene-code 扩展 // 短信验证码场景由表单配置决定,不在按钮属性中重复声明
var api = getApi(); var api = getApi();
var form = button.closest('form'); var form = button.closest('form');
var values = readForm(form); var values = readForm(form);
var sceneCode = button.getAttribute('data-scene-code') || getFormCaptchaScene(form, 'password-reset'); var formType = getFormType(form);
var sceneCode = getFormCaptchaScene(form, formType);
if (!api) return; if (!api) return;
if (!requireValue(values.phone, '请填写手机号')) return; if (!validateBeforeSendCode(values)) return;
if (!await ensureCaptcha(form, sceneCode, values.phone)) return; if (!await ensureCaptcha(form, sceneCode, values.phone)) return;
values = readForm(form); values = readForm(form);
@@ -278,13 +365,7 @@
var values = readForm(form); var values = readForm(form);
if (!api) return; if (!api) return;
if (!requireValue(values.phone, '请填写手机号')) return; if (!validateBeforeSubmit('password-reset', values)) return;
if (!requireValue(values.smsCode, '请填写验证码')) return;
if (!requireValue(values.newPassword, '请填写新密码')) return;
if (values.newPassword !== values.confirmPassword) {
showMessage('两次输入的新密码不一致');
return;
}
if (!await ensureCaptcha(form, getFormCaptchaScene(form, 'password-reset'), values.phone)) return; if (!await ensureCaptcha(form, getFormCaptchaScene(form, 'password-reset'), values.phone)) return;
values = readForm(form); values = readForm(form);
@@ -293,7 +374,7 @@
try { try {
await api.resetPassword(buildPasswordResetBody(values)); await api.resetPassword(buildPasswordResetBody(values));
showMessage('密码已重设,请重新登录'); showMessage('密码已重设,请重新登录');
root.location.href = form.getAttribute('data-success-url') || 'login.html'; root.location.href = getSuccessUrl('password-reset');
} catch (error) { } catch (error) {
showMessage(error.message || '重设密码失败'); showMessage(error.message || '重设密码失败');
} finally { } finally {
@@ -311,21 +392,29 @@
} }
function bindLoginModeTabs() { function bindLoginModeTabs() {
queryAll('[data-login-mode-tab]').forEach(function (button) { queryAll('.login-mode-tab').forEach(function (button) {
button.addEventListener('click', function () { button.addEventListener('click', function () {
setLoginMode(button.getAttribute('data-login-mode-tab')); setLoginMode(button.getAttribute('aria-controls'));
}); });
}); });
} }
function getFormType(form) {
if (!form) return '';
return Object.keys(FORM_CONFIG).find(function (formType) {
return FORM_CONFIG[formType].selector === '#' + form.id;
}) || '';
}
function init() { function init() {
// 每个页面只会命中自己的 data-auth-form,不需要拆多个入口文件。 // 每个页面只会命中自己的表单 id,不需要拆多个入口文件。
if (!documentRef) return; if (!documentRef) return;
bindSubmit('[data-auth-form="login"]', submitLogin); bindSubmit(FORM_CONFIG.login.selector, submitLogin);
bindSubmit('[data-auth-form="sms-login"]', submitSmsLogin); bindSubmit(FORM_CONFIG['sms-login'].selector, submitSmsLogin);
bindSubmit('[data-auth-form="register"]', submitRegister); bindSubmit(FORM_CONFIG.register.selector, submitRegister);
bindSubmit('[data-auth-form="password-reset"]', submitPasswordReset); bindSubmit(FORM_CONFIG['password-reset'].selector, submitPasswordReset);
bindLoginModeTabs(); bindLoginModeTabs();
bindCodeButtons(); bindCodeButtons();
} }
@@ -337,6 +426,8 @@
buildPasswordResetBody: buildPasswordResetBody, buildPasswordResetBody: buildPasswordResetBody,
getCaptchaScene: getCaptchaScene, getCaptchaScene: getCaptchaScene,
getFormCaptchaScene: getFormCaptchaScene, getFormCaptchaScene: getFormCaptchaScene,
getSuccessUrl: getSuccessUrl,
validateAuthValues: validateAuthValues,
init: init init: init
}; };
}); });
+27 -2
View File
@@ -70,6 +70,7 @@
// requestData 是 TAC 拖动完成后的轨迹数据;context 保存本次挑战的接口字段。 // requestData 是 TAC 拖动完成后的轨迹数据;context 保存本次挑战的接口字段。
var settings = context || {}; var settings = context || {};
var source = requestData || {}; var source = requestData || {};
var track = normalizeVerifyTrack(source.data || {});
return { return {
tenantId: settings.tenantId || api.tenantId, tenantId: settings.tenantId || api.tenantId,
@@ -80,12 +81,35 @@
providerCode: settings.providerCode || 'tianai', providerCode: settings.providerCode || 'tianai',
captchaType: settings.captchaType || 'SLIDER', captchaType: settings.captchaType || 'SLIDER',
payload: { payload: {
id: source.id, track: track
data: source.data || {}
} }
}; };
} }
function normalizeVerifyTrack(data) {
// 后端 /captcha/verify 要求轨迹统一放在 payload.track,并显式携带 left/top。
var track = Object.assign({}, data || {});
var trackList = Array.isArray(track.trackList) ? track.trackList : [];
var first = trackList[0] || {};
var last = trackList[trackList.length - 1] || {};
if (typeof track.left === 'undefined') {
track.left = toNumber(last.x) - toNumber(first.x);
}
if (typeof track.top === 'undefined') {
track.top = toNumber(last.y) - toNumber(first.y);
}
return track;
}
function toNumber(value) {
var numberValue = Number(value);
return Number.isFinite(numberValue) ? numberValue : 0;
}
function normalizeVerifyResponse(response) { function normalizeVerifyResponse(response) {
// TAC 只看 code 是否为 200;接口通过 passed 表示业务验证结果。 // TAC 只看 code 是否为 200;接口通过 passed 表示业务验证结果。
var payload = response || {}; var payload = response || {};
@@ -299,6 +323,7 @@
buildChallengeBody: buildChallengeBody, buildChallengeBody: buildChallengeBody,
adaptChallengeResponse: adaptChallengeResponse, adaptChallengeResponse: adaptChallengeResponse,
buildVerifyBody: buildVerifyBody, buildVerifyBody: buildVerifyBody,
normalizeVerifyTrack: normalizeVerifyTrack,
createCaptchaLayer: createCaptchaLayer, createCaptchaLayer: createCaptchaLayer,
closeCaptchaLayer: closeCaptchaLayer, closeCaptchaLayer: closeCaptchaLayer,
extractValidToken: extractValidToken, extractValidToken: extractValidToken,
+1 -1
View File
@@ -21,7 +21,7 @@
<div class="eyebrow">Create Account</div> <div class="eyebrow">Create Account</div>
<h1 id="register-title">注册账号</h1> <h1 id="register-title">注册账号</h1>
<p class="lead">注册后即可创建家谱或通过邀请码加入家族。</p> <p class="lead">注册后即可创建家谱或通过邀请码加入家族。</p>
<form class="form-grid" data-auth-form="register" data-captcha-scene="WEB_H5_REGISTER" data-success-url="create-genealogy.html"> <form id="register-form" class="form-grid">
<input <input
class="input" class="input"
name="phone" name="phone"
+23
View File
@@ -13,6 +13,12 @@ function readPage(fileName) {
} }
function run() { function run() {
const expectedFormIds = {
'login.html': ['login-password-form', 'login-sms-form'],
'register.html': ['register-form'],
'forgot-password.html': ['password-reset-form']
};
pages.forEach((fileName) => { pages.forEach((fileName) => {
const html = readPage(fileName); const html = readPage(fileName);
const formCaptchaBox = /<form[\s\S]*?data-captcha-box[\s\S]*?<\/form>/i.test(html); const formCaptchaBox = /<form[\s\S]*?data-captcha-box[\s\S]*?<\/form>/i.test(html);
@@ -30,8 +36,25 @@ function run() {
assert.strictEqual(hasCaptchaModalCss, false, `${fileName} 不应加载自定义验证码样式`); assert.strictEqual(hasCaptchaModalCss, false, `${fileName} 不应加载自定义验证码样式`);
assert.notStrictEqual(layuiScriptIndex, -1, `${fileName} 应加载 layui.js 使用 layer.msg`); assert.notStrictEqual(layuiScriptIndex, -1, `${fileName} 应加载 layui.js 使用 layer.msg`);
assert.ok(layuiScriptIndex < authScriptIndex, `${fileName} 应在 auth-pages.js 之前加载 layui.js`); assert.ok(layuiScriptIndex < authScriptIndex, `${fileName} 应在 auth-pages.js 之前加载 layui.js`);
assert.deepStrictEqual(
Array.from(html.matchAll(/<form[^>]*id="([^"]+)"/g)).map((match) => match[1]),
expectedFormIds[fileName],
`${fileName} 应使用表单 id 作为 JS 挂载点`
);
assert.strictEqual(/data-auth-form=|data-captcha-scene=|data-success-url=|data-scene-code=|data-login-mode/.test(html), false, `${fileName} 不应把认证业务配置写在 HTML data 属性上`);
assert.strictEqual(
(html.match(/<input type="hidden" name="validToken" \/>/g) || []).length,
expectedFormIds[fileName].length,
`${fileName} 每个认证表单都应携带 validToken 隐藏字段`
);
}); });
assert.strictEqual(
readPage('register.html').includes('id="register-form"'),
true,
'register.html 应使用 register-form 挂载注册逻辑'
);
console.log('auth-page-structure tests passed'); console.log('auth-page-structure tests passed');
} }
+31 -5
View File
@@ -51,13 +51,39 @@ function run() {
}); });
assert.strictEqual(AuthPages.getCaptchaScene('login'), 'WEB_H5_LOGIN'); assert.strictEqual(AuthPages.getCaptchaScene('login'), 'WEB_H5_LOGIN');
assert.strictEqual(AuthPages.getCaptchaScene('sms-login'), 'WEB_H5_LOGIN');
assert.strictEqual(AuthPages.getCaptchaScene('register'), 'WEB_H5_REGISTER'); assert.strictEqual(AuthPages.getCaptchaScene('register'), 'WEB_H5_REGISTER');
assert.strictEqual(AuthPages.getCaptchaScene('password-reset'), 'WEB_H5_FORGOT_PASSWORD'); assert.strictEqual(AuthPages.getCaptchaScene('password-reset'), 'WEB_H5_FORGOT_PASSWORD');
assert.strictEqual(AuthPages.getFormCaptchaScene({ assert.strictEqual(AuthPages.getSuccessUrl('register'), 'login.html');
getAttribute(name) { assert.strictEqual(AuthPages.getSuccessUrl('password-reset'), 'login.html');
return name === 'data-captcha-scene' ? 'WEB_H5_REGISTER' : ''; assert.strictEqual(AuthPages.validateAuthValues('login', {
} phone: '',
}, 'login'), 'WEB_H5_REGISTER'); password: '123456'
}), '请填写手机号');
assert.strictEqual(AuthPages.validateAuthValues('login', {
phone: '123',
password: '123456'
}), '请输入正确的手机号');
assert.strictEqual(AuthPages.validateAuthValues('register', {
phone: '13800000000',
password: '12345'
}), '密码至少需要 6 位');
assert.strictEqual(AuthPages.validateAuthValues('sms-login', {
phone: '13800000000',
smsCode: '12'
}), '请输入正确的短信验证码');
assert.strictEqual(AuthPages.validateAuthValues('password-reset', {
phone: '13800000000',
smsCode: '123456',
newPassword: 'abcdef',
confirmPassword: 'abcdeg'
}), '两次输入的新密码不一致');
assert.strictEqual(AuthPages.validateAuthValues('password-reset', {
phone: '13800000000',
smsCode: '123456',
newPassword: 'abcdef',
confirmPassword: 'abcdef'
}), '');
console.log('auth-pages tests passed'); console.log('auth-pages tests passed');
} }
+25 -4
View File
@@ -53,7 +53,17 @@ function run() {
assert.deepStrictEqual(CaptchaPages.buildVerifyBody({ assert.deepStrictEqual(CaptchaPages.buildVerifyBody({
id: 'tianai-1', id: 'tianai-1',
data: { data: {
trackList: [{ x: 10, y: 2 }] bgImageWidth: 340,
bgImageHeight: 180,
templateImageWidth: 50,
templateImageHeight: 50,
startTime: 1720000000000,
stopTime: 1720000001500,
trackList: [
{ x: 0, y: 0, t: 0, type: 'down' },
{ x: 120, y: 0, t: 650, type: 'move' },
{ x: 120, y: 0, t: 700, type: 'up' }
]
} }
}, { }, {
sceneCode: 'WEB_H5_LOGIN', sceneCode: 'WEB_H5_LOGIN',
@@ -70,9 +80,20 @@ function run() {
providerCode: 'tianai', providerCode: 'tianai',
captchaType: 'SLIDER', captchaType: 'SLIDER',
payload: { payload: {
id: 'tianai-1', track: {
data: { bgImageWidth: 340,
trackList: [{ x: 10, y: 2 }] bgImageHeight: 180,
templateImageWidth: 50,
templateImageHeight: 50,
startTime: 1720000000000,
stopTime: 1720000001500,
left: 120,
top: 0,
trackList: [
{ x: 0, y: 0, t: 0, type: 'down' },
{ x: 120, y: 0, t: 650, type: 'move' },
{ x: 120, y: 0, t: 700, type: 'up' }
]
} }
} }
}); });