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
前置已完成(勿重复造):
mobile-ui/zkxw-mobile-login-ui-demo.htmlmobile-ui/assets/wechat-logo.png、wechat-logo-32.png| 路径 | 职责 |
|---|---|
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 换票、协议正文页内容、忘记密码 / 注册。
mobile-h5/staticFiles:
Create: mobile-h5/static/wechat-logo.png
[ ] Step 1: 拷贝资源
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。
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
)"
auth-mock.jsFiles:
mobile-h5/utils/auth-mock.jsTest(Node 可跑的轻量断言): mobile-h5/utils/auth-mock.test.mjs(可选;若环境无 test runner,用下方 node --test 或手写 assert 脚本)
[ ] Step 1: 写入 auth-mock.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 }
}
Create mobile-h5/utils/auth-mock.validate.test.mjs:
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:
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 改为:
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:
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')
cd /Users/wuzilong/work/00code/zkxw/mobile-h5 && node utils/validate-credentials.test.mjs
Expected: 打印 validate-credentials: all passed,exit 0。
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
)"
login.scssFiles:
mobile-h5/styles/components/login.scssModify: mobile-h5/App.vue(仅加 import,页面本身在 Task 4)
[ ] Step 1: 写入样式(对齐 Demo 方案 B,rpx)
.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;
}
App.vue 增加 import在 <style lang="scss"> 末尾现有 imports 后追加:
@import './styles/components/login.scss';
cd /Users/wuzilong/work/00code/zkxw
git add mobile-h5/styles/components/login.scss mobile-h5/App.vue
git commit -m "$(cat <<'EOF'
style(mobile-h5): add login page SCSS for scheme B layout
EOF
)"
pages/login/index.vueFiles:
Create: mobile-h5/pages/login/index.vue
[ ] Step 1: 创建页面(完整实现)
<template>
<view class="login-page">
<view class="login-brand">
<view class="login-brand__mark">兴</view>
<view class="login-brand__title">中科兴皖</view>
<view class="login-brand__sub">员工工作台登录</view>
</view>
<view class="login-card">
<view class="login-field">
<text class="login-field__label">账号</text>
<view
class="login-field__box"
:class="{ 'is-focus': focusUser, 'is-error': !!errors.username }"
>
<input
class="login-field__input"
type="text"
:value="username"
placeholder="请输入用户名 / 工号"
placeholder-class="login-ph"
@input="onUserInput"
@focus="focusUser = true"
@blur="focusUser = false"
/>
</view>
<view v-if="errors.username" class="login-field__error">{{ errors.username }}</view>
</view>
<view class="login-field">
<text class="login-field__label">密码</text>
<view
class="login-field__box"
:class="{ 'is-focus': focusPwd, 'is-error': !!errors.password }"
>
<input
class="login-field__input"
:password="!showPwd"
:value="password"
placeholder="请输入密码"
placeholder-class="login-ph"
@input="onPwdInput"
@focus="focusPwd = true"
@blur="focusPwd = false"
/>
<text class="login-field__action" @click="showPwd = !showPwd">
{{ showPwd ? '隐藏' : '显示' }}
</text>
</view>
<view v-if="errors.password" class="login-field__error">{{ errors.password }}</view>
</view>
<view
class="login-submit"
:class="{ 'is-disabled': loading }"
@click="onPasswordLogin"
>
{{ loading ? '登录中…' : '登录' }}
</view>
<view class="login-agree" @click="toggleAgree">
<view class="login-agree__box" :class="{ 'is-on': agreed }" />
<view class="login-agree__text">
已阅读并同意
<text class="login-agree__link" @click.stop="toastProtocol('用户协议')">《用户协议》</text>
和
<text class="login-agree__link" @click.stop="toastProtocol('隐私政策')">《隐私政策》</text>
</view>
</view>
</view>
<view class="login-divider">或</view>
<view class="login-wechat" @click="onWechatLogin">
<image class="login-wechat__icon" src="/static/wechat-logo.png" mode="aspectFit" />
<text>微信授权登录</text>
</view>
</view>
</template>
<script>
import {
isLoggedIn,
mockPasswordLogin,
mockWechatLogin
} from '../../utils/auth-mock.js'
import { validateCredentials } from '../../utils/validate-credentials.js'
export default {
data() {
return {
username: '',
password: '',
showPwd: false,
agreed: false,
loading: false,
focusUser: false,
focusPwd: false,
errors: {
username: '',
password: ''
}
}
},
onShow() {
if (isLoggedIn()) {
uni.reLaunch({ url: '/pages/index/index' })
}
},
methods: {
onUserInput(e) {
this.username = e.detail.value
this.errors.username = ''
},
onPwdInput(e) {
this.password = e.detail.value
this.errors.password = ''
},
toggleAgree() {
this.agreed = !this.agreed
},
ensureAgree() {
if (!this.agreed) {
uni.showToast({ title: '请先同意用户协议与隐私政策', icon: 'none' })
return false
}
return true
},
toastProtocol(name) {
uni.showToast({ title: name + '(Demo 占位)', icon: 'none' })
},
goHome() {
uni.reLaunch({ url: '/pages/index/index' })
},
onPasswordLogin() {
if (this.loading) return
if (!this.ensureAgree()) return
this.errors = { username: '', password: '' }
const v = validateCredentials(this.username, this.password)
if (!v.ok) {
if (v.code === 'EMPTY_USERNAME') this.errors.username = v.message
if (v.code === 'EMPTY_PASSWORD') this.errors.password = v.message
return
}
this.loading = true
try {
mockPasswordLogin({
username: this.username,
password: this.password
})
uni.showToast({ title: '登录成功', icon: 'success' })
setTimeout(() => this.goHome(), 400)
} catch (e) {
uni.showToast({ title: e.message || '登录失败', icon: 'none' })
} finally {
this.loading = false
}
},
onWechatLogin() {
if (this.loading) return
if (!this.ensureAgree()) return
this.loading = true
try {
mockWechatLogin()
uni.showToast({ title: '微信授权成功(Demo)', icon: 'none' })
setTimeout(() => this.goHome(), 400)
} catch (e) {
uni.showToast({ title: e.message || '授权失败', icon: 'none' })
} finally {
this.loading = false
}
}
}
}
</script>
<style scoped>
.login-ph {
color: #9aaba3;
}
</style>
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
)"
Files:
Modify: mobile-h5/pages.json
[ ] Step 1: 将 login 插入 pages 数组第一项
最终 pages 开头应为:
"pages": [
{
"path": "pages/login/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "登录"
}
},
{
"path": "pages/index/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "工作台"
}
}
]
保留其后既有 pages/demo/* 条目不变。
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
)"
Files:
Modify: mobile-h5/pages/index/index.vue(在现有 Header 操作区加退出;若无合适位置,在页脚或标题旁加文字按钮)
[ ] Step 1: 阅读现有 pages/index/index.vue,在品牌 Header 右侧或底部增加退出
示例(按实际模板微调 class):
<!-- 在合适位置 -->
<text class="home-logout" @click="onLogout">退出</text>
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))。
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
)"
Files:
docs/miniprogram-ui-spec-sanqiang.md(目录附近 / 移动工作台小节追加登录页)Modify: docs/superpowers/specs/2026-08-10-miniprogram-login-ui-design.md §5 / §6
[ ] Step 1: 在 UI 规范「移动工作台」节追加
- 登录页 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
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
)"
cd /Users/wuzilong/work/00code/zkxw/mobile-h5 && node utils/validate-credentials.test.mjs
Expected: all passed
| # | 操作 | 期望 |
|---|---|---|
| 1 | 冷启动 | 进入登录页(非工作台) |
| 2 | 不勾选协议点「登录」 | Toast「请先同意用户协议与隐私政策」 |
| 3 | 不勾选点「微信授权登录」 | 同上 |
| 4 | 勾选协议,账号/密码留空点登录 | 字段错误文案 |
| 5 | 填写账号密码登录 | Toast 成功 → 进入工作台 |
| 6 | 密码「显示/隐藏」 | 明文/密文切换 |
| 7 | 微信授权登录 | Toast Demo 成功 → 工作台 |
| 8 | 工作台点退出 | 回到登录页;再进需重新登录 |
| 9 | 视觉 | 无 #10b981;微信按钮为 static/wechat-logo.png |
若偏差明显,只改 login.scss / pages/login/index.vue,勿改 Token 色值。
| Spec 要求 | 对应 Task |
|---|---|
| 方案 B 居中品牌 + 白卡片 + 微信次要 | Task 3–4 |
| 账号=用户名/工号;密码可显隐 | Task 4 |
| 协议门禁 | Task 4 |
| 微信官方 Logo | Task 1、4 |
| 空校验 / loading / 成功进工作台 | Task 2、4、5 |
| 不做忘记密码/注册/真实 API | 全计划未引入 |
| HTML Demo 已有 | 前置完成 |
| 规范文档指向 | Task 7 |
无 TBD /「类似 Task N」占位;校验逻辑有可执行测试命令。
/api 账号密码登录(RSA / JWT)与小程序端加密对齐 web-adminwx.login code → 后端换票与账号绑定