提交
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`(若后端签名变更)
|
||||
@@ -172,7 +172,7 @@ V1 **不**改详情主接口形状:前端根据 form_data / `relatedInstanceId
|
||||
- **Web**: `display:flex; flex-wrap` 标签流,尽量一行多字段
|
||||
- **移动 / 窄屏**: 每字段一行(`label:value`)
|
||||
|
||||
点击应用名(或行标题)→ 路由至现有实例详情页;权限不足时提示,不白屏。
|
||||
点击应用名(或行标题)→ 打开只读详情弹窗(盖在当前发起/详情等界面之上,不关父弹窗);权限不足时提示,不白屏。细则见 `2026-08-11-oa-related-approval-detail-dialog-design.md`。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
# 登录确认弹窗(方案 F)设计
|
||||
|
||||
> 日期:2026-08-10
|
||||
> 适用范围:`mobile-h5` 员工登录页协议门禁确认
|
||||
> 视觉 Demo:`mobile-ui/zkxw-mobile-login-confirm-dialog-demo.html`(**登录采用 F**;A–F 全部保留)
|
||||
> 品牌 Token:对齐 `docs/miniprogram-ui-spec-sanqiang.md` §2(**非** web-admin Fresh Mint)
|
||||
> 关联:`docs/superpowers/specs/2026-08-10-miniprogram-login-ui-design.md`
|
||||
|
||||
---
|
||||
|
||||
## 1. 目标与约束
|
||||
|
||||
### 1.1 目标
|
||||
|
||||
1. 用自定义确认弹窗替换登录页 `uni.showModal`,视觉对齐 Demo **F(精简系统风)**。
|
||||
2. 未勾选协议时,账号登录与微信授权登录均先弹出该确认窗。
|
||||
3. `mobile-ui` 中 A–F 六套样例**全部保留**,作为后续其它场景弹窗选型资产;**本次登录仅落地 F**。
|
||||
|
||||
### 1.2 已确认选型
|
||||
|
||||
| 项 | 约定 |
|
||||
|----|------|
|
||||
| 登录确认样式 | **F · 精简系统风** |
|
||||
| 样例资产 | A–F 均保留在 `zkxw-mobile-login-confirm-dialog-demo.html` |
|
||||
| 协议正文链接 | 正文中《用户协议》《隐私政策》为**快捷链接**(品牌绿色),点击跳转对应协议页;登录页勾选行链接保留 |
|
||||
|
||||
### 1.3 硬约束
|
||||
|
||||
| 项 | 约定 |
|
||||
|----|------|
|
||||
| 主色确认按钮字色 | `#0a9f6d` |
|
||||
| 禁止 | 使用 web-admin `#10b981`;不引入 Vant Dialog |
|
||||
| 遮罩 | 半透明深色,**无** `backdrop-filter` 模糊 |
|
||||
| 触达 | 底栏按钮高度建议 ≥ 44px |
|
||||
|
||||
### 1.4 非目标
|
||||
|
||||
- 不实现 A/B/C/D/E 其它样式变体(仅 Demo 保留)
|
||||
- 不改 mock 鉴权、协议页内容、登录表单布局
|
||||
- 本期不做成全局命令式 API(如 `showConfirm()` 挂载到 uni);登录页内组件即可
|
||||
|
||||
---
|
||||
|
||||
## 2. 视觉规格(F)
|
||||
|
||||
权威预览:Demo 卡片 **F**。
|
||||
|
||||
| 元素 | 规格 |
|
||||
|------|------|
|
||||
| 遮罩 | `rgba(15, 30, 24, 0.32)` 全屏;点击关闭(等同取消) |
|
||||
| 卡片 | 居中;白底;圆角 **14px**;轻阴影 |
|
||||
| 标题 | 「请先同意协议」;约 17px;加粗;居中;色 `#1f2a26` |
|
||||
| 正文 | 「登录前需阅读并同意《用户协议》和《隐私政策》。是否同意并继续登录?」;约 13px;色约 `#555` / muted;居中;行高 1.5 |
|
||||
| 协议链接 | 《用户协议》《隐私政策》字色 `#0a9f6d`;可点击;与登录页勾选行一致跳转 `/pages/protocol/index?type=user|privacy` |
|
||||
| 分割线 | 标题区与按钮区之间 `1px` `#e8e8e8` |
|
||||
| 按钮区 | 左右等分;中间竖分割线 |
|
||||
| 取消 | 文案「取消」;字色 `#666`;透明底 |
|
||||
| 确认 | 文案「同意并登录」;字色 `#0a9f6d`;字重 600;透明底 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 交互
|
||||
|
||||
1. 用户未勾选协议且触发「登录」或「微信授权登录」→ 打开确认弹窗(不进入登录流程)。
|
||||
2. 点「同意并登录」→ 将 `agreed` 设为 `true` → 关闭弹窗 → 继续原登录流程。
|
||||
3. 点「取消」或点遮罩 → 关闭弹窗 → `agreed` 不变 → 中止本次登录。
|
||||
4. 已勾选协议时 → **不**弹窗,直接登录。
|
||||
5. 弹窗打开时页面禁止底层滚动穿透(`catchtouchmove` 或等价处理)。
|
||||
6. 点正文中《用户协议》/《隐私政策》→ `navigateTo` 协议页;**不关闭**确认弹窗(返回登录页后弹窗仍在,可继续同意或取消);点击链接须 `@click.stop`,避免误触遮罩关闭。
|
||||
|
||||
---
|
||||
|
||||
## 4. 实现结构
|
||||
|
||||
### 4.1 文件
|
||||
|
||||
| 路径 | 职责 |
|
||||
|------|------|
|
||||
| `mobile-h5/components/ConfirmDialog.vue` | 新建:F 样式确认弹窗(props:`show` / `title` / `content` / 按钮文案;**默认插槽**可自定义正文,用于协议快捷链接;events:`confirm` / `cancel` / `update:show`) |
|
||||
| `mobile-h5/styles/components/confirm-dialog.scss` | 新建:遮罩与卡片样式(Token 与 Demo F 一致) |
|
||||
| `mobile-h5/pages/login/index.vue` | 改:`ensureAgree` 改为控制本地 `agreeDialogVisible`,去掉 `uni.showModal` |
|
||||
| `mobile-h5/App.vue` | 改:引入 `confirm-dialog.scss` |
|
||||
| `mobile-ui/zkxw-mobile-login-confirm-dialog-demo.html` | 改:标注「登录页采用 F」;A–F 均保留 |
|
||||
| `docs/miniprogram-ui-spec-sanqiang.md` | 改(可选短注):确认弹窗 Demo / 登录采用 F 的指向 |
|
||||
|
||||
### 4.2 组件接口(约定)
|
||||
|
||||
```text
|
||||
props:
|
||||
show: Boolean
|
||||
title: String(默认「请先同意协议」)
|
||||
content: String(无插槽时的纯文本兜底)
|
||||
cancelText: String(默认「取消」)
|
||||
confirmText: String(默认「同意并登录」)
|
||||
|
||||
slots:
|
||||
default — 自定义正文(登录页放入带协议链接的文案)
|
||||
|
||||
emits:
|
||||
update:show
|
||||
confirm
|
||||
cancel
|
||||
```
|
||||
|
||||
登录页用 `:show` + `@update:show`(或等价)+ `@confirm` / `@cancel` 驱动 `ensureAgree` 的 Promise;正文用默认插槽渲染协议快捷链接,点击调用既有 `openProtocol(type)`。
|
||||
|
||||
### 4.3 与既有登录 Spec 的关系
|
||||
|
||||
`2026-08-10-miniprogram-login-ui-design.md` §4 曾写未勾选时 Toast;**本规格覆盖该门禁交互**:未勾选时改为 **F 确认弹窗**(与当前代码意图一致,并美化为自定义 F)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 验收
|
||||
|
||||
1. 未勾选协议点「登录」/「微信授权登录」→ 出现 F 样式弹窗(非系统 `showModal`)。
|
||||
2. 「同意并登录」后勾选态为开,并完成对应 mock 登录跳转。
|
||||
3. 「取消」或点遮罩 → 不登录、勾选仍为关。
|
||||
4. 已勾选时直接登录,无弹窗。
|
||||
5. Demo HTML 仍可浏览 A–F;说明中写明登录采用 F。
|
||||
6. 弹窗正文中点击《用户协议》《隐私政策》可进入对应协议页;返回后确认弹窗仍可见。
|
||||
|
||||
---
|
||||
|
||||
## 6. 后续扩展(非本期)
|
||||
|
||||
其它页面若需确认弹窗,优先对照同一 Demo 选型(A–F),再复用或扩展 `ConfirmDialog`;**不**默认改 F 的登录专用文案与布局。
|
||||
@@ -1,7 +1,7 @@
|
||||
# OA 加班套件 · 自动计算时长 + 中国节假日缓存 设计文档
|
||||
|
||||
**日期**: 2026-08-10
|
||||
**状态**: 待实现
|
||||
**状态**: 已实现
|
||||
**范围**: `web-admin` 加班套件运行时时长联动;`backend` 时长重算/校验;全国节假日日历同步与落库
|
||||
**前置**: 既有加班套件(手动时长);请假套件手改联动模式(`oa-leave-kit` / `oa_leave_hours`)
|
||||
**界面模式**: 对齐请假套件发起态交互;视觉 Fresh Mint
|
||||
@@ -128,9 +128,10 @@
|
||||
- 数据源:`CN_HOLIDAY_API_BASE`(默认 `https://holiday.ailcc.com`)。
|
||||
- **不要只用** `/api/holiday/year/{year}`(文档明确**不返回补班**)。
|
||||
- 使用 `GET /api/holiday/batch?d=日期列表&type=Y`,按月拆批(每月 ≤31,低于 50 上限)。
|
||||
- 覆盖:**当年**;若当前月为 12 月则同时拉**下一年**(或始终拉当年+下一年,实现任选其一并在计划中写死)。
|
||||
- 覆盖:**当年 + 下一年**;若某年在库中行数已 ≥ 该年天数(365/366),则**跳过该年**,不再请求 ailcc。
|
||||
- Upsert by `day_date`;失败打日志并**保留旧缓存**。
|
||||
- 超时与有限次重试(如 2 次)在服务层配置。
|
||||
- 若国务院中途调整放假安排需刷新:删除该年数据(或整表)后重启服务/等到下次同步即可重新拉取。
|
||||
|
||||
### 4.3 进程内后台循环(替代 cron)
|
||||
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
# OA「关联审批单」选单:三 Tab + 卡片模式修订设计
|
||||
|
||||
**日期**: 2026-08-10
|
||||
**状态**: 已实现(计划见 `docs/superpowers/plans/2026-08-10-oa-related-approval-picker-tabs-card.md`)
|
||||
**方案**: 方案 1 — 扩展现有 `GET /related-candidates`(`tab` + 卡片字段),放宽 `assert_linkable_instance`
|
||||
**界面参考**: 钉钉关联选单卡片;Demo `docs/mockups/oa-related-approval-picker-card-demo.html`(实现时过滤区仅保留关键词)
|
||||
**前置文档**:
|
||||
- `docs/superpowers/specs/2026-07-22-oa-related-approval-design.md`(V1 基线)
|
||||
- `docs/superpowers/specs/2026-07-14-oa-approval-dingtalk-design.md` §5.5
|
||||
|
||||
**决策记录**:
|
||||
- 「我审批的」= 现网已办 `done`(非待办 `pending`)
|
||||
- 实例状态不限制:处理中 / 已通过 / 已拒绝 / 已打回 / 已撤销均可选、可关联;卡片须展示流程状态
|
||||
- 三 Tab 内可见的实例均可关联(放宽「只能关联本人发起」)
|
||||
- 部门:展示发起部门 `initiatorDeptName`;无则整行不显示
|
||||
- 过滤:仅关键词(流程名 / 标题);不做状态下拉、不做应用下拉
|
||||
- 实现路径:扩展 `/related-candidates`,不新建独立 picker 接口;不改 `form_data` / `oa_relation` 契约
|
||||
|
||||
---
|
||||
|
||||
## 1. 目标与范围
|
||||
|
||||
### 1.1 目标
|
||||
|
||||
将「关联审批单」发起选单从表格改为钉钉式卡片,并支持按关系维度浏览:**我发起的 / 我审批的(已办)/ 抄送我的**;取消「仅已通过」限制,使当前用户在上述关系下可见的实例均可关联。
|
||||
|
||||
### 1.2 包含(本期)
|
||||
|
||||
| 能力 | 说明 |
|
||||
|------|------|
|
||||
| 选单 Tab | `mine` / `done` / `cc`;默认 `mine` |
|
||||
| 状态放开 | 候选与提交校验均不再要求 `status=completed` |
|
||||
| 关联权限放宽 | 发起人 **或** 已办审批任务 **或** 抄送任务 → 可关联 |
|
||||
| 卡片 UI | 勾选 + 头像字 + 标题 + 部门(可选) + 字段摘要 + 时间 + 右侧状态 |
|
||||
| 关键词过滤 | 匹配应用名(流程名)或实例标题 |
|
||||
| 分页多选 | 保留;已关联 id 禁用勾选 |
|
||||
|
||||
### 1.3 不包含(本期)
|
||||
|
||||
- 状态筛选器、按审批应用下拉筛选
|
||||
- 新建独立 `/related-picker` 路由
|
||||
- 改变 `form_data[field]=string[]` 或 `oa_relation` 表结构
|
||||
- 选中后自动回填其它表单字段 / 自动串子流程
|
||||
- 小程序选单壳(契约兼容即可)
|
||||
- 改详情主接口批量附带 relatedSummaries
|
||||
|
||||
### 1.4 相对 V1 基线的变更点
|
||||
|
||||
| 项 | V1(2026-07-22) | 本期 |
|
||||
|----|------------------|------|
|
||||
| 选单范围 | 本人发起 + `completed` | 三 Tab 可见;状态不限 |
|
||||
| 选单 UI | 表格 | 卡片 |
|
||||
| 过滤 | 关键词 | 仍仅关键词(语义不变,UI 去掉多余筛选项若曾有) |
|
||||
| 校验 | 本人 + completed | 关系可见即可关联;状态不限 |
|
||||
|
||||
若与 V1 冲突,**以本文为选单/关联校验权威**;V1 其余(扁平化、嵌套打印、控件注册)仍有效。
|
||||
|
||||
---
|
||||
|
||||
## 2. 后端设计
|
||||
|
||||
### 2.1 `GET /related-candidates`
|
||||
|
||||
**Query**:
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `tab` | string | `mine`(默认)\| `done` \| `cc` |
|
||||
| `keyword` | string | 可选;匹配 `OaApp.app_name` 或 `OaInstance.title` |
|
||||
| `page` | int | 默认 1 |
|
||||
| `pageSize` | int | 默认 20,上限 100 |
|
||||
|
||||
**Tab 语义(SHALL 与 `oa_engine.list_tasks` 对齐)**:
|
||||
|
||||
| tab | 含义 | 查询要点 |
|
||||
|-----|------|----------|
|
||||
| `mine` | 我发起的 | `OaInstance.initiator_user_id = 当前用户`,`del_flag=0`,租户隔离;**不**滤 status |
|
||||
| `done` | 我审批的(已办) | 存在 `OaTask`:`assignee_user_id=当前用户`、`task_type=approval`、`status ∈ (approved, rejected, returned, transferred)`;按 **instance 去重** |
|
||||
| `cc` | 抄送我的 | 存在 `OaTask`:`assignee_user_id=当前用户`、`task_type=cc`;按 **instance 去重** |
|
||||
|
||||
排序:实例 `create_time DESC`。
|
||||
租户:仅当前用户 `tenant_no`;不信任客户端覆盖。
|
||||
|
||||
**列表项(卡片 DTO)SHALL 包含**:
|
||||
|
||||
```ts
|
||||
{
|
||||
id: string
|
||||
appName: string
|
||||
title: string // 原始实例 title,可空
|
||||
displayTitle: string // 建议「{发起人}提交的{应用名}」;缺名时回退 appName/title
|
||||
initiatorUserName: string
|
||||
initiatorDeptName: string // 可空;空则前端不渲染部门行
|
||||
applyTime: string // YYYY-MM-DD HH:mm:ss
|
||||
status: string // running|completed|rejected|returned|cancelled|…
|
||||
statusText: string // 处理中|已通过|已拒绝|已打回|已撤销|…
|
||||
currentApproverNames: string // 处理中:当前审批人姓名,顿号拼接;否则可空
|
||||
previewFields: Array<{ label: string; value: string }> // 最多 2 条业务字段摘要
|
||||
}
|
||||
```
|
||||
|
||||
`previewFields`:复用 `flatten_form_fields_for_summary`,取前 2 条(跳过布局/说明等与现扁平化一致)。
|
||||
|
||||
### 2.2 `assert_linkable_instance`(放宽)
|
||||
|
||||
SHALL 同时满足:
|
||||
|
||||
1. 实例存在、`del_flag=0`、同 `tenant_no`
|
||||
2. **不再**要求 `status == completed`
|
||||
3. 当前用户满足**任一**:
|
||||
- `inst.initiator_user_id == user_id`,或
|
||||
- 存在该用户对该实例的已办审批任务(同 `done` 任务条件),或
|
||||
- 存在该用户对该实例的抄送任务
|
||||
|
||||
否则抛出明确错误(如「无权关联该审批单」)。
|
||||
`start_instance` 写 `oa_relation`、`build_related_summaries(mode=picker)` 均走此校验。
|
||||
|
||||
### 2.3 enrichment / 详情回显
|
||||
|
||||
- `mode=view`:保持「当前用户已能看当前单」前提下,对被关联 id 无可见权则 `unavailable: true`(不整接口 403)
|
||||
- `mode=picker`:按新的 `assert_linkable_instance` 判定
|
||||
- 已选卡片在表单上的收起/展开字段规则仍遵循 V1 §5(前 3 / 全部);与选单 `previewFields`(前 2)独立
|
||||
|
||||
### 2.4 兼容
|
||||
|
||||
- 未传 `tab` 时默认 `mine`
|
||||
- 旧前端只传 `keyword/page/pageSize`:行为变为「我发起的 + 状态不限」(相对 V1 的 breaking:不再仅 completed)。产品已确认接受。
|
||||
|
||||
---
|
||||
|
||||
## 3. 前端设计
|
||||
|
||||
### 3.1 改动面
|
||||
|
||||
| 模块 | 职责 |
|
||||
|------|------|
|
||||
| `OaRelatedApproval.vue` | 选单弹窗:Tab + 关键词 + 卡片列表 + 多选分页 |
|
||||
| `api/oaApproval.ts` | `fetchOaRelatedCandidates` 增加 `tab`;类型对齐卡片 DTO |
|
||||
| Demo | `oa-related-approval-picker-card-demo.html` 可同步去掉状态/应用筛选项(可选,非门禁) |
|
||||
|
||||
设计器 DragRule / `setupOaRelatedApproval` / `form_data` 写入逻辑不变。
|
||||
|
||||
### 3.2 选单交互(SHALL)
|
||||
|
||||
1. 打开弹窗 → 默认 Tab `mine`,拉候选
|
||||
2. 切换 Tab → 清空**本次**勾选草稿,`page=1`,重新请求
|
||||
3. 关键词回车或点查询 → `page=1` 刷新
|
||||
4. 卡片勾选多选;已在 `modelValue` 中的 id 禁用
|
||||
5. 确定 → 合并 id 到 `modelValue`(去重保序)
|
||||
6. 空列表文案:「暂无审批单」
|
||||
|
||||
### 3.3 卡片视觉(SHALL)
|
||||
|
||||
对齐钉钉截图 + Fresh Mint:
|
||||
|
||||
- 左:checkbox → 圆角方头像(发起人姓名末 2 字 + 色块)
|
||||
- 中:`displayTitle`;可选「部门:{initiatorDeptName}」;最多 2 行 `label:value`;底部 `applyTime`
|
||||
- 右:`status=running` 时展示当前审批人(如有)+「处理中」;其它状态展示 `statusText`;色语义:处理中/打回偏警告色,已通过成功色,已拒绝危险色,已撤销中性
|
||||
|
||||
遮罩/弹窗:现有 `dialog-form-layout`、无 backdrop blur。
|
||||
|
||||
---
|
||||
|
||||
## 4. 错误与边界
|
||||
|
||||
| 场景 | 行为 |
|
||||
|------|------|
|
||||
| Tab 无数据 | 空态「暂无审批单」 |
|
||||
| 关键词无命中 | 空态 |
|
||||
| 篡改不可关联 id | 提交/校验失败,明确错误,不写 relation |
|
||||
| 同一实例在 done/cc 多条 task | 列表按 instance 去重,只出一张卡 |
|
||||
| 无发起部门 | 不渲染部门行 |
|
||||
| 处理中但暂无审批人 | 右侧仅「处理中」 |
|
||||
| 必填未选 | FormCreate 校验拦截 |
|
||||
| 无该控件旧应用 | 行为不变 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 测试建议
|
||||
|
||||
**后端**
|
||||
- `list_related_candidates`:三 tab 范围;keyword;分页;status 不限
|
||||
- `assert_linkable`:发起人任意状态可过;已办/抄送可过;无关用户失败;删除/跨租户失败
|
||||
- start 写 relation:他人已办实例可关联
|
||||
|
||||
**前端**
|
||||
- Tab 切换清空勾选并换源
|
||||
- 卡片字段渲染(部门有无、状态色、preview 两行)
|
||||
- 已选禁用、多选合并
|
||||
|
||||
**手工**
|
||||
- 对照 Demo/钉钉截图目视
|
||||
- 发起页选三 Tab 各关联一张并进详情跳转
|
||||
|
||||
---
|
||||
|
||||
## 6. 验收标准
|
||||
|
||||
1. 选单为卡片而非表格;三 Tab 可切换且数据语义正确(done=已办)
|
||||
2. 任意实例状态均可出现在候选中(在对应 Tab 关系下)且可成功关联
|
||||
3. 仅关键词过滤生效
|
||||
4. 有发起部门则显示,无则整行不出现
|
||||
5. 卡片展示流程状态;处理中尽量展示当前审批人
|
||||
6. 越权 id 无法提交;`form_data`/`oa_relation` 契约不变
|
||||
7. UI 符合 Fresh Mint,主色非设计器默认蓝
|
||||
|
||||
---
|
||||
|
||||
## 7. 实现顺序建议
|
||||
|
||||
1. 后端:放宽 `assert_linkable_instance` + 单测
|
||||
2. 后端:`list_related_candidates` 支持 `tab` 与卡片 DTO + 单测
|
||||
3. API 路由透传 `tab`
|
||||
4. 前端 API 类型与 `fetchOaRelatedCandidates`
|
||||
5. `OaRelatedApproval.vue` 选单 UI 卡片化 + Tab + 关键词
|
||||
6. 回归:发起关联、详情回显、嵌套打印不破坏
|
||||
7. (可选)同步精简 HTML Demo 过滤区;`./scripts/update-graph.sh backend`(若签名/调用图有变)
|
||||
@@ -0,0 +1,61 @@
|
||||
# OA「关联审批单」:只读详情弹窗展示设计
|
||||
|
||||
**日期**: 2026-08-11
|
||||
**状态**: 已批准
|
||||
**基线**: `docs/superpowers/specs/2026-07-22-oa-related-approval-design.md`
|
||||
**问题**: 发起审批等弹窗内点击关联单标题会 `router.push` 打开「审批详情」Tab,但被上层弹窗遮挡,用户看不到详情。
|
||||
|
||||
---
|
||||
|
||||
## 1. 目标
|
||||
|
||||
点击「关联审批单」标题(及嵌套「已关联 M 条」)时,以**叠一层只读详情弹窗**展示关联实例;父级发起/编辑弹窗不关闭,关闭详情后可继续填单。
|
||||
|
||||
## 2. 非目标
|
||||
|
||||
- 不在弹窗内提供同意/拒绝/打回/加签/转交/撤销/复制并重新发起等操作
|
||||
- 不改后端 API、路由注册、权限模型
|
||||
- 不重构整页 `instances/detail.vue` 为共享组件(可局部复用其组装逻辑)
|
||||
|
||||
## 3. 交互
|
||||
|
||||
| 场景 | 行为 |
|
||||
|------|------|
|
||||
| 发起审批弹窗内点关联标题 | 打开只读详情弹窗(盖在发起弹窗上) |
|
||||
| 关闭只读详情 | 回到发起弹窗,表单状态保留 |
|
||||
| 详情弹窗内再点嵌套关联 | 再叠一层同款只读弹窗(各控件实例自管) |
|
||||
| `unavailable` / 无 id | 不打开;保持现有不可点态 |
|
||||
| 加载失败 / 无权限 | 接口错误提示;不白屏 |
|
||||
|
||||
**变更**:`OaRelatedApproval` 内 **不再** `router.push` 到实例详情页(一律弹窗)。列表页「查看详情」等入口不受影响。
|
||||
|
||||
## 4. UI
|
||||
|
||||
- `el-dialog`:`append-to-body`、`align-center`、`dialog-form-layout`、`modal-class="protocol-dialog-mask"`、`destroy-on-close`;无 backdrop blur
|
||||
- 宽度约 `920px`(对齐发起弹窗)
|
||||
- 标题区:实例标题 + 状态 `el-tag`
|
||||
- 正文:表单只读回显(form-create,与详情页同套 rule 预处理)+ `OaProgressChain`
|
||||
- 底栏:仅「关闭」(Close 图标 + 文案)
|
||||
|
||||
## 5. 实现落点
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `web-admin/src/components/oa-approval/OaInstanceDetailDialog.vue` | 新建:按 `instanceId` 拉详情并只读展示 |
|
||||
| `web-admin/src/components/oa-approval/OaRelatedApproval.vue` | `goDetail` 改为打开上述弹窗;挂载弹窗实例 |
|
||||
| 基线 spec §5 跳转句 | 改为交叉引用本文 |
|
||||
|
||||
详情组装(`applySchema` / progress 投影 / status 文案)与 `instances/detail.vue` 只读部分对齐;审批动作相关代码不迁入。
|
||||
|
||||
## 6. 验收
|
||||
|
||||
1. 发起弹窗内点关联标题 → 详情盖在上面,发起单仍在
|
||||
2. 关详情 → 可继续编辑/提交
|
||||
3. 嵌套关联可再叠一层并可逐层关闭
|
||||
4. 不可用项不可点;无权限有提示
|
||||
|
||||
## 7. 自检
|
||||
|
||||
- 无 TBD/占位未决项
|
||||
- 与基线「可跳转详情」意图一致,仅展示载体由路由页改为弹窗
|
||||
- 范围限于 web-admin 关联控件 + 新建只读弹窗
|
||||
@@ -0,0 +1,29 @@
|
||||
# OA 四个中心列表分页设计
|
||||
|
||||
## 背景
|
||||
|
||||
待办中心 / 已办中心 / 我发起的 / 抄送我的目前一次返回全量,无 `el-pagination`。需与同模块草稿箱及标准列表页对齐,补服务端分页。
|
||||
|
||||
## 决策
|
||||
|
||||
- 默认每页 **20** 条(与草稿箱一致);可选 10 / 20 / 50。
|
||||
- **DB 级** `count` + `offset/limit`,再对当前页 enrich。
|
||||
- 接口返回 `{ list, total }`;`page` / `pageSize` 查询参数。
|
||||
|
||||
## 后端
|
||||
|
||||
- `GET /api/.../oa-approval/tasks/{kind}` 增加 `page`(默认 1)、`pageSize`(默认 20,1–100)。
|
||||
- `list_tasks` 增加同名参数;返回 `{"list": [...], "total": N}`(破坏性:原直接返回 list,调用方与单测需同步)。
|
||||
- `pending` / `done` / `cc`:在 `OaTask` 查询上分页;`cc` 的 `readStatus` 仍先筛选再分页。
|
||||
- `mine`:在 `OaInstance` 查询上分页。
|
||||
- 排序保持 `create_time DESC`。
|
||||
|
||||
## 前端
|
||||
|
||||
- 四页:`list-body` 内表格 + `pagination list-pager-bar` + `el-pagination`(对齐草稿箱)。
|
||||
- 序号:`(page - 1) * pageSize + index + 1`。
|
||||
- `fetchOaTasks` 传 `page` / `pageSize`;抄送筛选/重置时 `page = 1`。
|
||||
|
||||
## 非目标
|
||||
|
||||
角标 count、详情/审批动作、额外筛选项、假分页。
|
||||
Reference in new issue
Block a user