# 小程序登录页 UI 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:** 在 `mobile-h5` 落地与 HTML Demo 一致的员工登录页(账号密码为主、微信授权为次、协议门禁),用 mock 鉴权跳转工作台;本期不联调真实登录 / 微信换票 API。 **Architecture:** 视觉权威为 `mobile-ui/zkxw-mobile-login-ui-demo.html`;`pages/login/index` 作为小程序首页(`pages.json` 第一项);样式走既有三墙 Token + 独立 `login.scss`;`utils/auth-mock.js` 负责本地 token 读写与模拟登录成功;微信按钮仅 Toast / 模拟成功,不调用真实后端。 **Tech Stack:** uni-app Vue3(HBuilderX)、`@vant/weapp`、SCSS、微信小程序发行 **Spec:** `docs/superpowers/specs/2026-08-10-miniprogram-login-ui-design.md` **前置已完成(勿重复造):** - HTML Demo:`mobile-ui/zkxw-mobile-login-ui-demo.html` - 微信 Logo:`mobile-ui/assets/wechat-logo.png`、`wechat-logo-32.png` - 设计说明:上述 Spec --- ## File map(将创建 / 修改) | 路径 | 职责 | |------|------| | `mobile-h5/static/wechat-logo.png` | 登录页微信按钮图标(从 Demo assets 拷贝) | | `mobile-h5/utils/auth-mock.js` | mock 登录、token 存取、是否已登录 | | `mobile-h5/styles/components/login.scss` | 登录页布局与控件样式(方案 B) | | `mobile-h5/pages/login/index.vue` | 登录页 UI + 交互 | | `mobile-h5/pages.json` | 将 login 设为首页;`navigationStyle: custom` | | `mobile-h5/App.vue` | 引入 `login.scss`;启动时未登录可留在 login(首页即 login) | | `docs/miniprogram-ui-spec-sanqiang.md` | 增加登录页 Demo / 设计指向 | | `docs/superpowers/specs/2026-08-10-miniprogram-login-ui-design.md` | §6 非目标中划掉「uni-app 落地」并注明已进入实现 | **不修改:** 后端登录 API、真实微信 code 换票、协议正文页内容、忘记密码 / 注册。 --- ### Task 1: 拷贝微信 Logo 到 `mobile-h5/static` **Files:** - Create: `mobile-h5/static/wechat-logo.png` - [ ] **Step 1: 拷贝资源** ```bash cp /Users/wuzilong/work/00code/zkxw/mobile-ui/assets/wechat-logo-32.png \ /Users/wuzilong/work/00code/zkxw/mobile-h5/static/wechat-logo.png ``` Expected: 文件存在,体积约十余 KB。 - [ ] **Step 2: Commit** ```bash cd /Users/wuzilong/work/00code/zkxw git add mobile-h5/static/wechat-logo.png git commit -m "$(cat <<'EOF' chore(mobile-h5): add WeChat logo asset for login button EOF )" ``` --- ### Task 2: Mock 鉴权工具 `auth-mock.js` **Files:** - Create: `mobile-h5/utils/auth-mock.js` - Test(Node 可跑的轻量断言): `mobile-h5/utils/auth-mock.test.mjs`(可选;若环境无 test runner,用下方 `node --test` 或手写 assert 脚本) - [ ] **Step 1: 写入 `auth-mock.js`** ```js const TOKEN_KEY = 'zkxw_mobile_token' const USER_KEY = 'zkxw_mobile_user' export function getToken() { try { return uni.getStorageSync(TOKEN_KEY) || '' } catch (e) { return '' } } export function isLoggedIn() { return !!getToken() } export function getUser() { try { const raw = uni.getStorageSync(USER_KEY) return raw ? JSON.parse(raw) : null } catch (e) { return null } } export function clearSession() { try { uni.removeStorageSync(TOKEN_KEY) uni.removeStorageSync(USER_KEY) } catch (e) { /* ignore */ } } /** * 账号密码 mock 登录。 * Demo 约定:任意非空账号密码均成功;空值抛错。 */ export function mockPasswordLogin({ username, password }) { const u = (username || '').trim() const p = password || '' if (!u) { const err = new Error('请输入用户名 / 工号') err.code = 'EMPTY_USERNAME' throw err } if (!p) { const err = new Error('请输入密码') err.code = 'EMPTY_PASSWORD' throw err } const token = 'mock_' + Date.now() const user = { username: u, displayName: u } uni.setStorageSync(TOKEN_KEY, token) uni.setStorageSync(USER_KEY, JSON.stringify(user)) return { token, user } } /** * 微信授权 mock:不调用 wx.login,直接写会话。 */ export function mockWechatLogin() { const token = 'mock_wx_' + Date.now() const user = { username: 'wx_user', displayName: '微信用户' } uni.setStorageSync(TOKEN_KEY, token) uni.setStorageSync(USER_KEY, JSON.stringify(user)) return { token, user } } /** 纯校验(不写 storage),供单测 / 表单前置 */ export function validateCredentials(username, password) { const u = (username || '').trim() const p = password || '' if (!u) return { ok: false, code: 'EMPTY_USERNAME', message: '请输入用户名 / 工号' } if (!p) return { ok: false, code: 'EMPTY_PASSWORD', message: '请输入密码' } return { ok: true } } ``` - [ ] **Step 2: 写入可在 Node 跑的校验单测(不依赖 uni)** Create `mobile-h5/utils/auth-mock.validate.test.mjs`: ```js import assert from 'node:assert/strict' import { validateCredentials } from './auth-mock.js' // Node 下 auth-mock 顶部会引用 uni —— 因此把 validateCredentials 抽到无 uni 依赖的文件更稳。 // 若 Step 1 整文件依赖 uni,则改为单独文件 validate-credentials.js(见 Step 3 修正)。 ``` **修正(推荐落地结构):** 将纯函数拆到 `validate-credentials.js`,避免 Node 测 uni。 Create `mobile-h5/utils/validate-credentials.js`: ```js export function validateCredentials(username, password) { const u = (username || '').trim() const p = password || '' if (!u) return { ok: false, code: 'EMPTY_USERNAME', message: '请输入用户名 / 工号' } if (!p) return { ok: false, code: 'EMPTY_PASSWORD', message: '请输入密码' } return { ok: true } } ``` Update `auth-mock.js` 改为: ```js import { validateCredentials } from './validate-credentials.js' export { validateCredentials } const TOKEN_KEY = 'zkxw_mobile_token' const USER_KEY = 'zkxw_mobile_user' export function getToken() { try { return uni.getStorageSync(TOKEN_KEY) || '' } catch (e) { return '' } } export function isLoggedIn() { return !!getToken() } export function getUser() { try { const raw = uni.getStorageSync(USER_KEY) return raw ? JSON.parse(raw) : null } catch (e) { return null } } export function clearSession() { try { uni.removeStorageSync(TOKEN_KEY) uni.removeStorageSync(USER_KEY) } catch (e) { /* ignore */ } } export function mockPasswordLogin({ username, password }) { const v = validateCredentials(username, password) if (!v.ok) { const err = new Error(v.message) err.code = v.code throw err } const u = (username || '').trim() const token = 'mock_' + Date.now() const user = { username: u, displayName: u } uni.setStorageSync(TOKEN_KEY, token) uni.setStorageSync(USER_KEY, JSON.stringify(user)) return { token, user } } export function mockWechatLogin() { const token = 'mock_wx_' + Date.now() const user = { username: 'wx_user', displayName: '微信用户' } uni.setStorageSync(TOKEN_KEY, token) uni.setStorageSync(USER_KEY, JSON.stringify(user)) return { token, user } } ``` Create `mobile-h5/utils/validate-credentials.test.mjs`: ```js import assert from 'node:assert/strict' import { validateCredentials } from './validate-credentials.js' assert.equal(validateCredentials('', 'x').code, 'EMPTY_USERNAME') assert.equal(validateCredentials(' ', 'x').code, 'EMPTY_USERNAME') assert.equal(validateCredentials('admin', '').code, 'EMPTY_PASSWORD') assert.equal(validateCredentials('admin', '111111').ok, true) console.log('validate-credentials: all passed') ``` - [ ] **Step 3: 跑校验测试** ```bash cd /Users/wuzilong/work/00code/zkxw/mobile-h5 && node utils/validate-credentials.test.mjs ``` Expected: 打印 `validate-credentials: all passed`,exit 0。 - [ ] **Step 4: Commit** ```bash cd /Users/wuzilong/work/00code/zkxw git add mobile-h5/utils/validate-credentials.js \ mobile-h5/utils/validate-credentials.test.mjs \ mobile-h5/utils/auth-mock.js git commit -m "$(cat <<'EOF' feat(mobile-h5): add mock auth helpers for login page EOF )" ``` --- ### Task 3: 登录页样式 `login.scss` **Files:** - Create: `mobile-h5/styles/components/login.scss` - Modify: `mobile-h5/App.vue`(仅加 import,页面本身在 Task 4) - [ ] **Step 1: 写入样式(对齐 Demo 方案 B,rpx)** ```scss .login-page { min-height: 100vh; padding: 96rpx 48rpx 64rpx; box-sizing: border-box; background: radial-gradient(520rpx 320rpx at 8% -8%, rgba(10, 159, 109, 0.16), transparent 60%), radial-gradient(440rpx 280rpx at 96% 4%, rgba(214, 163, 67, 0.14), transparent 55%), linear-gradient(180deg, #eef6f2 0%, #f5f8f7 48%, #f3f6f5 100%); } .login-brand { text-align: center; margin-bottom: 44rpx; } .login-brand__mark { width: 128rpx; height: 128rpx; margin: 0 auto 24rpx; border-radius: 36rpx; background: linear-gradient(145deg, var(--brand-green-dark), var(--brand-green)); color: #fff; font-size: 52rpx; font-weight: 700; display: flex; align-items: center; justify-content: center; box-shadow: 0 20rpx 48rpx rgba(8, 127, 87, 0.28); } .login-brand__title { font-size: 44rpx; font-weight: 650; color: var(--text-primary); letter-spacing: 0.03em; } .login-brand__sub { margin-top: 16rpx; font-size: 26rpx; color: var(--text-muted); } .login-card { background: var(--surface); border: 1px solid var(--border); border-radius: 32rpx; padding: 36rpx 32rpx 32rpx; box-shadow: var(--shadow-soft); } .login-field { margin-bottom: 28rpx; } .login-field__label { display: block; font-size: 24rpx; color: var(--text-muted); margin-bottom: 12rpx; } .login-field__box { display: flex; align-items: center; height: 88rpx; padding: 0 24rpx; border: 1px solid var(--border); border-radius: 20rpx; background: #f8fbfa; } .login-field__box.is-focus { border-color: var(--brand-green); background: #fff; box-shadow: 0 0 0 6rpx rgba(10, 159, 109, 0.12); } .login-field__box.is-error { border-color: var(--danger); } .login-field__input { flex: 1; height: 88rpx; font-size: 28rpx; color: var(--text-primary); } .login-field__action { font-size: 24rpx; color: var(--brand-green); padding: 12rpx 0 12rpx 16rpx; } .login-field__error { margin-top: 12rpx; font-size: 24rpx; color: var(--danger); } .login-submit { margin-top: 8rpx; height: 88rpx; border-radius: 24rpx; background: var(--brand-green); color: #fff; font-size: 30rpx; font-weight: 600; display: flex; align-items: center; justify-content: center; } .login-submit.is-disabled { opacity: 0.55; } .login-agree { display: flex; align-items: flex-start; gap: 16rpx; margin-top: 28rpx; } .login-agree__box { width: 32rpx; height: 32rpx; border-radius: 50%; border: 3rpx solid var(--brand-green); margin-top: 4rpx; flex-shrink: 0; box-sizing: border-box; } .login-agree__box.is-on { background: var(--brand-green); } .login-agree__text { flex: 1; font-size: 24rpx; color: var(--text-muted); line-height: 1.45; } .login-agree__link { color: var(--brand-green); } .login-divider { display: flex; align-items: center; gap: 24rpx; margin: 44rpx 0 28rpx; font-size: 24rpx; color: var(--text-muted); } .login-divider::before, .login-divider::after { content: ''; flex: 1; height: 1px; background: var(--border); } .login-wechat { height: 88rpx; border-radius: 24rpx; border: 1px solid var(--border); background: #fff; display: flex; align-items: center; justify-content: center; gap: 16rpx; font-size: 28rpx; font-weight: 500; color: var(--text-primary); } .login-wechat__icon { width: 44rpx; height: 44rpx; } ``` - [ ] **Step 2: 在 `App.vue` 增加 import** 在 ` ``` - [ ] **Step 2: Commit** ```bash cd /Users/wuzilong/work/00code/zkxw git add mobile-h5/pages/login/index.vue git commit -m "$(cat <<'EOF' feat(mobile-h5): add employee login page UI (scheme B) EOF )" ``` --- ### Task 5: 注册路由并把登录设为启动页 **Files:** - Modify: `mobile-h5/pages.json` - [ ] **Step 1: 将 login 插入 `pages` 数组第一项** 最终 `pages` 开头应为: ```json "pages": [ { "path": "pages/login/index", "style": { "navigationStyle": "custom", "navigationBarTitleText": "登录" } }, { "path": "pages/index/index", "style": { "navigationStyle": "custom", "navigationBarTitleText": "工作台" } } ] ``` 保留其后既有 `pages/demo/*` 条目不变。 - [ ] **Step 2: Commit** ```bash cd /Users/wuzilong/work/00code/zkxw git add mobile-h5/pages.json git commit -m "$(cat <<'EOF' feat(mobile-h5): register login as launch page EOF )" ``` --- ### Task 6: 工作台增加「退出登录」(便于手测闭环) **Files:** - Modify: `mobile-h5/pages/index/index.vue`(在现有 Header 操作区加退出;若无合适位置,在页脚或标题旁加文字按钮) - [ ] **Step 1: 阅读现有 `pages/index/index.vue`,在品牌 Header 右侧或底部增加退出** 示例(按实际模板微调 class): ```vue 退出 ``` ```js import { clearSession } from '../../utils/auth-mock.js' methods: { onLogout() { clearSession() uni.reLaunch({ url: '/pages/login/index' }) } } ``` 样式可用现有 Header 次要文字色 `var(--text-muted)` 或白色半透明(若在绿顶栏内用 `rgba(255,255,255,.9)`)。 - [ ] **Step 2: Commit** ```bash cd /Users/wuzilong/work/00code/zkxw git add mobile-h5/pages/index/index.vue git commit -m "$(cat <<'EOF' feat(mobile-h5): add logout on workbench for login demo loop EOF )" ``` --- ### Task 7: 文档对齐 **Files:** - Modify: `docs/miniprogram-ui-spec-sanqiang.md`(目录附近 / 移动工作台小节追加登录页) - Modify: `docs/superpowers/specs/2026-08-10-miniprogram-login-ui-design.md` §5 / §6 - [ ] **Step 1: 在 UI 规范「移动工作台」节追加** ```markdown - 登录页 Demo:`mobile-ui/zkxw-mobile-login-ui-demo.html` - 登录页设计:`docs/superpowers/specs/2026-08-10-miniprogram-login-ui-design.md` - 工程页:`mobile-h5/pages/login/index.vue`(方案 B:居中品牌 + 白卡片 + 微信次要按钮) ``` - [ ] **Step 2: 更新设计 Spec §5 交付物表,增加工程页行;§6 将「uni-app 页面落地」改为已实现(Mock),保留「真实 API 联调」为非目标** - [ ] **Step 3: Commit** ```bash cd /Users/wuzilong/work/00code/zkxw git add docs/miniprogram-ui-spec-sanqiang.md \ docs/superpowers/specs/2026-08-10-miniprogram-login-ui-design.md git commit -m "$(cat <<'EOF' docs: link miniprogram login demo and mobile-h5 page EOF )" ``` --- ### Task 8: 手测验收(HBuilderX / 微信开发者工具) - [ ] **Step 1: 运行校验单测** ```bash cd /Users/wuzilong/work/00code/zkxw/mobile-h5 && node utils/validate-credentials.test.mjs ``` Expected: `all passed` - [ ] **Step 2: HBuilderX 运行到微信开发者工具,按清单勾选** | # | 操作 | 期望 | |---|------|------| | 1 | 冷启动 | 进入登录页(非工作台) | | 2 | 不勾选协议点「登录」 | Toast「请先同意用户协议与隐私政策」 | | 3 | 不勾选点「微信授权登录」 | 同上 | | 4 | 勾选协议,账号/密码留空点登录 | 字段错误文案 | | 5 | 填写账号密码登录 | Toast 成功 → 进入工作台 | | 6 | 密码「显示/隐藏」 | 明文/密文切换 | | 7 | 微信授权登录 | Toast Demo 成功 → 工作台 | | 8 | 工作台点退出 | 回到登录页;再进需重新登录 | | 9 | 视觉 | 无 `#10b981`;微信按钮为 `static/wechat-logo.png` | - [ ] **Step 3: 对照 HTML Demo 目测间距 / 圆角 / 按钮高度 ≈ 44px** 若偏差明显,只改 `login.scss` / `pages/login/index.vue`,勿改 Token 色值。 --- ## Spec coverage(自审) | Spec 要求 | 对应 Task | |-----------|-----------| | 方案 B 居中品牌 + 白卡片 + 微信次要 | Task 3–4 | | 账号=用户名/工号;密码可显隐 | Task 4 | | 协议门禁 | Task 4 | | 微信官方 Logo | Task 1、4 | | 空校验 / loading / 成功进工作台 | Task 2、4、5 | | 不做忘记密码/注册/真实 API | 全计划未引入 | | HTML Demo 已有 | 前置完成 | | 规范文档指向 | Task 7 | ## Placeholder scan 无 TBD /「类似 Task N」占位;校验逻辑有可执行测试命令。 --- ## 后续另开(本计划不做) - `/api` 账号密码登录(RSA / JWT)与小程序端加密对齐 web-admin - `wx.login` code → 后端换票与账号绑定 - 《用户协议》《隐私政策》正文页