# 小程序登录页 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 → 后端换票与账号绑定
- 《用户协议》《隐私政策》正文页