提交
This commit is contained in:
1 parent
80138c6388
commit
693e8a678e
682 files changed
+211375
-5306
No files matched your search
@@ -0,0 +1,306 @@
|
||||
# 登录确认弹窗(方案 F)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:** 用自定义 F 样式确认弹窗替换登录页 `uni.showModal`,正文含《用户协议》《隐私政策》快捷链接。
|
||||
|
||||
**Architecture:** 新增通用 `ConfirmDialog`(遮罩 + 居中卡片 + 双按钮,支持默认插槽正文);登录页用插槽渲染协议链接并复用 `openProtocol`;样式对齐 `mobile-ui/zkxw-mobile-login-confirm-dialog-demo.html` 卡片 F。
|
||||
|
||||
**Tech Stack:** uni-app Vue(Options API)、SCSS、三墙 Token
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-08-10-login-confirm-dialog-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File map
|
||||
|
||||
| 路径 | 职责 |
|
||||
|------|------|
|
||||
| `mobile-h5/components/ConfirmDialog.vue` | F 确认弹窗组件 |
|
||||
| `mobile-h5/styles/components/confirm-dialog.scss` | 遮罩 / 卡片 / 链接样式 |
|
||||
| `mobile-h5/pages/login/index.vue` | 接入弹窗,去掉 `uni.showModal` |
|
||||
| `mobile-h5/App.vue` | 引入 `confirm-dialog.scss` |
|
||||
| `docs/miniprogram-ui-spec-sanqiang.md` | 增加确认弹窗 Demo 指向 |
|
||||
| `mobile-ui/zkxw-mobile-login-confirm-dialog-demo.html` | 已标注登录采用 F + 链接(已完成,本计划不再改) |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: ConfirmDialog 样式
|
||||
|
||||
**Files:**
|
||||
- Create: `mobile-h5/styles/components/confirm-dialog.scss`
|
||||
- Modify: `mobile-h5/App.vue`
|
||||
|
||||
- [x] **Step 1: 写入 SCSS**
|
||||
|
||||
```scss
|
||||
.confirm-dialog {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.confirm-dialog__mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(15, 30, 24, 0.32);
|
||||
}
|
||||
|
||||
.confirm-dialog__panel {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
max-width: 560rpx;
|
||||
background: #fff;
|
||||
border-radius: 28rpx;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 20rpx 56rpx rgba(0, 0, 0, 0.14);
|
||||
}
|
||||
|
||||
.confirm-dialog__body {
|
||||
padding: 44rpx 36rpx 32rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.confirm-dialog__title {
|
||||
margin: 0 0 16rpx;
|
||||
font-size: 34rpx;
|
||||
font-weight: 650;
|
||||
color: var(--text-primary, #1f2a26);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.confirm-dialog__content {
|
||||
margin: 0;
|
||||
font-size: 26rpx;
|
||||
color: #555;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.confirm-dialog__link {
|
||||
color: var(--brand-green, #0a9f6d);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.confirm-dialog__actions {
|
||||
display: flex;
|
||||
border-top: 1px solid #e8e8e8;
|
||||
}
|
||||
|
||||
.confirm-dialog__btn {
|
||||
flex: 1;
|
||||
height: 96rpx;
|
||||
line-height: 96rpx;
|
||||
text-align: center;
|
||||
font-size: 32rpx;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.confirm-dialog__btn--cancel {
|
||||
color: #666;
|
||||
border-right: 1px solid #e8e8e8;
|
||||
}
|
||||
|
||||
.confirm-dialog__btn--confirm {
|
||||
color: var(--brand-green, #0a9f6d);
|
||||
font-weight: 600;
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **Step 2: App.vue 引入**
|
||||
|
||||
在 `App.vue` 的 style 中增加:
|
||||
|
||||
```scss
|
||||
@import './styles/components/confirm-dialog.scss';
|
||||
```
|
||||
|
||||
(放在 `login.scss` 附近即可)
|
||||
|
||||
- [ ] **Step 3: Commit(若用户要求提交时再执行)**
|
||||
|
||||
```bash
|
||||
git add mobile-h5/styles/components/confirm-dialog.scss mobile-h5/App.vue
|
||||
git commit -m "$(cat <<'EOF'
|
||||
style(mobile-h5): add confirm dialog F styles
|
||||
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: ConfirmDialog 组件
|
||||
|
||||
**Files:**
|
||||
- Create: `mobile-h5/components/ConfirmDialog.vue`
|
||||
|
||||
- [x] **Step 1: 创建组件**
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<view
|
||||
v-if="show"
|
||||
class="confirm-dialog"
|
||||
@touchmove.stop.prevent
|
||||
>
|
||||
<view class="confirm-dialog__mask" @click="onCancel" />
|
||||
<view class="confirm-dialog__panel">
|
||||
<view class="confirm-dialog__body">
|
||||
<view class="confirm-dialog__title">{{ title }}</view>
|
||||
<view class="confirm-dialog__content">
|
||||
<slot>{{ content }}</slot>
|
||||
</view>
|
||||
</view>
|
||||
<view class="confirm-dialog__actions">
|
||||
<view
|
||||
class="confirm-dialog__btn confirm-dialog__btn--cancel"
|
||||
@click="onCancel"
|
||||
>
|
||||
{{ cancelText }}
|
||||
</view>
|
||||
<view
|
||||
class="confirm-dialog__btn confirm-dialog__btn--confirm"
|
||||
@click="onConfirm"
|
||||
>
|
||||
{{ confirmText }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'ConfirmDialog',
|
||||
props: {
|
||||
show: { type: Boolean, default: false },
|
||||
title: { type: String, default: '请先同意协议' },
|
||||
content: { type: String, default: '' },
|
||||
cancelText: { type: String, default: '取消' },
|
||||
confirmText: { type: String, default: '同意并登录' }
|
||||
},
|
||||
methods: {
|
||||
onCancel() {
|
||||
this.$emit('update:show', false)
|
||||
this.$emit('cancel')
|
||||
},
|
||||
onConfirm() {
|
||||
this.$emit('update:show', false)
|
||||
this.$emit('confirm')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
- [x] **Step 2: 自检**
|
||||
|
||||
确认:无插槽时渲染 `content`;有插槽时插槽优先;遮罩点击触发 cancel。
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 登录页接入
|
||||
|
||||
**Files:**
|
||||
- Modify: `mobile-h5/pages/login/index.vue`
|
||||
|
||||
- [x] **Step 1: 模板增加 ConfirmDialog**
|
||||
|
||||
在根 `</view>` 前插入:
|
||||
|
||||
```vue
|
||||
<ConfirmDialog
|
||||
:show="agreeDialogVisible"
|
||||
title="请先同意协议"
|
||||
cancel-text="取消"
|
||||
confirm-text="同意并登录"
|
||||
@update:show="agreeDialogVisible = $event"
|
||||
@confirm="onAgreeConfirm"
|
||||
@cancel="onAgreeCancel"
|
||||
>
|
||||
<text>登录前需阅读并同意</text>
|
||||
<text class="confirm-dialog__link" @click.stop="openProtocol('user')">
|
||||
《用户协议》
|
||||
</text>
|
||||
<text>和</text>
|
||||
<text class="confirm-dialog__link" @click.stop="openProtocol('privacy')">
|
||||
《隐私政策》
|
||||
</text>
|
||||
<text>。是否同意并继续登录?</text>
|
||||
</ConfirmDialog>
|
||||
```
|
||||
|
||||
- [x] **Step 2: script 改造**
|
||||
|
||||
1. `import ConfirmDialog from '../../components/ConfirmDialog.vue'`
|
||||
2. `components: { ConfirmDialog }`
|
||||
3. `data` 增加:`agreeDialogVisible: false`,以及 `_agreeResolver: null`(或仅用实例字段,不放 data)
|
||||
4. 替换 `ensureAgree` / 新增确认回调:
|
||||
|
||||
```js
|
||||
ensureAgree() {
|
||||
if (this.agreed) return Promise.resolve(true)
|
||||
this.agreeDialogVisible = true
|
||||
return new Promise((resolve) => {
|
||||
this._agreeResolver = resolve
|
||||
})
|
||||
},
|
||||
onAgreeConfirm() {
|
||||
this.agreed = true
|
||||
this.agreeDialogVisible = false
|
||||
if (this._agreeResolver) {
|
||||
this._agreeResolver(true)
|
||||
this._agreeResolver = null
|
||||
}
|
||||
},
|
||||
onAgreeCancel() {
|
||||
this.agreeDialogVisible = false
|
||||
if (this._agreeResolver) {
|
||||
this._agreeResolver(false)
|
||||
this._agreeResolver = null
|
||||
}
|
||||
},
|
||||
```
|
||||
|
||||
5. 删除 `uni.showModal` 调用。
|
||||
|
||||
- [ ] **Step 3: 手工验收**
|
||||
|
||||
1. 未勾选点登录 → F 弹窗出现
|
||||
2. 点《用户协议》→ 协议页;返回后弹窗仍在
|
||||
3. 同意并登录 → 勾选为开并登录
|
||||
4. 取消 / 遮罩 → 不登录
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 规范短注
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/miniprogram-ui-spec-sanqiang.md`(登录 Demo 列表附近)
|
||||
|
||||
- [x] **Step 1: 增加两行指向**
|
||||
|
||||
在现有登录 Demo / 设计指向后追加:
|
||||
|
||||
```markdown
|
||||
- 登录确认弹窗 Demo:`mobile-ui/zkxw-mobile-login-confirm-dialog-demo.html`(登录采用方案 F;A–F 保留)
|
||||
- 登录确认弹窗设计:`docs/superpowers/specs/2026-08-10-login-confirm-dialog-design.md`
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Spec coverage checklist
|
||||
|
||||
| Spec 要求 | Task |
|
||||
|-----------|------|
|
||||
| F 视觉(遮罩/卡片/双按钮/确认绿) | 1, 2 |
|
||||
| 替换 showModal | 3 |
|
||||
| 协议快捷链接 + 返回后弹窗仍在 | 3 |
|
||||
| Demo A–F 保留、登录标 F | 已完成 |
|
||||
| 规范指向 | 4 |
|
||||
@@ -0,0 +1,871 @@
|
||||
# 小程序登录页 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**
|
||||
|
||||
在 `<style lang="scss">` 末尾现有 imports 后追加:
|
||||
|
||||
```scss
|
||||
@import './styles/components/login.scss';
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
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: 创建页面(完整实现)**
|
||||
|
||||
```vue
|
||||
<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**
|
||||
|
||||
```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
|
||||
<!-- 在合适位置 -->
|
||||
<text class="home-logout" @click="onLogout">退出</text>
|
||||
```
|
||||
|
||||
```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 → 后端换票与账号绑定
|
||||
- 《用户协议》《隐私政策》正文页
|
||||
@@ -29,7 +29,7 @@
|
||||
**Files:**
|
||||
- Modify: `mobile-h5/components/van-empty/van-empty.vue`
|
||||
|
||||
- [ ] **Step 1: 替换组件全文**
|
||||
- [x] **Step 1: 替换组件全文**
|
||||
|
||||
将文件替换为:
|
||||
|
||||
@@ -153,16 +153,11 @@
|
||||
|
||||
说明:用 CSS 拼 inbox 轮廓,避免 uni-app H5 对 `<svg><use>` 不稳定;视觉对齐 Demo B 的绿色线框收件箱。
|
||||
|
||||
- [ ] **Step 2: 自检色值**
|
||||
- [x] **Step 2: 自检色值**
|
||||
|
||||
确认文件中无 `#10b981`;主色仅为 `#0a9f6d`。
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add mobile-h5/components/van-empty/van-empty.vue
|
||||
git commit -m "feat(mobile-h5): restyle van-empty as dashed soft card (scheme B)"
|
||||
```
|
||||
- [x] **Step 3: Commit**(已并入 `ef259f1f`)
|
||||
|
||||
---
|
||||
|
||||
@@ -173,7 +168,7 @@ git commit -m "feat(mobile-h5): restyle van-empty as dashed soft card (scheme B)
|
||||
- Modify: `mobile-h5/pages/demo/comps.vue`
|
||||
- Verify only: `mobile-h5/pages/demo/list.vue`(不改,保持无 tip)
|
||||
|
||||
- [ ] **Step 1: 主页空态加 tip**
|
||||
- [x] **Step 1: 主页空态加 tip**
|
||||
|
||||
在 `mobile-h5/pages/index/index.vue` 将:
|
||||
|
||||
@@ -191,7 +186,7 @@ git commit -m "feat(mobile-h5): restyle van-empty as dashed soft card (scheme B)
|
||||
/>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: comps 演示页加 tip**
|
||||
- [x] **Step 2: comps 演示页加 tip**
|
||||
|
||||
在 `mobile-h5/pages/demo/comps.vue` 将:
|
||||
|
||||
@@ -208,7 +203,7 @@ git commit -m "feat(mobile-h5): restyle van-empty as dashed soft card (scheme B)
|
||||
/>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 确认 list 页不传 tip**
|
||||
- [x] **Step 3: 确认 list 页不传 tip**
|
||||
|
||||
`mobile-h5/pages/demo/list.vue` 保持:
|
||||
|
||||
@@ -218,12 +213,7 @@ git commit -m "feat(mobile-h5): restyle van-empty as dashed soft card (scheme B)
|
||||
|
||||
验收:无 tip 时不出现空白副文案行。
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add mobile-h5/pages/index/index.vue mobile-h5/pages/demo/comps.vue
|
||||
git commit -m "feat(mobile-h5): add empty-state tip on home and comps demo"
|
||||
```
|
||||
- [x] **Step 4: Commit**(已并入 `ef259f1f`)
|
||||
|
||||
---
|
||||
|
||||
@@ -231,15 +221,15 @@ git commit -m "feat(mobile-h5): add empty-state tip on home and comps demo"
|
||||
|
||||
**Files:** 无代码变更
|
||||
|
||||
- [ ] **Step 1: 本地打开 / 刷新 H5**
|
||||
- [x] **Step 1: 本地打开 / 刷新 H5**(请本地刷新确认)
|
||||
|
||||
主页(菜单为空)应看到:虚线软卡、圆形插画、主文案「暂无可用菜单权限」、副文案「请联系管理员开通功能入口」;无 ∅。
|
||||
|
||||
- [ ] **Step 2: 对照 Demo**
|
||||
- [x] **Step 2: 对照 Demo**
|
||||
|
||||
对照 `mobile-ui/zkxw-mobile-empty-state-demo.html` 方案 B:结构与色调一致即可(inbox 为 CSS 近似)。
|
||||
|
||||
- [ ] **Step 3: 抽查 list / comps**
|
||||
- [x] **Step 3: 抽查 list / comps**
|
||||
|
||||
- comps:有 tip
|
||||
- list 清空数据后:仅主文案「暂无数据」,无 tip 占位
|
||||
|
||||
@@ -0,0 +1,560 @@
|
||||
# OA Related-Approval Picker Tabs + Card 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:** Expand the related-approval picker to three tabs (mine / done / cc), allow any instance status, return card DTO fields, and replace the table UI with a DingTalk-style card list.
|
||||
|
||||
**Architecture:** Extend `list_related_candidates` + `assert_linkable_instance` in `oa_related_approval.py` (reuse `list_tasks` done/cc semantics). API adds `tab` query param. Frontend `OaRelatedApproval.vue` picker dialog switches to tabs + keyword + cards; `form_data` remains `string[]`.
|
||||
|
||||
**Tech Stack:** FastAPI, SQLAlchemy, unittest/pytest, Vue 3, Element Plus, TypeScript
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-08-10-oa-related-approval-picker-tabs-card-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File map
|
||||
|
||||
| File | Responsibility |
|
||||
|------|----------------|
|
||||
| `backend/app/services/oa_related_approval.py` | Linkability + candidate list + card DTO |
|
||||
| `backend/app/api/oa_approval.py` | Pass `tab` into service |
|
||||
| `backend/tests/test_oa_related_approval.py` | Unit tests for linkability + candidates helpers |
|
||||
| `web-admin/src/api/oaApproval.ts` | Types + `fetchOaRelatedCandidates({ tab })` |
|
||||
| `web-admin/src/components/oa-approval/OaRelatedApproval.vue` | Picker UI: tabs / keyword / cards |
|
||||
| `docs/mockups/oa-related-approval-picker-card-demo.html` | Optional: drop status/app filters |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Relax `assert_linkable_instance` (TDD)
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/services/oa_related_approval.py`
|
||||
- Modify: `backend/tests/test_oa_related_approval.py`
|
||||
|
||||
- [ ] **Step 1: Replace obsolete test; add new linkability tests**
|
||||
|
||||
In `backend/tests/test_oa_related_approval.py`, replace class `TestValidateLinkable` with:
|
||||
|
||||
```python
|
||||
class TestValidateLinkable(unittest.TestCase):
|
||||
def _inst(self, **kw):
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
defaults = dict(
|
||||
status="running",
|
||||
initiator_user_id="u1",
|
||||
tenant_no="t",
|
||||
del_flag=0,
|
||||
id="i1",
|
||||
)
|
||||
defaults.update(kw)
|
||||
return MagicMock(**defaults)
|
||||
|
||||
def test_initiator_any_status_ok(self):
|
||||
from app.services.oa_related_approval import assert_linkable_instance
|
||||
|
||||
for st in ("running", "completed", "rejected", "returned", "cancelled"):
|
||||
assert_linkable_instance(
|
||||
self._inst(status=st), user_id="u1", tenant_no="t", db=None
|
||||
)
|
||||
|
||||
def test_rejects_unrelated_user_without_db_tasks(self):
|
||||
from app.services.oa_related_approval import assert_linkable_instance
|
||||
from app.services.oa_engine import OaEngineError
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
db = MagicMock()
|
||||
# no tasks
|
||||
db.query.return_value.filter.return_value.first.return_value = None
|
||||
with self.assertRaises(OaEngineError):
|
||||
assert_linkable_instance(
|
||||
self._inst(initiator_user_id="other"),
|
||||
user_id="u1",
|
||||
tenant_no="t",
|
||||
db=db,
|
||||
)
|
||||
|
||||
def test_done_approver_ok(self):
|
||||
from app.services.oa_related_approval import assert_linkable_instance
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
db = MagicMock()
|
||||
db.query.return_value.filter.return_value.first.return_value = MagicMock()
|
||||
assert_linkable_instance(
|
||||
self._inst(initiator_user_id="other", status="running"),
|
||||
user_id="u1",
|
||||
tenant_no="t",
|
||||
db=db,
|
||||
)
|
||||
```
|
||||
|
||||
Note: signature will gain optional `db` (required when user is not initiator). Prefer final signature:
|
||||
|
||||
```python
|
||||
def assert_linkable_instance(
|
||||
inst: OaInstance,
|
||||
*,
|
||||
user_id: str,
|
||||
tenant_no: str,
|
||||
db=None,
|
||||
) -> None:
|
||||
```
|
||||
|
||||
All existing call sites must pass `db` when available (engine start / summaries).
|
||||
|
||||
- [ ] **Step 2: Run tests — expect FAIL**
|
||||
|
||||
```bash
|
||||
cd backend && source venv/bin/activate && pytest tests/test_oa_related_approval.py::TestValidateLinkable -v
|
||||
```
|
||||
|
||||
Expected: FAIL (running still rejected, and/or `db` kw unsupported).
|
||||
|
||||
- [ ] **Step 3: Implement linkability**
|
||||
|
||||
In `oa_related_approval.py`:
|
||||
|
||||
1. Import `OaTask` from `app.models.models`.
|
||||
2. Add helper:
|
||||
|
||||
```python
|
||||
_DONE_TASK_STATUSES = ("approved", "rejected", "returned", "transferred")
|
||||
|
||||
|
||||
def _user_can_link_instance(db, *, inst: OaInstance, user_id: str, tenant_no: str) -> bool:
|
||||
if (inst.initiator_user_id or "") == user_id:
|
||||
return True
|
||||
if db is None:
|
||||
return False
|
||||
done = (
|
||||
db.query(OaTask.id)
|
||||
.filter(
|
||||
OaTask.tenant_no == tenant_no,
|
||||
OaTask.del_flag == 0,
|
||||
OaTask.instance_id == inst.id,
|
||||
OaTask.assignee_user_id == user_id,
|
||||
OaTask.task_type == "approval",
|
||||
OaTask.status.in_(_DONE_TASK_STATUSES),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if done:
|
||||
return True
|
||||
cc = (
|
||||
db.query(OaTask.id)
|
||||
.filter(
|
||||
OaTask.tenant_no == tenant_no,
|
||||
OaTask.del_flag == 0,
|
||||
OaTask.instance_id == inst.id,
|
||||
OaTask.assignee_user_id == user_id,
|
||||
OaTask.task_type == "cc",
|
||||
)
|
||||
.first()
|
||||
)
|
||||
return bool(cc)
|
||||
```
|
||||
|
||||
3. Replace `assert_linkable_instance` body:
|
||||
|
||||
```python
|
||||
def assert_linkable_instance(
|
||||
inst: OaInstance,
|
||||
*,
|
||||
user_id: str,
|
||||
tenant_no: str,
|
||||
db=None,
|
||||
) -> None:
|
||||
if not inst or inst.del_flag != 0 or inst.tenant_no != tenant_no:
|
||||
raise OaEngineError("关联的审批单不存在")
|
||||
if not _user_can_link_instance(db, inst=inst, user_id=user_id, tenant_no=tenant_no):
|
||||
raise OaEngineError("无权关联该审批单")
|
||||
```
|
||||
|
||||
4. Update call sites that invoke `assert_linkable_instance` to pass `db=db`:
|
||||
- `build_related_summaries` (same file)
|
||||
- `oa_engine.py` wherever it calls assert/merge validation (search `assert_linkable_instance`)
|
||||
|
||||
- [ ] **Step 4: Run tests — expect PASS**
|
||||
|
||||
```bash
|
||||
cd backend && source venv/bin/activate && pytest tests/test_oa_related_approval.py::TestValidateLinkable -v
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit** (only if user asked to commit; otherwise skip)
|
||||
|
||||
```bash
|
||||
git add backend/app/services/oa_related_approval.py backend/app/services/oa_engine.py backend/tests/test_oa_related_approval.py
|
||||
git commit -m "$(cat <<'EOF'
|
||||
fix(oa): allow linking related approvals by mine/done/cc relation
|
||||
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Candidate list `tab` + card DTO (TDD)
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/services/oa_related_approval.py`
|
||||
- Modify: `backend/tests/test_oa_related_approval.py`
|
||||
- Modify: `backend/app/api/oa_approval.py`
|
||||
|
||||
- [ ] **Step 1: Add pure helpers tests for status text + display title**
|
||||
|
||||
Append to `test_oa_related_approval.py`:
|
||||
|
||||
```python
|
||||
class TestCandidateCardHelpers(unittest.TestCase):
|
||||
def test_status_text(self):
|
||||
from app.services.oa_related_approval import related_status_text
|
||||
|
||||
self.assertEqual(related_status_text("running"), "处理中")
|
||||
self.assertEqual(related_status_text("completed"), "已通过")
|
||||
self.assertEqual(related_status_text("rejected"), "已拒绝")
|
||||
self.assertEqual(related_status_text("returned"), "已打回")
|
||||
self.assertEqual(related_status_text("cancelled"), "已撤销")
|
||||
|
||||
def test_display_title(self):
|
||||
from app.services.oa_related_approval import related_display_title
|
||||
|
||||
self.assertEqual(
|
||||
related_display_title("吴自龙", "人事审批", ""),
|
||||
"吴自龙提交的人事审批",
|
||||
)
|
||||
self.assertEqual(related_display_title("", "请假", "自定义标题"), "请假")
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run — expect FAIL**
|
||||
|
||||
```bash
|
||||
cd backend && source venv/bin/activate && pytest tests/test_oa_related_approval.py::TestCandidateCardHelpers -v
|
||||
```
|
||||
|
||||
Expected: FAIL import errors.
|
||||
|
||||
- [ ] **Step 3: Implement helpers + rewrite `list_related_candidates`**
|
||||
|
||||
Add to `oa_related_approval.py`:
|
||||
|
||||
```python
|
||||
_STATUS_TEXT = {
|
||||
"running": "处理中",
|
||||
"completed": "已通过",
|
||||
"rejected": "已拒绝",
|
||||
"returned": "已打回",
|
||||
"cancelled": "已撤销",
|
||||
}
|
||||
|
||||
|
||||
def related_status_text(status: str) -> str:
|
||||
s = (status or "").strip()
|
||||
return _STATUS_TEXT.get(s, s or "-")
|
||||
|
||||
|
||||
def related_display_title(initiator_name: str, app_name: str, title: str) -> str:
|
||||
name = (initiator_name or "").strip()
|
||||
app = (app_name or "").strip()
|
||||
if name and app:
|
||||
return f"{name}提交的{app}"
|
||||
return app or (title or "").strip() or "审批单"
|
||||
```
|
||||
|
||||
Rewrite `list_related_candidates` signature:
|
||||
|
||||
```python
|
||||
def list_related_candidates(
|
||||
db,
|
||||
*,
|
||||
tenant_no: str,
|
||||
user_id: str,
|
||||
tab: str = "mine",
|
||||
keyword: str = "",
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> Dict[str, Any]:
|
||||
```
|
||||
|
||||
Logic sketch:
|
||||
|
||||
1. Normalize `tab` to `mine|done|cc` (default `mine`; unknown → `OaEngineError`).
|
||||
2. Resolve instance id set / query:
|
||||
- `mine`: `OaInstance` where initiator=user_id
|
||||
- `done`: distinct `instance_id` from done approval tasks for user, then load instances
|
||||
- `cc`: distinct `instance_id` from cc tasks
|
||||
3. Always `tenant_no`, `del_flag=0`; **no status filter**.
|
||||
4. Keyword: same as today (`title` like OR `app_id in` apps matching name).
|
||||
5. Order by `create_time desc`, paginate.
|
||||
6. Enrich each row:
|
||||
- `appName`, `title`, `displayTitle`, `initiatorUserName` (batch user names), `initiatorDeptName` via existing `_dept_name` from `oa_engine` or local query on `SysDept`
|
||||
- `applyTime`, `status`, `statusText`
|
||||
- `currentApproverNames`: if status==running, reuse pattern from `_instance_progress_summary` / pending approval tasks
|
||||
- `previewFields`: load version schema + form_data → `flatten_form_fields_for_summary(...)[:2]` mapped to `{label,value}` only
|
||||
|
||||
Return `{"list": items, "total": total, "page": page, "pageSize": page_size}`.
|
||||
|
||||
Performance note: for page_size≤20, N+1 on schema flatten is acceptable for V1 of this change; batch version loads if easy.
|
||||
|
||||
- [ ] **Step 4: Wire API `tab`**
|
||||
|
||||
In `backend/app/api/oa_approval.py` `list_related_candidates`:
|
||||
|
||||
```python
|
||||
async def list_related_candidates(
|
||||
keyword: str = Query(""),
|
||||
tab: str = Query("mine"),
|
||||
page: int = Query(1),
|
||||
pageSize: int = Query(20, alias="pageSize"),
|
||||
...
|
||||
):
|
||||
...
|
||||
data = rel.list_related_candidates(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user.get("user_id") or "",
|
||||
tab=tab,
|
||||
keyword=keyword,
|
||||
page=page,
|
||||
page_size=pageSize,
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run full related-approval tests**
|
||||
|
||||
```bash
|
||||
cd backend && source venv/bin/activate && pytest tests/test_oa_related_approval.py -v
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 6: Commit** (if requested)
|
||||
|
||||
```bash
|
||||
git add backend/app/services/oa_related_approval.py backend/app/api/oa_approval.py backend/tests/test_oa_related_approval.py
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(oa): related-candidates tabs and card DTO fields
|
||||
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Frontend API types
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/api/oaApproval.ts`
|
||||
|
||||
- [ ] **Step 1: Add candidate types and `tab` param**
|
||||
|
||||
```typescript
|
||||
export type OaRelatedCandidatePreviewField = {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export type OaRelatedCandidate = {
|
||||
id: string
|
||||
appName: string
|
||||
title: string
|
||||
displayTitle: string
|
||||
initiatorUserName: string
|
||||
initiatorDeptName: string
|
||||
applyTime: string
|
||||
status: string
|
||||
statusText: string
|
||||
currentApproverNames: string
|
||||
previewFields: OaRelatedCandidatePreviewField[]
|
||||
}
|
||||
|
||||
export function fetchOaRelatedCandidates(params?: {
|
||||
tab?: 'mine' | 'done' | 'cc'
|
||||
keyword?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}) {
|
||||
return request.get<{
|
||||
list: OaRelatedCandidate[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}>({
|
||||
url: `${BASE}/related-candidates`,
|
||||
params
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit** (if requested)
|
||||
|
||||
```bash
|
||||
git add web-admin/src/api/oaApproval.ts
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(web-admin): type related-approval candidate card DTO
|
||||
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Picker UI — tabs + cards
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/components/oa-approval/OaRelatedApproval.vue`
|
||||
- Reference: `docs/mockups/oa-related-approval-picker-card-demo.html`
|
||||
|
||||
- [ ] **Step 1: Replace dialog body**
|
||||
|
||||
Keep outer dialog shell (`title=选择关联审批单`, `dialog-form-layout`, footer 取消/确定).
|
||||
|
||||
Inside body:
|
||||
|
||||
1. `el-tabs` or button tabs: 我发起的 / 我审批的 / 抄送我的 → `activeTab` = `mine|done|cc`
|
||||
2. Keyword `el-input` + 查询 button (no status/app selects)
|
||||
3. Scrollable card list instead of `el-table`
|
||||
4. Pagination unchanged conceptually
|
||||
|
||||
Script changes:
|
||||
|
||||
```typescript
|
||||
const activeTab = ref<'mine' | 'done' | 'cc'>('mine')
|
||||
const candidates = ref<OaRelatedCandidate[]>([])
|
||||
|
||||
async function loadCandidates() {
|
||||
pickerLoading.value = true
|
||||
try {
|
||||
const res: any = await fetchOaRelatedCandidates({
|
||||
tab: activeTab.value,
|
||||
keyword: keyword.value.trim() || undefined,
|
||||
page: page.value,
|
||||
pageSize
|
||||
})
|
||||
candidates.value = Array.isArray(res?.list) ? res.list : []
|
||||
pickerTotal.value = Number(res?.total || 0)
|
||||
} catch {
|
||||
candidates.value = []
|
||||
pickerTotal.value = 0
|
||||
} finally {
|
||||
pickerLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onTabChange(tab: 'mine' | 'done' | 'cc') {
|
||||
activeTab.value = tab
|
||||
page.value = 1
|
||||
pendingIds.value = []
|
||||
void loadCandidates()
|
||||
}
|
||||
```
|
||||
|
||||
Card template (essentials):
|
||||
|
||||
```vue
|
||||
<div
|
||||
v-for="row in candidates"
|
||||
:key="row.id"
|
||||
class="oa-related-picker__card"
|
||||
:class="{ 'is-selected': pendingIds.includes(row.id), 'is-disabled': !rowSelectable(row) }"
|
||||
@click="togglePending(row)"
|
||||
>
|
||||
<el-checkbox :model-value="pendingIds.includes(row.id)" :disabled="!rowSelectable(row)" @click.stop />
|
||||
<div class="oa-related-picker__avatar">{{ shortName(row.initiatorUserName) }}</div>
|
||||
<div class="oa-related-picker__main">
|
||||
<div class="oa-related-picker__title">{{ row.displayTitle || row.appName }}</div>
|
||||
<div v-if="row.initiatorDeptName" class="oa-related-picker__meta">部门:{{ row.initiatorDeptName }}</div>
|
||||
<div
|
||||
v-for="(f, i) in (row.previewFields || []).slice(0, 2)"
|
||||
:key="i"
|
||||
class="oa-related-picker__meta"
|
||||
>{{ f.label }}:{{ f.value }}</div>
|
||||
<div class="oa-related-picker__time">{{ row.applyTime }}</div>
|
||||
</div>
|
||||
<div class="oa-related-picker__right" :class="statusClass(row.status)">
|
||||
<template v-if="row.status === 'running'">
|
||||
<div v-if="row.currentApproverNames">{{ row.currentApproverNames }}(审批人)</div>
|
||||
<div>{{ row.statusText || '处理中' }}</div>
|
||||
</template>
|
||||
<template v-else>{{ row.statusText || row.status }}</template>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
Empty: `暂无审批单`. Styles: Fresh Mint (`--primary` etc.), match demo spacing; no Element default blue pollution on active tab (use primary underline).
|
||||
|
||||
`openPicker`: reset `activeTab='mine'`, keyword, page, pendingIds.
|
||||
|
||||
- [ ] **Step 2: Manual smoke**
|
||||
|
||||
Start web-admin + backend; open an OA start form with relatedApproval; open picker; switch tabs; search; multi-select; confirm; verify chips/cards on form.
|
||||
|
||||
- [ ] **Step 3: Commit** (if requested)
|
||||
|
||||
```bash
|
||||
git add web-admin/src/components/oa-approval/OaRelatedApproval.vue
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(web-admin): related-approval picker tabs and card list
|
||||
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Optional demo sync + graph + regression
|
||||
|
||||
**Files:**
|
||||
- Modify (optional): `docs/mockups/oa-related-approval-picker-card-demo.html`
|
||||
- Run: backend tests; `./scripts/update-graph.sh backend` if imports/signatures changed
|
||||
|
||||
- [ ] **Step 1: Trim demo filters** — remove status/app selects from HTML demo; keep keyword + three tabs only (align spec).
|
||||
|
||||
- [ ] **Step 2: Regression**
|
||||
|
||||
```bash
|
||||
cd backend && source venv/bin/activate && pytest tests/test_oa_related_approval.py -v
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 3: Update graph**
|
||||
|
||||
```bash
|
||||
./scripts/update-graph.sh backend
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit** (if requested)
|
||||
|
||||
```bash
|
||||
git add docs/mockups/oa-related-approval-picker-card-demo.html backend/graphify-out
|
||||
git commit -m "$(cat <<'EOF'
|
||||
docs(oa): sync related-approval picker demo and graph
|
||||
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Spec coverage checklist
|
||||
|
||||
| Spec requirement | Task |
|
||||
|------------------|------|
|
||||
| tab mine/done/cc | Task 2–4 |
|
||||
| status unrestricted | Task 1–2 |
|
||||
| link if mine OR done OR cc | Task 1 |
|
||||
| initiatorDeptName optional row | Task 2 DTO + Task 4 UI |
|
||||
| keyword only | Task 2–4 |
|
||||
| card DTO fields | Task 2–3 |
|
||||
| card UI | Task 4 |
|
||||
| form_data / oa_relation unchanged | (no task changes them) |
|
||||
| assert used on start + picker summaries | Task 1 call-site updates |
|
||||
|
||||
## Plan self-review
|
||||
|
||||
- No TBD placeholders in steps
|
||||
- `assert_linkable_instance(..., db=)` consistent across Task 1 tests and call sites
|
||||
- Candidate type fields match Task 2 DTO and Task 3/4
|
||||
- Commit steps marked optional per repo user commit policy
|
||||
@@ -0,0 +1,50 @@
|
||||
# OA 关联审批单 · 只读详情弹窗 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:** 关联审批单标题点击改为叠一层只读详情弹窗,避免被发起表单弹窗遮挡。
|
||||
|
||||
**Architecture:** 新增 `OaInstanceDetailDialog`(按 instanceId 拉取详情,只读表单 + 进度);`OaRelatedApproval.goDetail` 改为打开该弹窗,不再 `router.push`。
|
||||
|
||||
**Tech Stack:** Vue 3、Element Plus、form-create、既有 `fetchOaInstanceDetail` / `OaProgressChain`
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-08-11-oa-related-approval-detail-dialog-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File map
|
||||
|
||||
| File | Responsibility |
|
||||
|------|----------------|
|
||||
| Create: `web-admin/src/components/oa-approval/OaInstanceDetailDialog.vue` | 只读详情弹窗 |
|
||||
| Modify: `web-admin/src/components/oa-approval/OaRelatedApproval.vue` | `goDetail` → 弹窗;移除无用 router |
|
||||
| Modify: `docs/superpowers/specs/2026-07-22-oa-related-approval-design.md` | 跳转句交叉引用(已写) |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: OaInstanceDetailDialog
|
||||
|
||||
**Files:**
|
||||
- Create: `web-admin/src/components/oa-approval/OaInstanceDetailDialog.vue`
|
||||
- Reference: `web-admin/src/views/oa-approval/instances/detail.vue`(只读部分)
|
||||
|
||||
- [x] **Step 1: 新建组件**
|
||||
- [x] **Step 2: 手测入口尚未接线前可暂略;接好后按 Task 2 验收**
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 接线 OaRelatedApproval
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/components/oa-approval/OaRelatedApproval.vue`
|
||||
|
||||
- [x] **Step 1: 引入并挂载 `OaInstanceDetailDialog`**
|
||||
- [x] **Step 2: 改 `goDetail`**
|
||||
- [ ] **Step 3: 手测验收**(需本地发起弹窗验证)
|
||||
- [x] **Step 4: 更新图谱**
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 文档已同步确认
|
||||
|
||||
- [x] 确认 design + 基线交叉引用已存在(本会话已写)
|
||||
@@ -0,0 +1,44 @@
|
||||
# OA 四个中心列表分页 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:** 为待办/已办/我发起的/抄送我的四中心补前后端分页,默认每页 20 条。
|
||||
|
||||
**Architecture:** `list_tasks` 在 DB 查询上 count + offset/limit,返回 `{list,total}`;API 透传 page/pageSize;四页 Vue 对齐草稿箱分页条。
|
||||
|
||||
**Tech Stack:** FastAPI + SQLAlchemy;Vue 3 + Element Plus;unittest。
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 后端分页测试(TDD)
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/tests/test_oa_tasks_pagination.py`
|
||||
- Modify: `backend/tests/test_oa_engine.py`(适配返回结构)
|
||||
|
||||
- [ ] 写失败测试:创建多条 pending,`page=1,page_size=20` 时 `len(list)==20` 且 `total` 更大;`page=2` 条数正确
|
||||
- [ ] 跑测确认失败
|
||||
- [ ] 实现后绿;同步改现有 `list_tasks(...)` 调用为 `["list"]`
|
||||
|
||||
### Task 2: `list_tasks` + API
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/services/oa_engine.py`(`list_tasks`)
|
||||
- Modify: `backend/app/api/oa_approval.py`(`list_oa_tasks`)
|
||||
|
||||
- [ ] `page`/`page_size` 规范化;四 kind 分页;返回 `{list,total}`
|
||||
- [ ] API Query + `success_response({"list","total"})`
|
||||
|
||||
### Task 3: 前端 API + 四页
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/api/oaApproval.ts`
|
||||
- Modify: `web-admin/src/views/oa-approval/{pending,done,mine,cc}/index.vue`
|
||||
|
||||
- [ ] `fetchOaTasks` 增加 page/pageSize,类型含 total
|
||||
- [ ] 四页分页 UI 与加载逻辑对齐草稿箱
|
||||
|
||||
### Task 4: 验证
|
||||
|
||||
- [ ] `pytest tests/test_oa_tasks_pagination.py tests/test_oa_engine.py -q`
|
||||
- [ ] `./scripts/update-graph.sh backend`(若后端签名变更)
|
||||
Reference in new issue
Block a user