cloud 1 долоо хоног өмнө
parent
commit
9c4a3a9ab9

+ 5 - 0
PROJECT_CONTEXT.md

@@ -1,5 +1,10 @@
 # Project Context
 
+## 2026-10-24 登录协议勾选状态同步
+- 用户截图复现:点击登录协议文字后控件显示勾选,但 `agreed` 未更新,登录仍提示未同意。原因是原生 label 触发控件状态变化,而页面只在 checkbox 的 click 中手动反转布尔值。
+- 登录页改用 `checkbox-group` 的 change 事件读取 `detail.value`,文字与勾选框共享同一状态;隐私链接移到 label 外,点击链接不影响同意状态。测试码输入限制5位,短信码6位,验证码格式提示与协议提示分开。
+- 新增 `water-mini/tests/login-agreement-dom.test.mjs`:在 jsdom 中挂载真实 uni-app H5 Checkbox/CheckboxGroup/Label/Input,实际点击文字、勾选框、隐私链接及登录按钮,不通过直接赋值 agreed 绕过交互。4项DOM回归与全量40项前端测试、类型检查通过。浏览器/Windows工具启动均报“failed to write kernel assets: 系统找不到指定的路径”,未做真机或浏览器可视复测。
+
 ## 2026-10-24 恢复测试账号与正式短信登录并存
 - 用户明确要求三个测试账号继续使用固定验证码直接登录,正式手机号走短信,短信验证码两分钟过期。10-23 的 `0018bae` 将所有号码统一送至短信接口且只接受6位码,导致5位测试码 `66666` 无法使用;不能再次统一成纯短信登录。
 - `water-mini/src/utils/login-flow.ts` 按三个指定号码分流到 `/auth/test-phone`,其他号码走 `/auth/send-sms` 与 `/auth/phone-login`。页面分别校验 `test_phone_login`、`sms_login`,任一开启即显示手机号入口;测试号不发送短信、不启动发送倒计时,固定码由后端验证,正式号不能使用测试固定码。

+ 29 - 8
water-mini/src/pages/account/login.vue

@@ -16,6 +16,10 @@ const sendingCode = ref(false);
 const codeCountdown = ref(0);
 let countdownTimer: ReturnType<typeof setInterval> | null = null;
 
+function onAgreementChange(event: { detail: { value: string[] } }) {
+  agreed.value = event.detail.value.includes('agreed');
+}
+
 const isTestAccount = computed(() => isTestPhone(phone.value));
 const phoneLoginAvailable = computed(() =>
   Boolean(
@@ -130,11 +134,15 @@ async function loginByPhone() {
     toast('请先阅读并同意服务说明');
     return;
   }
+  if (!/^1[3-9]\d{9}$/.test(phone.value)) {
+    toast('请输入正确的手机号码');
+    return;
+  }
   const validCode = isTestAccount.value
     ? /^\d{5}$/.test(phoneCode.value)
     : /^\d{6}$/.test(phoneCode.value);
-  if (!/^1[3-9]\d{9}$/.test(phone.value) || !validCode) {
-    toast('请输入正确的手机号和验证码');
+  if (!validCode) {
+    toast(isTestAccount.value ? '测试账号请输入5位测试验证码 66666' : '请输入6位短信验证码');
     return;
   }
   if (busy.value || !assertPhoneLoginAvailable()) return;
@@ -198,8 +206,8 @@ async function loginByPhone() {
             v-model="phoneCode"
             class="input"
             type="number"
-            :maxlength="6"
-            placeholder="请输入验证码"
+            :maxlength="isTestAccount ? 5 : 6"
+            :placeholder="isTestAccount ? '请输入5位测试验证码' : '请输入6位短信验证码'"
             @confirm="loginByPhone"
           />
           <button
@@ -221,11 +229,15 @@ async function loginByPhone() {
         <button class="primary" :loading="busy" :disabled="busy" @click="loginByPhone">登录</button>
       </view>
       <view v-else class="muted">手机验证码登录暂未开放,请联系平台管理员。</view>
-      <label class="check-row">
-        <checkbox :checked="agreed" color="#176afa" @click="agreed = !agreed" />
-        <text>我已阅读并同意</text>
+      <view class="check-row">
+        <checkbox-group class="agreement-group" @change="onAgreementChange">
+          <label class="agreement-label">
+            <checkbox value="agreed" :checked="agreed" color="#176afa" />
+            <text>我已阅读并同意</text>
+          </label>
+        </checkbox-group>
         <text class="link" @click.stop="privacy">隐私与服务说明</text>
-      </label>
+      </view>
     </view>
     <view class="login-footer">
       <button
@@ -369,6 +381,15 @@ async function loginByPhone() {
   flex: none;
   margin-right: 2rpx;
 }
+.agreement-group,
+.agreement-label {
+  display: flex;
+  align-items: center;
+}
+.agreement-label {
+  gap: 10rpx;
+  min-height: 64rpx;
+}
 .link {
   color: #176afa;
 }

+ 170 - 0
water-mini/tests/login-agreement-dom.test.mjs

@@ -0,0 +1,170 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { readFile } from 'node:fs/promises';
+import { createRequire } from 'node:module';
+import { JSDOM } from 'jsdom';
+import { parse, compileScript, compileTemplate } from '@vue/compiler-sfc';
+import ts from 'typescript';
+
+// Mount the actual uni-app checkbox, group and label; do not replace their click behavior.
+const dom = new JSDOM('<!doctype html><html><body></body></html>', {
+  url: 'http://localhost/',
+});
+for (const key of ['window', 'document', 'Element', 'HTMLElement', 'SVGElement', 'Node']) {
+  globalThis[key] = dom.window[key];
+}
+globalThis.__uniConfig = {};
+globalThis.getCurrentPages = () => [];
+const require = createRequire(import.meta.url);
+const vue = require('vue');
+const { Checkbox, CheckboxGroup, Label, Input } = require('@dcloudio/uni-h5');
+const source = await readFile(new URL('../src/pages/account/login.vue', import.meta.url), 'utf8');
+const { descriptor } = parse(source, { filename: 'login.vue' });
+const script = compileScript(descriptor, { id: 'login-agreement-dom' });
+const components = {
+  checkbox: 'UniCheckbox',
+  'checkbox-group': 'UniCheckboxGroup',
+  label: 'UniLabel',
+  input: 'UniInput',
+};
+const template = compileTemplate({
+  source: descriptor.template.content.replace(
+    /<(\/?)(checkbox-group|checkbox|label|input)(?=[\s/>])/g,
+    (_, closing, tag) => '<' + closing + components[tag],
+  ),
+  filename: 'login.vue',
+  id: 'login-agreement-dom',
+  compilerOptions: {
+    bindingMetadata: script.bindings,
+    isNativeTag: (tag) => ['view', 'text', 'button'].includes(tag),
+  },
+});
+assert.deepEqual(template.errors, []);
+
+function evaluate(code, dependencies) {
+  const { outputText } = ts.transpileModule(code, {
+    compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS },
+  });
+  const exports = {};
+  new Function('require', 'exports', outputText)((name) => {
+    if (name in dependencies) return dependencies[name];
+    throw new Error('Unexpected dependency: ' + name);
+  }, exports);
+  return exports;
+}
+
+async function click(element) {
+  assert.ok(element, 'click target exists');
+  element.click();
+  await vue.nextTick();
+  await Promise.resolve();
+}
+
+async function fixture(t, code = '66666') {
+  const posts = [],
+    messages = [],
+    accepted = [],
+    navigations = [];
+  globalThis.uni = { reLaunch: ({ url }) => navigations.push(url) };
+  const configuration = {
+    privacy_url: 'https://example.test/privacy',
+    capabilities: { sms_login: true, test_phone_login: true },
+  };
+  const login = { access_token: 'fixture-token', user_id: '42', identities: [] };
+  const component = evaluate(script.content, {
+    vue,
+    '@dcloudio/uni-app': { onShow() {} },
+    '../../stores/session': {
+      session: vue.reactive({ configuration }),
+      loadConfiguration() {},
+      acceptLogin: (value) => accepted.push(value),
+      accountPage: () => '/pages/home/index',
+    },
+    '../../api/client': {},
+    '../../utils/format': {
+      toast: (message) => messages.push(message),
+      go: (url) => navigations.push(url),
+    },
+    '../../utils/login-flow': {
+      isTestPhone: (phone) => ['13012345678', '13012345679', '13012345680'].includes(phone),
+      requestPhoneCode: async () => true,
+      loginByPhoneCode: async (phone, code) => {
+        posts.push({ phone, code });
+        return login;
+      },
+    },
+  }).default;
+  component.render = evaluate(template.code, { vue }).render;
+  const root = document.createElement('div');
+  document.body.append(root);
+  const app = vue.createApp(component);
+  app.component('UniCheckbox', Checkbox);
+  app.component('UniCheckboxGroup', CheckboxGroup);
+  app.component('UniLabel', Label);
+  app.component('UniInput', Input);
+  app.mount(root);
+  t.after(() => {
+    app.unmount();
+    root.remove();
+  });
+  await click(root.querySelector('.login-option'));
+  const inputs = root.querySelectorAll('.phone-login input');
+  for (const [index, value] of ['13012345678', code].entries()) {
+    inputs[index].value = value;
+    inputs[index].dispatchEvent(new window.Event('input', { bubbles: true }));
+  }
+  await vue.nextTick();
+  const consent = () =>
+    [...root.querySelectorAll('uni-label text')].find((element) =>
+      element.textContent.includes('我已阅读并同意'),
+    );
+  const checked = () => Boolean(root.querySelector('uni-checkbox svg'));
+  const submit = () => click(root.querySelector('.phone-login .primary'));
+  return { root, posts, messages, accepted, navigations, consent, checked, submit };
+}
+
+test('clicking the agreement text updates the actual checkbox and permits login', async (t) => {
+  const app = await fixture(t);
+  await app.submit();
+  assert.match(app.messages.at(-1), /请先阅读并同意/);
+  await click(app.consent());
+  assert.equal(app.checked(), true);
+  await app.submit();
+  assert.deepEqual(app.posts, [{ phone: '13012345678', code: '66666' }]);
+  assert.equal(app.accepted.length, 1);
+});
+
+test('checkbox and label clicks remain synchronized when repeatedly selecting and clearing', async (t) => {
+  const app = await fixture(t);
+  await click(app.root.querySelector('uni-checkbox'));
+  assert.equal(app.checked(), true);
+  await click(app.consent());
+  assert.equal(app.checked(), false);
+  await app.submit();
+  assert.equal(app.posts.length, 0);
+  assert.match(app.messages.at(-1), /请先阅读并同意/);
+  await click(app.consent());
+  await app.submit();
+  assert.equal(app.posts.length, 1);
+});
+
+test('opening the privacy link never changes agreement selection', async (t) => {
+  const app = await fixture(t);
+  await click(app.root.querySelector('.check-row .link'));
+  assert.equal(app.checked(), false);
+  await app.submit();
+  assert.equal(app.posts.length, 0);
+  await click(app.consent());
+  await click(app.root.querySelector('.check-row .link'));
+  assert.equal(app.checked(), true);
+  await app.submit();
+  assert.equal(app.posts.length, 1);
+});
+
+test('an agreed test login with a six-digit code shows a code error instead of an agreement error', async (t) => {
+  const app = await fixture(t, '666666');
+  await click(app.consent());
+  await app.submit();
+  assert.equal(app.posts.length, 0);
+  assert.equal(app.messages.at(-1), '测试账号请输入5位测试验证码 66666');
+});

+ 1 - 1
water-mini/tests/login-flow.test.mjs

@@ -235,7 +235,7 @@ test('code length follows the account type and fixed code cannot log into a real
   app.state.phoneCode.value = '123456';
   await app.state.loginByPhone();
   assert.equal(app.posts.length, 0);
-  assert.match(app.messages.at(-1), /正确的手机号和验证码/);
+  assert.match(app.messages.at(-1), /5位测试验证码/);
 });
 
 test('wrong code is reported without accepting a session or navigating away', async () => {