提交
This commit is contained in:
1 parent
80138c6388
commit
693e8a678e
682 files changed
+211375
-5306
No files matched your search
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -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`;
|
||||
@@ -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
|
||||
);
|
||||
@@ -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`;
|
||||
@@ -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、详情/审批动作、额外筛选项、假分页。
|
||||
Reference in new issue
Block a user