This commit is contained in:
zlwu committed 2026-08-11 11:13:01 +08:00
1 parent 80138c6388
commit 693e8a678e
682 files changed
+211375 -5306

No files matched your search

+2
View File
@@ -21,6 +21,8 @@
- 登录页 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:居中品牌 + 白卡片 + 微信次要按钮)
- 登录确认弹窗 Demo:`mobile-ui/zkxw-mobile-login-confirm-dialog-demo.html`(登录采用方案 F;A–F 保留)
- 登录确认弹窗设计:`docs/superpowers/specs/2026-08-10-login-confirm-dialog-design.md`
---
@@ -0,0 +1,720 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>关联审批单 · 选单卡片模式 Demo</title>
<style>
:root {
--primary: #10b981;
--primary-dark: #059669;
--primary-bg: #ecfdf5;
--primary-border: #a7f3d0;
--text: #1e293b;
--text-muted: #64748b;
--text-faint: #94a3b8;
--border: #e2e8f0;
--border-soft: #f1f5f9;
--bg: #f1f5f9;
--surface: #ffffff;
--warn: #d97706;
--danger: #ef4444;
--success: #059669;
--info: #64748b;
--shadow: 0 12px 40px rgba(15, 23, 42, 0.12);
--radius: 10px;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
font-family: Inter, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", system-ui, sans-serif;
font-size: 14px;
line-height: 1.5;
color: var(--text);
background:
radial-gradient(1200px 400px at 10% -10%, #d1fae5 0%, transparent 55%),
radial-gradient(900px 360px at 100% 0%, #e0f2fe 0%, transparent 50%),
var(--bg);
}
.page {
max-width: 960px;
margin: 0 auto;
padding: 28px 20px 80px;
}
h1 {
margin: 0 0 6px;
font-size: 22px;
font-weight: 700;
color: var(--text);
}
.lead {
margin: 0 0 20px;
color: var(--text-muted);
max-width: 720px;
}
.notes {
margin-bottom: 20px;
padding: 12px 14px;
border: 1px dashed var(--primary-border);
border-radius: var(--radius);
background: #fff;
color: var(--text);
font-size: 13px;
}
.notes strong { color: var(--primary-dark); }
.notes ul { margin: 8px 0 0; padding-left: 18px; }
.notes li { margin: 2px 0; }
/* Dialog shell */
.dialog {
background: var(--surface);
border-radius: 12px;
box-shadow: var(--shadow);
border: 1px solid var(--border);
overflow: hidden;
display: flex;
flex-direction: column;
max-height: calc(100vh - 120px);
}
.dialog__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
border-bottom: 1px solid var(--border);
}
.dialog__title {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.dialog__close {
width: 28px;
height: 28px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--text-muted);
font-size: 18px;
cursor: pointer;
}
.dialog__close:hover { background: var(--border-soft); }
.dialog__body {
padding: 0;
overflow: auto;
flex: 1;
}
.dialog__footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 18px;
border-top: 1px solid var(--border);
background: #f8fafc;
}
.dialog__footer-left {
color: var(--text-muted);
font-size: 13px;
}
.dialog__footer-left em {
font-style: normal;
color: var(--primary-dark);
font-weight: 600;
}
.dialog__footer-right { display: flex; gap: 8px; }
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
height: 36px;
padding: 0 14px;
border-radius: 8px;
border: 1px solid var(--border);
background: #fff;
color: var(--text);
font-size: 14px;
cursor: pointer;
}
.btn:hover { background: #f8fafc; }
.btn--primary {
border-color: var(--primary);
background: var(--primary);
color: #fff;
}
.btn--primary:hover { background: var(--primary-dark); border-color: var(--primary-dark); }
.btn--primary:disabled {
opacity: 0.45;
cursor: not-allowed;
}
/* Tabs */
.tabs {
display: flex;
gap: 0;
padding: 0 18px;
border-bottom: 1px solid var(--border);
background: #fff;
}
.tab {
position: relative;
height: 44px;
padding: 0 16px;
border: 0;
background: transparent;
color: var(--text-muted);
font-size: 14px;
cursor: pointer;
}
.tab:hover { color: var(--text); }
.tab.is-active {
color: var(--primary-dark);
font-weight: 600;
}
.tab.is-active::after {
content: "";
position: absolute;
left: 12px;
right: 12px;
bottom: 0;
height: 2px;
border-radius: 1px;
background: var(--primary);
}
.tab .count {
margin-left: 4px;
color: var(--text-faint);
font-weight: 400;
font-size: 12px;
}
/* Filters */
.filters {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
padding: 12px 18px;
border-bottom: 1px solid var(--border-soft);
background: #fff;
}
.field {
display: flex;
align-items: center;
gap: 6px;
}
.field label {
font-size: 12px;
color: var(--text-muted);
white-space: nowrap;
}
.input, .select {
height: 34px;
padding: 0 10px;
border: 1px solid var(--border);
border-radius: 8px;
background: #fff;
color: var(--text);
font-size: 13px;
outline: none;
}
.input { min-width: 200px; }
.input:focus, .select:focus {
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.2);
}
.select { min-width: 120px; }
/* Card list */
.card-list {
background: #fff;
}
.card {
display: flex;
gap: 12px;
align-items: flex-start;
padding: 14px 18px;
border-bottom: 1px solid var(--border-soft);
cursor: pointer;
transition: background 0.12s;
}
.card:hover { background: #f8fafc; }
.card.is-selected { background: var(--primary-bg); }
.card.is-disabled {
opacity: 0.55;
cursor: not-allowed;
}
.card__check {
flex-shrink: 0;
margin-top: 10px;
}
.card__check input {
width: 16px;
height: 16px;
accent-color: var(--primary);
cursor: pointer;
}
.card__avatar {
flex-shrink: 0;
width: 40px;
height: 40px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.5px;
margin-top: 2px;
}
.card__main {
flex: 1;
min-width: 0;
}
.card__title {
margin: 0 0 4px;
font-size: 14px;
font-weight: 600;
color: var(--text);
line-height: 1.4;
}
.card__meta {
margin: 0;
padding: 0;
list-style: none;
color: var(--text-muted);
font-size: 12.5px;
}
.card__meta li {
margin: 1px 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card__time {
margin-top: 6px;
color: var(--text-faint);
font-size: 12px;
}
.card__right {
flex-shrink: 0;
text-align: right;
min-width: 100px;
padding-top: 2px;
}
.card__actor {
font-size: 12.5px;
color: var(--warn);
line-height: 1.4;
}
.card__status {
margin-top: 4px;
font-size: 12.5px;
font-weight: 500;
}
.st-running { color: var(--warn); }
.st-completed { color: var(--success); }
.st-rejected { color: var(--danger); }
.st-returned { color: var(--warn); }
.st-cancelled { color: var(--info); }
.empty {
padding: 48px 18px;
text-align: center;
color: var(--text-faint);
font-size: 13px;
}
.pager {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 8px;
padding: 10px 18px 14px;
color: var(--text-muted);
font-size: 12px;
background: #fff;
}
.pager button {
height: 28px;
padding: 0 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: #fff;
cursor: pointer;
font-size: 12px;
}
.pager button:disabled { opacity: 0.4; cursor: not-allowed; }
.legend {
margin-top: 18px;
font-size: 12px;
color: var(--text-muted);
}
</style>
</head>
<body>
<div class="page">
<h1>关联审批单 · 选单卡片模式</h1>
<p class="lead">
需求草案 Demo:去掉「仅已通过」限制;三 Tab(我发起的 / 我审批的 / 抄送我的);
钉钉式卡片;展示部门与表单摘要;支持筛选。交互可点选,便于对齐产品预期。
</p>
<div class="notes">
<strong>本 Demo 约定(待评审)</strong>
<ul>
<li>Tab 默认落在「我发起的」;切换 Tab 清空当前页勾选草稿(已确认关联保留在表单外)。</li>
<li>状态不限制:处理中 / 已通过 / 已拒绝 / 已打回 / 已撤销均可出现;右侧展示当前处理人 + 状态文案。</li>
<li>卡片主信息:标题、部门、前 2 个业务字段摘要、申请时间;左勾选 + 发起人头像字。</li>
<li>筛选:仅关键词(流程名 / 标题)。</li>
</ul>
</div>
<div class="dialog" role="dialog" aria-label="选择关联审批单">
<div class="dialog__header">
<h2 class="dialog__title">选择关联审批单</h2>
<button class="dialog__close" type="button" aria-label="关闭">×</button>
</div>
<div class="tabs" role="tablist">
<button class="tab is-active" data-tab="mine" type="button">我发起的<span class="count" id="count-mine"></span></button>
<button class="tab" data-tab="done" type="button">我审批的<span class="count" id="count-done"></span></button>
<button class="tab" data-tab="cc" type="button">抄送我的<span class="count" id="count-cc"></span></button>
</div>
<div class="filters">
<div class="field">
<label>关键词</label>
<input class="input" id="kw" type="search" placeholder="搜索流程名 / 标题" />
</div>
<button class="btn" type="button" id="btnSearch">查询</button>
<button class="btn" type="button" id="btnReset">重置</button>
</div>
<div class="dialog__body">
<div class="card-list" id="list"></div>
<div class="pager">
<span id="pageInfo">第 1 页</span>
<button type="button" id="prevPage">上一页</button>
<button type="button" id="nextPage">下一页</button>
</div>
</div>
<div class="dialog__footer">
<div class="dialog__footer-left">已选 <em id="selCount">0</em> 项</div>
<div class="dialog__footer-right">
<button class="btn" type="button">取消</button>
<button class="btn btn--primary" type="button" id="btnOk" disabled>确定</button>
</div>
</div>
</div>
<p class="legend">
静态 Mock · Fresh Mint 配色 · 打开本文件即可预览:
<code>docs/mockups/oa-related-approval-picker-card-demo.html</code>
</p>
</div>
<script>
const AVATAR_COLORS = ["#3b82f6", "#10b981", "#8b5cf6", "#f59e0b", "#06b6d4", "#ef4444"];
const STATUS_TEXT = {
running: "处理中",
completed: "已通过",
rejected: "已拒绝",
returned: "已打回",
cancelled: "已撤销",
};
/** 已关联(模拟表单已选,不可再选) */
const alreadyLinked = new Set(["m2"]);
const DATA = {
mine: [
{
id: "m1",
appName: "人事审批",
title: "吴自龙提交的人事审批",
initiatorName: "吴自龙",
deptName: "研发中心 / 平台组",
fields: [
{ label: "数字输入框", value: "123" },
{ label: "单行输入框", value: "123" },
],
applyTime: "2026-07-23 13:50",
status: "running",
actor: "吴震 (审批人)",
},
{
id: "m2",
appName: "请假",
title: "吴自龙提交的请假",
initiatorName: "吴自龙",
deptName: "研发中心 / 平台组",
fields: [
{ label: "请假类型", value: "年假" },
{ label: "时长", value: "1.5 天" },
],
applyTime: "2026-07-20 09:12",
status: "completed",
actor: "",
},
{
id: "m3",
appName: "报销",
title: "吴自龙提交的报销",
initiatorName: "吴自龙",
deptName: "研发中心 / 平台组",
fields: [
{ label: "报销金额", value: "¥1,280.00" },
{ label: "事由", value: "客户拜访差旅" },
],
applyTime: "2026-07-18 16:40",
status: "rejected",
actor: "",
},
{
id: "m4",
appName: "采购",
title: "吴自龙提交的采购",
initiatorName: "吴自龙",
deptName: "研发中心 / 平台组",
fields: [
{ label: "采购品类", value: "办公设备" },
{ label: "预算", value: "¥8,000" },
],
applyTime: "2026-07-10 11:05",
status: "returned",
actor: "",
},
],
done: [
{
id: "d1",
appName: "请假",
title: "张三提交的请假",
initiatorName: "张三",
deptName: "市场部 / 华东区",
fields: [
{ label: "请假类型", value: "事假" },
{ label: "时长", value: "0.5 天" },
],
applyTime: "2026-07-22 10:20",
status: "running",
actor: "吴自龙 (审批人)",
},
{
id: "d2",
appName: "人事审批",
title: "李四提交的人事审批",
initiatorName: "李四",
deptName: "人事行政部",
fields: [
{ label: "调整类型", value: "转正" },
{ label: "生效日期", value: "2026-08-01" },
],
applyTime: "2026-07-15 14:33",
status: "completed",
actor: "",
},
{
id: "d3",
appName: "报销",
title: "王五提交的报销",
initiatorName: "王五",
deptName: "财务部",
fields: [
{ label: "报销金额", value: "¥560.00" },
{ label: "科目", value: "招待费" },
],
applyTime: "2026-07-12 08:50",
status: "cancelled",
actor: "",
},
],
cc: [
{
id: "c1",
appName: "采购",
title: "赵六提交的采购",
initiatorName: "赵六",
deptName: "供应链 / 采购组",
fields: [
{ label: "供应商", value: "某某科技" },
{ label: "金额", value: "¥12,000" },
],
applyTime: "2026-07-21 17:08",
status: "running",
actor: "陈七 (审批人)",
},
{
id: "c2",
appName: "人事审批",
title: "钱八提交的人事审批",
initiatorName: "钱八",
deptName: "研发中心 / 测试组",
fields: [
{ label: "岗位", value: "高级测试工程师" },
{ label: "职级", value: "P6" },
],
applyTime: "2026-07-08 09:40",
status: "completed",
actor: "",
},
],
};
const state = {
tab: "mine",
kw: "",
page: 1,
pageSize: 5,
selected: new Set(),
};
function shortName(name) {
const s = String(name || "").trim();
return s.length <= 2 ? s : s.slice(-2);
}
function avatarColor(name) {
let h = 0;
for (let i = 0; i < name.length; i++) h = (h + name.charCodeAt(i) * 17) % AVATAR_COLORS.length;
return AVATAR_COLORS[h];
}
function statusClass(st) {
return "st-" + (st || "running");
}
function filtered() {
return (DATA[state.tab] || []).filter((row) => {
if (state.kw) {
const blob = [row.appName, row.title].join(" ").toLowerCase();
if (!blob.includes(state.kw.toLowerCase())) return false;
}
return true;
});
}
function renderCounts() {
for (const k of ["mine", "done", "cc"]) {
const el = document.getElementById("count-" + k);
if (el) el.textContent = "(" + DATA[k].length + ")";
}
}
function renderList() {
const all = filtered();
const totalPages = Math.max(1, Math.ceil(all.length / state.pageSize));
if (state.page > totalPages) state.page = totalPages;
const start = (state.page - 1) * state.pageSize;
const pageRows = all.slice(start, start + state.pageSize);
const list = document.getElementById("list");
if (!pageRows.length) {
list.innerHTML = '<div class="empty">暂无符合条件的审批单</div>';
} else {
list.innerHTML = pageRows
.map((row) => {
const disabled = alreadyLinked.has(row.id);
const checked = state.selected.has(row.id);
const meta = [
row.deptName ? `<li>部门:${escapeHtml(row.deptName)}</li>` : "",
...(row.fields || [])
.slice(0, 2)
.map((f) => `<li>${escapeHtml(f.label)}:${escapeHtml(f.value)}</li>`),
].join("");
const actor =
row.status === "running" && row.actor
? `<div class="card__actor">${escapeHtml(row.actor)}</div>`
: "";
return `
<div class="card ${checked ? "is-selected" : ""} ${disabled ? "is-disabled" : ""}"
data-id="${row.id}" data-disabled="${disabled ? "1" : "0"}">
<div class="card__check">
<input type="checkbox" ${checked ? "checked" : ""} ${disabled ? "disabled" : ""} />
</div>
<div class="card__avatar" style="background:${avatarColor(row.initiatorName)}">
${escapeHtml(shortName(row.initiatorName))}
</div>
<div class="card__main">
<h3 class="card__title">${escapeHtml(row.title)}</h3>
<ul class="card__meta">${meta}</ul>
<div class="card__time">${escapeHtml(row.applyTime)}</div>
</div>
<div class="card__right">
${actor}
<div class="card__status ${statusClass(row.status)}">${STATUS_TEXT[row.status] || row.status}</div>
</div>
</div>`;
})
.join("");
}
document.getElementById("pageInfo").textContent =
`共 ${all.length} 条 · 第 ${state.page}/${totalPages} 页`;
document.getElementById("prevPage").disabled = state.page <= 1;
document.getElementById("nextPage").disabled = state.page >= totalPages;
document.getElementById("selCount").textContent = String(state.selected.size);
document.getElementById("btnOk").disabled = state.selected.size === 0;
}
function escapeHtml(s) {
return String(s || "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
document.querySelectorAll(".tab").forEach((btn) => {
btn.addEventListener("click", () => {
document.querySelectorAll(".tab").forEach((b) => b.classList.remove("is-active"));
btn.classList.add("is-active");
state.tab = btn.dataset.tab;
state.page = 1;
state.selected.clear();
renderList();
});
});
document.getElementById("btnSearch").addEventListener("click", () => {
state.kw = document.getElementById("kw").value.trim();
state.page = 1;
renderList();
});
document.getElementById("btnReset").addEventListener("click", () => {
document.getElementById("kw").value = "";
state.kw = "";
state.page = 1;
renderList();
});
document.getElementById("kw").addEventListener("keydown", (e) => {
if (e.key === "Enter") document.getElementById("btnSearch").click();
});
document.getElementById("prevPage").addEventListener("click", () => {
if (state.page > 1) {
state.page -= 1;
renderList();
}
});
document.getElementById("nextPage").addEventListener("click", () => {
state.page += 1;
renderList();
});
document.getElementById("list").addEventListener("click", (e) => {
const card = e.target.closest(".card");
if (!card || card.dataset.disabled === "1") return;
const id = card.dataset.id;
if (state.selected.has(id)) state.selected.delete(id);
else state.selected.add(id);
renderList();
});
renderCounts();
renderList();
</script>
</body>
</html>
-10
View File
@@ -1,10 +0,0 @@
-- docs/sql/drop_oa_role.sql
-- 下线 OA 工作流角色:审批人选改为职位(sys_post / sys_user_post)
-- 同步软删菜单「工作流角色」
UPDATE `sys_menu`
SET `del_flag` = 1, `update_time` = NOW()
WHERE `id` = 'OA000000000000000000000000000102' AND `del_flag` = 0;
DROP TABLE IF EXISTS `oa_role_user`;
DROP TABLE IF EXISTS `oa_role`;
-84
View File
@@ -1,84 +0,0 @@
-- OA 发起人填写草稿(非模板设计草稿)
CREATE TABLE IF NOT EXISTS `oa_instance_draft` (
`id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '主键ID',
`app_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '应用ID',
`app_version_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '暂存时锁定的已发布版本ID',
`title` varchar(200) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '标题',
`form_data_json` mediumtext COLLATE utf8mb4_unicode_ci COMMENT '表单数据 JSON',
`initiator_select_json` text COLLATE utf8mb4_unicode_ci COMMENT '发起人自选 JSON',
`initiator_dept_id` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '发起人部门',
`owner_user_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '草稿所属人',
`tenant_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
`order_num` int(11) DEFAULT NULL COMMENT '排序号',
`remark` varchar(512) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注',
`create_by` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建人id',
`create_dept_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '创建人部门ID',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_by` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '更新人id',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`del_flag` tinyint(4) NOT NULL DEFAULT '0' COMMENT '删除标记:0-正常 1-已删除',
PRIMARY KEY (`id`) USING BTREE,
KEY `idx_oid_owner` (`tenant_no`,`owner_user_id`,`del_flag`) USING BTREE,
KEY `idx_oid_app` (`tenant_no`,`app_id`,`del_flag`) USING BTREE,
KEY `idx_oid_update` (`tenant_no`,`owner_user_id`,`update_time`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='OA发起审批草稿';
-- 草稿箱位于「发起审批」与「待办中心」之间;仅首次插入时顺延 order_num
UPDATE `sys_menu` AS m
INNER JOIN (
SELECT 1 AS ok
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM `sys_menu`
WHERE `id` = 'OA000000000000000000000000000109' AND `del_flag` = 0
)
) AS guard ON 1 = 1
SET m.`order_num` = m.`order_num` + 1
WHERE m.`parent_id` = 'OA000000000000000000000000000001'
AND m.`del_flag` = 0
AND m.`order_num` IS NOT NULL
AND m.`order_num` >= 4;
INSERT INTO `sys_menu` (
`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`,
`is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`,
`create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`
) SELECT
'OA000000000000000000000000000109', '草稿箱', 'OA000000000000000000000000000001',
'C', 'drafts', '/oa-approval/drafts/index', 'oa:drafts',
1, '1', '1', 'zkxw', 4, 'OA发起审批草稿箱',
NULL, '', NOW(), NULL, NOW(), 0
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM `sys_menu` WHERE `id` = 'OA000000000000000000000000000109' AND `del_flag` = 0
);
-- 已拥有「发起审批」菜单的角色自动获得「草稿箱」(幂等)
-- 注意:MySQL 5.7 下 INSERT…SELECT 中 UUID() 可能整批相同,导致主键冲突;改用按行唯一的 MD5。
INSERT INTO `sys_role_menu` (
`id`, `role_id`, `menu_id`, `tenant_no`, `order_num`, `remark`,
`create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`
)
SELECT
MD5(CONCAT(rm.`role_id`, '#', 'OA000000000000000000000000000109')),
rm.`role_id`,
'OA000000000000000000000000000109',
COALESCE(NULLIF(rm.`tenant_no`, ''), 'zkxw'),
0,
'auto from oa:start',
NULL,
'',
NOW(),
NULL,
NOW(),
0
FROM `sys_role_menu` rm
WHERE rm.`menu_id` = 'OA000000000000000000000000000103'
AND rm.`del_flag` = 0
AND NOT EXISTS (
SELECT 1
FROM `sys_role_menu` x
WHERE x.`role_id` = rm.`role_id`
AND x.`menu_id` = 'OA000000000000000000000000000109'
AND x.`del_flag` = 0
);
-7
View File
@@ -1,7 +0,0 @@
-- OA 抄送任务已读标记(oa_task)
-- MySQL 5.7 / utf8mb4
-- 历史数据全部视为未读(DEFAULT 0)
ALTER TABLE `oa_task`
ADD COLUMN `is_read` TINYINT NOT NULL DEFAULT 0 COMMENT '抄送是否已读 0未读 1已读' AFTER `round_no`,
ADD COLUMN `read_time` DATETIME NULL DEFAULT NULL COMMENT '抄送首次已读时间' AFTER `is_read`;
+17
View File
@@ -0,0 +1,17 @@
-- 中国节假日日历缓存
-- MySQL 5.7 / utf8mb4
CREATE TABLE IF NOT EXISTS `sys_cn_holiday_day` (
`id` VARCHAR(64) NOT NULL COMMENT 'UUID',
`day_date` DATE NOT NULL COMMENT '日历日',
`day_type` TINYINT NOT NULL COMMENT '0工作日1周末2节日3调休放假4补班',
`day_name` VARCHAR(64) DEFAULT NULL COMMENT '名称',
`week` TINYINT DEFAULT NULL COMMENT '1-7周一到周日',
`is_rest_day` TINYINT NOT NULL DEFAULT 0 COMMENT '1休息0工作',
`raw_json` TEXT COMMENT '原始JSON片段',
`sync_time` DATETIME DEFAULT NULL COMMENT '同步时间',
`create_time` DATETIME DEFAULT NULL,
`update_time` DATETIME DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_day_date` (`day_date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='中国节假日日历缓存';
@@ -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、详情/审批动作、额外筛选项、假分页。