2026-08-10-miniprogram-login-ui.md 22 KB

小程序登录页 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: 拷贝资源

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
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

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:

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')
  • Step 3: 跑校验测试
cd /Users/wuzilong/work/00code/zkxw/mobile-h5 && node utils/validate-credentials.test.mjs

Expected: 打印 validate-credentials: all passed,exit 0。

  • Step 4: Commit
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)

.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

在 <style lang="scss"> 末尾现有 imports 后追加:

@import './styles/components/login.scss';
  • Step 3: Commit
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
)"

Task 4: 登录页 pages/login/index.vue

Files:

  • 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>
  • Step 2: Commit
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 开头应为:

"pages": [
  {
    "path": "pages/login/index",
    "style": {
      "navigationStyle": "custom",
      "navigationBarTitleText": "登录"
    }
  },
  {
    "path": "pages/index/index",
    "style": {
      "navigationStyle": "custom",
      "navigationBarTitleText": "工作台"
    }
  }
]

保留其后既有 pages/demo/* 条目不变。

  • Step 2: Commit
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):

<!-- 在合适位置 -->
<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))。

  • Step 2: Commit
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 规范「移动工作台」节追加

- 登录页 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
)"

Task 8: 手测验收(HBuilderX / 微信开发者工具)

  • Step 1: 运行校验单测
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 → 后端换票与账号绑定
  • 《用户协议》《隐私政策》正文页