docs(oa): add nested relatedApproval print design, plan, and demos
Records screen count+jump vs print multi-line recursion (max 3 levels) and links the V1 related-approval spec to the revision. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
600adfb6d8
commit
51ce9d6059
5 files changed
+1685
-1
No files matched your search
@@ -0,0 +1,417 @@
|
||||
<!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: #334155;
|
||||
--text-muted: #94a3b8;
|
||||
--text-strong: #0f172a;
|
||||
--border: #e2e8f0;
|
||||
--bg: #f8fafc;
|
||||
--panel: #ffffff;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
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: var(--bg);
|
||||
}
|
||||
.page {
|
||||
max-width: 1080px;
|
||||
margin: 0 auto;
|
||||
padding: 28px 24px 64px;
|
||||
}
|
||||
h1 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 22px;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
.lead {
|
||||
margin: 0 0 24px;
|
||||
color: var(--text-muted);
|
||||
max-width: 720px;
|
||||
}
|
||||
.scene {
|
||||
margin-bottom: 12px;
|
||||
padding: 12px 14px;
|
||||
border: 1px dashed var(--primary-border);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
}
|
||||
.scene strong { color: var(--primary-dark); }
|
||||
.options {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
@media (min-width: 900px) {
|
||||
.options { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
.option {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: var(--panel);
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgb(15 23 42 / 6%);
|
||||
cursor: pointer;
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
}
|
||||
.option:hover { border-color: var(--primary-border); }
|
||||
.option.is-selected {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 2px rgb(16 185 129 / 25%);
|
||||
}
|
||||
.option-head {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
padding: 14px 16px 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: #fafafa;
|
||||
}
|
||||
.badge {
|
||||
flex-shrink: 0;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
background: var(--primary-bg);
|
||||
color: var(--primary-dark);
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.option.is-current .badge {
|
||||
background: #fef3c7;
|
||||
color: #b45309;
|
||||
}
|
||||
.option-meta h3 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 15px;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
.option-meta p {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.tag-now {
|
||||
display: inline-block;
|
||||
margin-left: 6px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
background: #fef3c7;
|
||||
color: #b45309;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.mock-body { padding: 14px 16px 16px; }
|
||||
.field-label {
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Related approval card (shared) */
|
||||
.ra-card {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--primary-border);
|
||||
border-radius: 8px;
|
||||
background: var(--primary-bg);
|
||||
}
|
||||
.ra-head {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.ra-title {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.ra-toggle {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--primary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ra-toggle:hover, .ra-title:hover { color: var(--primary-dark); }
|
||||
.ra-fields {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 16px;
|
||||
margin-top: 8px;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.ra-fields.collapsed .ra-f:nth-child(n+4) { display: none; }
|
||||
.ra-f { display: inline-flex; gap: 6px; max-width: 100%; }
|
||||
.ra-f-label { color: var(--text-muted); }
|
||||
.ra-f-value { color: var(--text); word-break: break-all; }
|
||||
.ra-f.muted .ra-f-value { color: var(--text-muted); font-style: italic; }
|
||||
.ra-nested {
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px dashed var(--primary-border);
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.ra-nested.open { display: flex; }
|
||||
.ra-card--nested {
|
||||
background: #fff;
|
||||
border-color: #d1fae5;
|
||||
}
|
||||
.ra-hint {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.pros {
|
||||
margin: 12px 16px 16px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
background: #f1f5f9;
|
||||
font-size: 12.5px;
|
||||
color: var(--text);
|
||||
}
|
||||
.pros strong { color: var(--primary-dark); }
|
||||
.footer-note {
|
||||
margin-top: 28px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--border);
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.pick-bar {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
margin: 24px -24px -64px;
|
||||
padding: 14px 24px;
|
||||
background: rgb(255 255 255 / 92%);
|
||||
backdrop-filter: blur(8px);
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.pick-bar span { color: var(--text-muted); font-size: 13px; }
|
||||
.pick-bar em { color: var(--primary-dark); font-style: normal; font-weight: 600; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<h1>关联审批单 · 嵌套展示方案</h1>
|
||||
<p class="lead">
|
||||
场景:发起/详情里选中一张历史审批单 A;A 自己的表单里也有「关联审批单」字段(指向 B)。
|
||||
当前 V1 规格把嵌套字段压成「已关联 M 条」,导致看不到缩略信息与展开。请点选你更倾向的方案(可点卡片内「展开」试交互)。
|
||||
</p>
|
||||
|
||||
<div class="scene">
|
||||
<strong>数据链:</strong>当前单 → 关联 <u>差旅报销 · 07-18</u>(单 A)→ 单 A 内又关联了 <u>外出申请 · 07-10</u>(单 B)
|
||||
</div>
|
||||
|
||||
<div class="options" id="options">
|
||||
<!-- A: current -->
|
||||
<article class="option is-current" data-choice="a-status-quo" onclick="selectOption(this)">
|
||||
<div class="option-head">
|
||||
<div class="badge">A</div>
|
||||
<div class="option-meta">
|
||||
<h3>现状(V1 已定)<span class="tag-now">当前实现</span></h3>
|
||||
<p>嵌套 relatedApproval 只显示「已关联 M 条」,不递归、无子卡片缩略</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mock-body">
|
||||
<div class="field-label">关联审批单</div>
|
||||
<div class="ra-card" data-stop>
|
||||
<div class="ra-head">
|
||||
<button type="button" class="ra-title">差旅报销 · 07-18</button>
|
||||
<button type="button" class="ra-toggle" data-expand="a1">展开</button>
|
||||
</div>
|
||||
<div class="ra-fields collapsed" id="a1">
|
||||
<span class="ra-f"><span class="ra-f-label">事由</span><span class="ra-f-value">客户拜访</span></span>
|
||||
<span class="ra-f"><span class="ra-f-label">金额</span><span class="ra-f-value">¥1,280.00</span></span>
|
||||
<span class="ra-f muted"><span class="ra-f-label">关联审批单</span><span class="ra-f-value">已关联 1 条</span></span>
|
||||
<span class="ra-f"><span class="ra-f-label">备注</span><span class="ra-f-value">含打车费</span></span>
|
||||
</div>
|
||||
<div class="ra-hint">↑ 嵌套字段只有计数文案;即使点「展开」也看不到单 B 的业务字段</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pros"><strong>优点</strong>:实现简单、无递归性能风险。<strong>缺点</strong>:信息不足,正是你遇到的问题。</div>
|
||||
</article>
|
||||
|
||||
<!-- B: jump only -->
|
||||
<article class="option" data-choice="b-jump" onclick="selectOption(this)">
|
||||
<div class="option-head">
|
||||
<div class="badge">B</div>
|
||||
<div class="option-meta">
|
||||
<h3>计数 + 可跳转详情</h3>
|
||||
<p>仍显示「已关联 M 条」,但做成可点链接,跳进被关联单详情查看</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mock-body">
|
||||
<div class="field-label">关联审批单</div>
|
||||
<div class="ra-card" data-stop>
|
||||
<div class="ra-head">
|
||||
<button type="button" class="ra-title">差旅报销 · 07-18</button>
|
||||
<button type="button" class="ra-toggle" data-expand="b1">展开</button>
|
||||
</div>
|
||||
<div class="ra-fields collapsed" id="b1">
|
||||
<span class="ra-f"><span class="ra-f-label">事由</span><span class="ra-f-value">客户拜访</span></span>
|
||||
<span class="ra-f"><span class="ra-f-label">金额</span><span class="ra-f-value">¥1,280.00</span></span>
|
||||
<span class="ra-f"><span class="ra-f-label">关联审批单</span><span class="ra-f-value"><a href="#" style="color:var(--primary)" onclick="event.preventDefault();alert('跳转到:外出申请详情')">已关联 1 条 → 查看</a></span></span>
|
||||
<span class="ra-f"><span class="ra-f-label">备注</span><span class="ra-f-value">含打车费</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pros"><strong>优点</strong>:改动小、无嵌套 UI。<strong>缺点</strong>:仍无缩略预览,要跳页才能看清。</div>
|
||||
</article>
|
||||
|
||||
<!-- C: one-level nested cards -->
|
||||
<article class="option" data-choice="c-one-level" onclick="selectOption(this)">
|
||||
<div class="option-head">
|
||||
<div class="badge">C</div>
|
||||
<div class="option-meta">
|
||||
<h3>一层嵌套卡片(推荐倾向)</h3>
|
||||
<p>展开单 A 后,嵌套关联展示为子卡片(应用名·时间 + 前 3 字段);再往下仍用计数,不无限递归</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mock-body">
|
||||
<div class="field-label">关联审批单</div>
|
||||
<div class="ra-card" data-stop>
|
||||
<div class="ra-head">
|
||||
<button type="button" class="ra-title">差旅报销 · 07-18</button>
|
||||
<button type="button" class="ra-toggle" data-expand="c1" data-nested="c1n">展开</button>
|
||||
</div>
|
||||
<div class="ra-fields collapsed" id="c1">
|
||||
<span class="ra-f"><span class="ra-f-label">事由</span><span class="ra-f-value">客户拜访</span></span>
|
||||
<span class="ra-f"><span class="ra-f-label">金额</span><span class="ra-f-value">¥1,280.00</span></span>
|
||||
<span class="ra-f"><span class="ra-f-label">备注</span><span class="ra-f-value">含打车费</span></span>
|
||||
</div>
|
||||
<div class="ra-nested" id="c1n">
|
||||
<div class="field-label" style="margin:0">内含关联审批单 · 1 条</div>
|
||||
<div class="ra-card ra-card--nested">
|
||||
<div class="ra-head">
|
||||
<button type="button" class="ra-title">外出申请 · 07-10</button>
|
||||
<button type="button" class="ra-toggle" data-expand="c1b">展开</button>
|
||||
</div>
|
||||
<div class="ra-fields collapsed" id="c1b">
|
||||
<span class="ra-f"><span class="ra-f-label">目的地</span><span class="ra-f-value">上海</span></span>
|
||||
<span class="ra-f"><span class="ra-f-label">起止</span><span class="ra-f-value">07-10 ~ 07-12</span></span>
|
||||
<span class="ra-f"><span class="ra-f-label">同行人</span><span class="ra-f-value">张三</span></span>
|
||||
<span class="ra-f muted"><span class="ra-f-label">关联审批单</span><span class="ra-f-value">已关联 0 条</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pros"><strong>优点</strong>:有缩略与展开,信息完整可控。<strong>缺点</strong>:enrichment 需多取一层;UI 略复杂。</div>
|
||||
</article>
|
||||
|
||||
<!-- D: deep recursive -->
|
||||
<article class="option" data-choice="d-recursive" onclick="selectOption(this)">
|
||||
<div class="option-head">
|
||||
<div class="badge">D</div>
|
||||
<div class="option-meta">
|
||||
<h3>无限递归嵌套</h3>
|
||||
<p>每一层关联都可以再展开子关联,形成树状</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mock-body">
|
||||
<div class="field-label">关联审批单</div>
|
||||
<div class="ra-card" data-stop>
|
||||
<div class="ra-head">
|
||||
<button type="button" class="ra-title">差旅报销 · 07-18</button>
|
||||
<span class="ra-toggle" style="cursor:default;color:var(--text-muted)">已展开</span>
|
||||
</div>
|
||||
<div class="ra-fields">
|
||||
<span class="ra-f"><span class="ra-f-label">事由</span><span class="ra-f-value">客户拜访</span></span>
|
||||
</div>
|
||||
<div class="ra-nested open">
|
||||
<div class="ra-card ra-card--nested">
|
||||
<div class="ra-head">
|
||||
<button type="button" class="ra-title">外出申请 · 07-10</button>
|
||||
</div>
|
||||
<div class="ra-nested open" style="border-top:1px dashed #d1fae5;margin-top:8px;padding-top:8px">
|
||||
<div class="ra-card ra-card--nested" style="opacity:.85">
|
||||
<div class="ra-head">
|
||||
<button type="button" class="ra-title">立项申请 · 06-01</button>
|
||||
</div>
|
||||
<div class="ra-hint">…可能继续嵌套</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pros"><strong>优点</strong>:最完整。<strong>缺点</strong>:性能、权限、循环引用、UI 噪声都更大,一般不建议 V1.x。</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="footer-note">
|
||||
说明:V1 设计文档 §5 明确写了「嵌套 relatedApproval → 已关联 M 条(不递归展开)」。
|
||||
你遇到的现象符合现规格,不是前端展开按钮单独坏了。接下来要决定是否修订规格(例如采用方案 C)。
|
||||
文件路径:<code>docs/mockups/oa-related-approval-nested-demo.html</code>
|
||||
</div>
|
||||
|
||||
<div class="pick-bar">
|
||||
<span>当前选择:<em id="picked">尚未选择</em></span>
|
||||
<span>也可直接在对话里回复 A / B / C / D</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function selectOption(el) {
|
||||
document.querySelectorAll('.option').forEach((n) => n.classList.remove('is-selected'))
|
||||
el.classList.add('is-selected')
|
||||
const choice = el.getAttribute('data-choice')
|
||||
const title = el.querySelector('h3')?.childNodes[0]?.textContent?.trim() || choice
|
||||
document.getElementById('picked').textContent = title
|
||||
window.__nestedChoice = choice
|
||||
console.log('[demo] selected', choice)
|
||||
}
|
||||
|
||||
document.querySelectorAll('[data-stop]').forEach((el) => {
|
||||
el.addEventListener('click', (e) => e.stopPropagation())
|
||||
})
|
||||
|
||||
document.querySelectorAll('[data-expand]').forEach((btn) => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation()
|
||||
const id = btn.getAttribute('data-expand')
|
||||
const fields = document.getElementById(id)
|
||||
if (!fields) return
|
||||
const collapsed = fields.classList.toggle('collapsed')
|
||||
btn.textContent = collapsed ? '展开' : '收起'
|
||||
const nestedId = btn.getAttribute('data-nested')
|
||||
if (nestedId) {
|
||||
document.getElementById(nestedId)?.classList.toggle('open', !collapsed)
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,280 @@
|
||||
<!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;
|
||||
--text: #334155;
|
||||
--text-muted: #94a3b8;
|
||||
--text-strong: #0f172a;
|
||||
--border: #e2e8f0;
|
||||
--bg: #f1f5f9;
|
||||
--line: #94a3b8;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
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: var(--bg);
|
||||
}
|
||||
.page { max-width: 1100px; margin: 0 auto; padding: 28px 24px 80px; }
|
||||
h1 { margin: 0 0 8px; font-size: 22px; color: var(--text-strong); }
|
||||
.lead { margin: 0 0 12px; color: var(--text-muted); max-width: 780px; }
|
||||
.decision {
|
||||
margin-bottom: 20px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
background: var(--primary-bg);
|
||||
border: 1px solid #a7f3d0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.decision strong { color: var(--primary-dark); }
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
@media (min-width: 960px) {
|
||||
.grid { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
.option {
|
||||
background: #fff;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
}
|
||||
.option:hover { border-color: #a7f3d0; }
|
||||
.option.is-selected {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 2px rgb(16 185 129 / 25%);
|
||||
}
|
||||
.option.is-bad { opacity: 0.92; }
|
||||
.head {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 14px 16px 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: #fafafa;
|
||||
}
|
||||
.badge {
|
||||
width: 28px; height: 28px; border-radius: 8px;
|
||||
display: grid; place-items: center;
|
||||
background: var(--primary-bg); color: var(--primary-dark);
|
||||
font-weight: 700; font-size: 13px; flex-shrink: 0;
|
||||
}
|
||||
.option.is-bad .badge { background: #fee2e2; color: #b91c1c; }
|
||||
.meta h3 { margin: 0 0 4px; font-size: 15px; color: var(--text-strong); }
|
||||
.meta p { margin: 0; font-size: 12.5px; color: var(--text-muted); }
|
||||
.tag {
|
||||
display: inline-block; margin-left: 6px; padding: 1px 6px;
|
||||
border-radius: 4px; font-size: 11px; font-weight: 600; vertical-align: middle;
|
||||
}
|
||||
.tag-now { background: #fef3c7; color: #b45309; }
|
||||
.tag-warn { background: #fee2e2; color: #b91c1c; }
|
||||
.mock { padding: 14px 16px; background: #eef2f7; }
|
||||
/* mini print sheet */
|
||||
.sheet {
|
||||
background: #fff;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 4px;
|
||||
padding: 14px 14px 12px;
|
||||
box-shadow: 0 1px 2px rgb(15 23 42 / 6%);
|
||||
}
|
||||
.sheet-title {
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.sheet-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
table.print {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
table.print th,
|
||||
table.print td {
|
||||
border: 1px solid var(--line);
|
||||
padding: 6px 8px;
|
||||
vertical-align: top;
|
||||
}
|
||||
table.print th {
|
||||
width: 28%;
|
||||
background: #f8fafc;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
text-align: left;
|
||||
}
|
||||
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; word-break: break-all; color: #64748b; }
|
||||
.hl { background: #ecfdf5; }
|
||||
.hl-bad { background: #fef2f2; }
|
||||
.note {
|
||||
margin: 0 16px 16px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
background: #f1f5f9;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.note strong { color: var(--primary-dark); }
|
||||
.option.is-bad .note strong { color: #b91c1c; }
|
||||
.pick-bar {
|
||||
position: sticky; bottom: 0;
|
||||
margin: 24px -24px -80px;
|
||||
padding: 14px 24px;
|
||||
background: rgb(255 255 255 / 94%);
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex; justify-content: space-between; gap: 12px;
|
||||
font-size: 13px; color: var(--text-muted);
|
||||
}
|
||||
.pick-bar em { font-style: normal; font-weight: 600; color: var(--primary-dark); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<h1>关联审批单 · 打印(PDF)如何展示?</h1>
|
||||
<p class="lead">
|
||||
屏幕端你已选 <strong>方案 B</strong>:嵌套关联显示「已关联 M 条」,可点进详情。
|
||||
但「打印」导出的是静态 PDF,<strong>无法点击跳转</strong>。请选择打印行里该字段怎么写。
|
||||
</p>
|
||||
<div class="decision">
|
||||
<strong>现状问题:</strong>打印逻辑把 <code>relatedApproval</code> 的值当普通数组格式化,
|
||||
会直接打出一串 instanceId(UUID),既不可读,也和屏幕端「已关联 M 条」不一致。
|
||||
</div>
|
||||
|
||||
<div class="grid" id="options">
|
||||
<!-- P0 current bad -->
|
||||
<article class="option is-bad" data-choice="p0-raw-id" onclick="pick(this)">
|
||||
<div class="head">
|
||||
<div class="badge">0</div>
|
||||
<div class="meta">
|
||||
<h3>现状(未特判)<span class="tag tag-warn">需改</span></h3>
|
||||
<p>直接打印 UUID 列表 —— 不可接受</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mock">
|
||||
<div class="sheet">
|
||||
<div class="sheet-title">差旅报销</div>
|
||||
<div class="sheet-meta"><span>安徽中科兴皖医学研究有限公司</span><span>创建时间:2026-07-20</span></div>
|
||||
<table class="print">
|
||||
<tr><th>审批编号</th><td>57F72ADA…</td></tr>
|
||||
<tr><th>事由</th><td>客户拜访</td></tr>
|
||||
<tr class="hl-bad"><th>关联审批单</th><td class="mono">B6244B87…、C1D2E3F4…</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="note"><strong>问题</strong>:纸面无法读,也不符合方案 B 语义。</div>
|
||||
</article>
|
||||
|
||||
<!-- P1 count only -->
|
||||
<article class="option" data-choice="p1-count" onclick="pick(this)">
|
||||
<div class="head">
|
||||
<div class="badge">1</div>
|
||||
<div class="meta">
|
||||
<h3>仅计数(严格对齐屏幕 B)</h3>
|
||||
<p>打印「已关联 M 条」;纸面无法跳转,信息最少</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mock">
|
||||
<div class="sheet">
|
||||
<div class="sheet-title">差旅报销</div>
|
||||
<div class="sheet-meta"><span>安徽中科兴皖医学研究有限公司</span><span>创建时间:2026-07-20</span></div>
|
||||
<table class="print">
|
||||
<tr><th>审批编号</th><td>57F72ADA…</td></tr>
|
||||
<tr><th>事由</th><td>客户拜访</td></tr>
|
||||
<tr class="hl"><th>关联审批单</th><td>已关联 2 条</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="note"><strong>优点</strong>:实现最简单、与屏幕文案一致。<strong>缺点</strong>:打印归档时看不出关联了哪几单。</div>
|
||||
</article>
|
||||
|
||||
<!-- P2 names list - recommended with B -->
|
||||
<article class="option" data-choice="p2-names" onclick="pick(this)">
|
||||
<div class="head">
|
||||
<div class="badge">2</div>
|
||||
<div class="meta">
|
||||
<h3>可读清单(推荐配合 B)</h3>
|
||||
<p>一行汇总:应用名 · 日期(多条顿号分隔);不展开对方全字段</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mock">
|
||||
<div class="sheet">
|
||||
<div class="sheet-title">差旅报销</div>
|
||||
<div class="sheet-meta"><span>安徽中科兴皖医学研究有限公司</span><span>创建时间:2026-07-20</span></div>
|
||||
<table class="print">
|
||||
<tr><th>审批编号</th><td>57F72ADA…</td></tr>
|
||||
<tr><th>事由</th><td>客户拜访</td></tr>
|
||||
<tr class="hl"><th>关联审批单</th><td>外出申请 · 07-10、立项申请 · 06-01</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="note"><strong>优点</strong>:纸面可辨认关联对象;仍不递归嵌套内容。<strong>缺点</strong>:打印前需 enrichment 取应用名/时间。</div>
|
||||
</article>
|
||||
|
||||
<!-- P3 multi-line -->
|
||||
<article class="option" data-choice="p3-multiline" onclick="pick(this)">
|
||||
<div class="head">
|
||||
<div class="badge">3</div>
|
||||
<div class="meta">
|
||||
<h3>多行清单</h3>
|
||||
<p>每条关联单独占一行(或单元格内换行),含应用名、时间、可选编号尾号</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mock">
|
||||
<div class="sheet">
|
||||
<div class="sheet-title">差旅报销</div>
|
||||
<div class="sheet-meta"><span>安徽中科兴皖医学研究有限公司</span><span>创建时间:2026-07-20</span></div>
|
||||
<table class="print">
|
||||
<tr><th>审批编号</th><td>57F72ADA…</td></tr>
|
||||
<tr><th>事由</th><td>客户拜访</td></tr>
|
||||
<tr class="hl">
|
||||
<th>关联审批单</th>
|
||||
<td>
|
||||
1. 外出申请 · 2026-07-10<br />
|
||||
2. 立项申请 · 2026-06-01
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="note"><strong>优点</strong>:多条时更清晰。<strong>缺点</strong>:表格略占高;仍不打印对方表单正文。</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<p class="lead" style="margin-top:20px">
|
||||
关于「嵌套」:若关联单 A 里还有关联单 B,打印侧建议与屏幕 B 一致 ——
|
||||
<strong>不递归打印 B 的字段</strong>,只打印当前单直接关联的那一层可读清单(方案 2/3)。
|
||||
</p>
|
||||
|
||||
<div class="pick-bar">
|
||||
<span>当前选择:<em id="picked">尚未选择</em></span>
|
||||
<span>也可回复:1 / 2 / 3(0 为需废弃的现状)</span>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
function pick(el) {
|
||||
document.querySelectorAll('.option').forEach((n) => n.classList.remove('is-selected'))
|
||||
el.classList.add('is-selected')
|
||||
const t = el.querySelector('h3')?.childNodes[0]?.textContent?.trim() || el.dataset.choice
|
||||
document.getElementById('picked').textContent = t
|
||||
window.__printChoice = el.dataset.choice
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,741 @@
|
||||
# OA Related Approval Nested + Print 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:** Make nested「已关联 M 条」clickable on screen, and make PDF print render related approvals as multi-line field blocks with up to 3 levels of recursion (deduped).
|
||||
|
||||
**Architecture:** Keep screen enrichment flat (`value` + optional `relatedIds`). Do not return a summary tree from the API. Print path asynchronously batches `fetchOaRelatedSummaries`, then a pure recursive builder (`maxDepth=3`, `visited`) produces `RelatedPrintBlock[]` for `OaApprovalPrintSheet`.
|
||||
|
||||
**Tech Stack:** FastAPI · pytest · Vue 3 · TypeScript · existing `oa-approval-print.ts` / html2canvas / jspdf · Element Plus
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-07-22-oa-related-approval-nested-print-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File map
|
||||
|
||||
| Path | Responsibility |
|
||||
|------|----------------|
|
||||
| Modify: `backend/app/services/oa_related_approval.py` | Flatten relatedApproval fields with `relatedIds` |
|
||||
| Modify: `backend/tests/test_oa_related_approval.py` | Assert `relatedIds` on nested/related flatten |
|
||||
| Modify: `web-admin/src/api/oaApproval.ts` | `OaRelatedSummaryField.relatedIds?: string[]` |
|
||||
| Modify: `web-admin/src/components/oa-approval/OaRelatedApproval.vue` | Clickable「已关联 M 条」→ detail |
|
||||
| Create: `web-admin/src/utils/oa-approval-print-related.ts` | Pure recursive block builder + async fetch wrapper |
|
||||
| Modify: `web-admin/src/utils/oa-approval-print.ts` | Async form rows; special-case `relatedApproval` |
|
||||
| Modify: `web-admin/src/components/oa-approval/OaApprovalPrintSheet.vue` | Render `blocks` with indent |
|
||||
| Verify: V1 spec cross-ref (already updated in brainstorm) | No further doc task unless drift |
|
||||
|
||||
**Locked rules:**
|
||||
- Screen: nested = count text + jump (`M=1` → that id; `M>1` → first id). No nested cards.
|
||||
- Print: multi-line blocks; recurse on `relatedIds` while `depth < 3`; at depth 3 keep count text only; duplicate id → title `(已在上文出现)`.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Backend flatten emits `relatedIds` (TDD)
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/tests/test_oa_related_approval.py`
|
||||
- Modify: `backend/app/services/oa_related_approval.py`
|
||||
|
||||
- [ ] **Step 1: Write failing test for `relatedIds`**
|
||||
|
||||
Add to `TestFlatten` in `backend/tests/test_oa_related_approval.py`:
|
||||
|
||||
```python
|
||||
def test_related_approval_field_includes_related_ids(self):
|
||||
schema = {
|
||||
"rule": [
|
||||
{"type": "input", "field": "reason", "title": "事由"},
|
||||
{"type": "relatedApproval", "field": "prev", "title": "关联审批单"},
|
||||
]
|
||||
}
|
||||
data = {"reason": "拜访", "prev": ["id1", "", "id2", "id1"]}
|
||||
fields = flatten_form_fields_for_summary(schema, data)
|
||||
rel = next(f for f in fields if f["label"] == "关联审批单")
|
||||
self.assertEqual(rel["value"], "已关联 2 条")
|
||||
self.assertEqual(rel["relatedIds"], ["id1", "id2"])
|
||||
|
||||
def test_related_approval_empty_ids(self):
|
||||
schema = {"rule": [{"type": "relatedApproval", "field": "prev", "title": "关联"}]}
|
||||
fields = flatten_form_fields_for_summary(schema, {"prev": []})
|
||||
self.assertEqual(fields[0]["value"], "已关联 0 条")
|
||||
self.assertEqual(fields[0]["relatedIds"], [])
|
||||
```
|
||||
|
||||
Also update existing `test_skips_layout_and_summarizes_upload` assertion for the related field — after change it should allow `relatedIds`:
|
||||
|
||||
```python
|
||||
self.assertEqual(fields[2]["value"], "已关联 2 条")
|
||||
self.assertEqual(fields[2].get("relatedIds"), ["id1", "id2"])
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test — expect FAIL**
|
||||
|
||||
```bash
|
||||
cd backend && source venv/bin/activate && pytest tests/test_oa_related_approval.py::TestFlatten::test_related_approval_field_includes_related_ids -v
|
||||
```
|
||||
|
||||
Expected: `AssertionError` / `KeyError` — no `relatedIds`.
|
||||
|
||||
- [ ] **Step 3: Implement flatten change**
|
||||
|
||||
In `backend/app/services/oa_related_approval.py`, add helper and change `flatten_form_fields_for_summary`:
|
||||
|
||||
```python
|
||||
def _normalize_related_ids(raw: Any) -> List[str]:
|
||||
if isinstance(raw, list):
|
||||
items = raw
|
||||
elif raw is None or raw == "":
|
||||
items = []
|
||||
else:
|
||||
items = [raw]
|
||||
out: List[str] = []
|
||||
seen = set()
|
||||
for x in items:
|
||||
t = str(x or "").strip()
|
||||
if not t or t in seen:
|
||||
continue
|
||||
seen.add(t)
|
||||
out.append(t)
|
||||
return out
|
||||
|
||||
|
||||
def flatten_form_fields_for_summary(schema: Any, form_data: Optional[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
data = form_data or {}
|
||||
out: List[Dict[str, Any]] = []
|
||||
for rule in _schema_rules(schema):
|
||||
typ = str(rule.get("type") or "")
|
||||
if not typ or typ in LAYOUT_SKIP:
|
||||
continue
|
||||
field = str(rule.get("field") or "").strip()
|
||||
if not field:
|
||||
continue
|
||||
label = str(rule.get("title") or rule.get("label") or field)
|
||||
raw = data.get(field)
|
||||
if typ == "relatedApproval":
|
||||
ids = _normalize_related_ids(raw)
|
||||
out.append(
|
||||
{
|
||||
"label": label,
|
||||
"value": f"已关联 {len(ids)} 条",
|
||||
"relatedIds": ids,
|
||||
}
|
||||
)
|
||||
continue
|
||||
text, full = _format_value(typ, raw)
|
||||
item: Dict[str, Any] = {"label": label, "value": text}
|
||||
if full:
|
||||
item["fullWidth"] = True
|
||||
out.append(item)
|
||||
return out
|
||||
```
|
||||
|
||||
Keep `_format_value` relatedApproval branch for any other callers, or leave as-is (dead for flatten path).
|
||||
|
||||
- [ ] **Step 4: Run full related-approval tests — expect PASS**
|
||||
|
||||
```bash
|
||||
cd backend && source venv/bin/activate && pytest tests/test_oa_related_approval.py -v
|
||||
```
|
||||
|
||||
Expected: all PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/services/oa_related_approval.py backend/tests/test_oa_related_approval.py
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(oa): emit relatedIds when flattening nested relatedApproval fields
|
||||
|
||||
Screen jump and print recursion need the instance id list alongside the
|
||||
「已关联 M 条」summary text without changing form_data shape.
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Frontend types + clickable nested count
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/api/oaApproval.ts`
|
||||
- Modify: `web-admin/src/components/oa-approval/OaRelatedApproval.vue`
|
||||
|
||||
- [ ] **Step 1: Extend field type**
|
||||
|
||||
In `web-admin/src/api/oaApproval.ts`:
|
||||
|
||||
```ts
|
||||
export type OaRelatedSummaryField = {
|
||||
label: string
|
||||
value: string
|
||||
fullWidth?: boolean
|
||||
/** 嵌套关联审批单的 instanceId 列表(可跳转 / 打印递归) */
|
||||
relatedIds?: string[]
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Render clickable count in card fields**
|
||||
|
||||
In `OaRelatedApproval.vue` field value area, replace the plain `{{ f.value }}` branch with:
|
||||
|
||||
```vue
|
||||
<template v-else-if="(f.relatedIds?.length || 0) > 0">
|
||||
<button
|
||||
type="button"
|
||||
class="oa-related-approval__link-btn"
|
||||
@click.stop="goDetail(f.relatedIds![0])"
|
||||
>
|
||||
{{ f.value }}
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>{{ f.value }}</template>
|
||||
```
|
||||
|
||||
Add style (native button, survives `form.disabled`):
|
||||
|
||||
```scss
|
||||
.oa-related-approval__link-btn {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--primary, #10b981);
|
||||
font-size: inherit;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
.oa-related-approval__link-btn:hover {
|
||||
color: var(--primary-dark, #059669);
|
||||
}
|
||||
```
|
||||
|
||||
Reuse existing `goDetail(id)` (router push to `/system-manage/oa-approval/instances/${id}`).
|
||||
|
||||
- [ ] **Step 3: Manual smoke**
|
||||
|
||||
1. Open an instance whose related card shows a nested field「已关联 1 条」with backend returning `relatedIds`.
|
||||
2. Click the count → navigates to that instance.
|
||||
3. Confirm no nested sub-cards appear.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add web-admin/src/api/oaApproval.ts web-admin/src/components/oa-approval/OaRelatedApproval.vue
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(web-admin): make nested relatedApproval count jump to first instance
|
||||
|
||||
Uses relatedIds from enrichment so screen stays count-only without nested cards.
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Pure print block builder
|
||||
|
||||
**Files:**
|
||||
- Create: `web-admin/src/utils/oa-approval-print-related.ts`
|
||||
|
||||
- [ ] **Step 1: Add types + pure builder**
|
||||
|
||||
Create `web-admin/src/utils/oa-approval-print-related.ts`:
|
||||
|
||||
```ts
|
||||
import type { OaRelatedSummary, OaRelatedSummaryField } from '@/api/oaApproval'
|
||||
import { fetchOaRelatedSummaries } from '@/api/oaApproval'
|
||||
|
||||
export const OA_RELATED_PRINT_MAX_DEPTH = 3
|
||||
|
||||
export type RelatedPrintBlock = {
|
||||
indexLabel: string
|
||||
title: string
|
||||
lines?: { label: string; value: string }[]
|
||||
children?: RelatedPrintBlock[]
|
||||
}
|
||||
|
||||
function datePart(applyTime: string) {
|
||||
const t = String(applyTime || '').trim()
|
||||
return t ? t.slice(0, 10) : ''
|
||||
}
|
||||
|
||||
function titleOf(s: OaRelatedSummary) {
|
||||
if (s.unavailable) return '无法查看'
|
||||
const name = String(s.appName || '审批单').trim() || '审批单'
|
||||
const d = datePart(s.applyTime)
|
||||
return d ? `${name} · ${d}` : name
|
||||
}
|
||||
|
||||
/** Pure: build blocks from an already-loaded summary map (for tests / recursion). */
|
||||
export function buildRelatedPrintBlocksFromMap(
|
||||
ids: string[],
|
||||
summaryById: Map<string, OaRelatedSummary>,
|
||||
opts: {
|
||||
depth: number
|
||||
maxDepth?: number
|
||||
visited: Set<string>
|
||||
indexPrefix?: string
|
||||
}
|
||||
): RelatedPrintBlock[] {
|
||||
const maxDepth = opts.maxDepth ?? OA_RELATED_PRINT_MAX_DEPTH
|
||||
const depth = opts.depth
|
||||
const visited = opts.visited
|
||||
const prefix = opts.indexPrefix || ''
|
||||
const blocks: RelatedPrintBlock[] = []
|
||||
|
||||
ids.forEach((rawId, i) => {
|
||||
const id = String(rawId || '').trim()
|
||||
if (!id) return
|
||||
const indexLabel = prefix ? `${prefix}.${i + 1}` : String(i + 1)
|
||||
|
||||
if (visited.has(id)) {
|
||||
blocks.push({ indexLabel, title: '(已在上文出现)' })
|
||||
return
|
||||
}
|
||||
visited.add(id)
|
||||
|
||||
const s = summaryById.get(id)
|
||||
if (!s) {
|
||||
blocks.push({ indexLabel, title: '关联信息加载失败' })
|
||||
return
|
||||
}
|
||||
if (s.unavailable) {
|
||||
blocks.push({ indexLabel, title: '无法查看' })
|
||||
return
|
||||
}
|
||||
|
||||
const lines: { label: string; value: string }[] = []
|
||||
const children: RelatedPrintBlock[] = []
|
||||
const fields = Array.isArray(s.fields) ? s.fields : []
|
||||
|
||||
for (const f of fields) {
|
||||
const label = String(f.label || '').trim() || '-'
|
||||
const value = String(f.value || '').trim() || '-'
|
||||
const nestedIds = (f.relatedIds || []).map((x) => String(x || '').trim()).filter(Boolean)
|
||||
|
||||
if (nestedIds.length && depth < maxDepth) {
|
||||
// Parent line keeps count text; children expand underneath
|
||||
lines.push({ label, value })
|
||||
children.push(
|
||||
...buildRelatedPrintBlocksFromMap(nestedIds, summaryById, {
|
||||
depth: depth + 1,
|
||||
maxDepth,
|
||||
visited,
|
||||
indexPrefix: indexLabel
|
||||
})
|
||||
)
|
||||
} else {
|
||||
lines.push({ label, value })
|
||||
}
|
||||
}
|
||||
|
||||
blocks.push({
|
||||
indexLabel,
|
||||
title: titleOf(s),
|
||||
lines,
|
||||
children: children.length ? children : undefined
|
||||
})
|
||||
})
|
||||
|
||||
return blocks
|
||||
}
|
||||
|
||||
async function loadSummariesIntoMap(ids: string[], map: Map<string, OaRelatedSummary>) {
|
||||
const missing = [...new Set(ids.map((x) => String(x || '').trim()).filter(Boolean))].filter(
|
||||
(id) => !map.has(id)
|
||||
)
|
||||
if (!missing.length) return
|
||||
try {
|
||||
const res: any = await fetchOaRelatedSummaries(missing, 'view')
|
||||
const list: OaRelatedSummary[] = Array.isArray(res?.list) ? res.list : Array.isArray(res) ? res : []
|
||||
for (const s of list) {
|
||||
if (s?.id) map.set(String(s.id), s)
|
||||
}
|
||||
for (const id of missing) {
|
||||
if (!map.has(id)) {
|
||||
map.set(id, {
|
||||
id,
|
||||
unavailable: true,
|
||||
appName: '',
|
||||
applyTime: '',
|
||||
status: '',
|
||||
fields: []
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
for (const id of missing) {
|
||||
if (!map.has(id)) {
|
||||
map.set(id, {
|
||||
id,
|
||||
unavailable: true,
|
||||
appName: '',
|
||||
applyTime: '',
|
||||
status: '',
|
||||
fields: []
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Collect nested relatedIds up to maxDepth for batch prefetch. */
|
||||
function collectNestedIds(
|
||||
ids: string[],
|
||||
map: Map<string, OaRelatedSummary>,
|
||||
depth: number,
|
||||
maxDepth: number,
|
||||
acc: Set<string>
|
||||
) {
|
||||
for (const id of ids) {
|
||||
const t = String(id || '').trim()
|
||||
if (!t || acc.has(t)) continue
|
||||
acc.add(t)
|
||||
if (depth >= maxDepth) continue
|
||||
const s = map.get(t)
|
||||
if (!s || s.unavailable) continue
|
||||
for (const f of s.fields || []) {
|
||||
const nested = (f.relatedIds || []).map((x) => String(x || '').trim()).filter(Boolean)
|
||||
for (const n of nested) acc.add(n)
|
||||
if (nested.length) collectNestedIds(nested, map, depth + 1, maxDepth, acc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefetch summaries (breadth waves up to maxDepth) then build blocks.
|
||||
* depth starts at 1 for ids on the printed form's relatedApproval field.
|
||||
*/
|
||||
export async function assembleRelatedPrintBlocks(
|
||||
ids: string[],
|
||||
opts?: { maxDepth?: number }
|
||||
): Promise<RelatedPrintBlock[]> {
|
||||
const maxDepth = opts?.maxDepth ?? OA_RELATED_PRINT_MAX_DEPTH
|
||||
const rootIds = [...new Set(ids.map((x) => String(x || '').trim()).filter(Boolean))]
|
||||
if (!rootIds.length) return []
|
||||
|
||||
const map = new Map<string, OaRelatedSummary>()
|
||||
// Wave fetch: load roots, then any nested ids referenced, up to maxDepth
|
||||
await loadSummariesIntoMap(rootIds, map)
|
||||
for (let d = 1; d < maxDepth; d++) {
|
||||
const need = new Set<string>()
|
||||
collectNestedIds(rootIds, map, 1, maxDepth, need)
|
||||
const missing = [...need].filter((id) => !map.has(id))
|
||||
if (!missing.length) break
|
||||
await loadSummariesIntoMap(missing, map)
|
||||
}
|
||||
|
||||
return buildRelatedPrintBlocksFromMap(rootIds, map, {
|
||||
depth: 1,
|
||||
maxDepth,
|
||||
visited: new Set()
|
||||
})
|
||||
}
|
||||
|
||||
export function normalizeRelatedIdsFromFormValue(raw: unknown): string[] {
|
||||
if (Array.isArray(raw)) {
|
||||
const out: string[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const x of raw) {
|
||||
const t = String(x || '').trim()
|
||||
if (!t || seen.has(t)) continue
|
||||
seen.add(t)
|
||||
out.push(t)
|
||||
}
|
||||
return out
|
||||
}
|
||||
if (raw == null || raw === '') return []
|
||||
const t = String(raw).trim()
|
||||
return t ? [t] : []
|
||||
}
|
||||
|
||||
/** @internal exported for quick node assert — optional smoke */
|
||||
export function _smokeBuildDuplicate() {
|
||||
const map = new Map<string, OaRelatedSummary>()
|
||||
map.set('a', {
|
||||
id: 'a',
|
||||
appName: '单A',
|
||||
applyTime: '2026-07-10 12:00:00',
|
||||
status: 'completed',
|
||||
fields: [{ label: '关联', value: '已关联 1 条', relatedIds: ['a'] }]
|
||||
})
|
||||
return buildRelatedPrintBlocksFromMap(['a'], map, { depth: 1, visited: new Set() })
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Quick sanity in Node (optional)**
|
||||
|
||||
From `web-admin` (or skip if no ts-node). Prefer manual reason:
|
||||
|
||||
- Self-ref `a → a`: first block full; nested child title `(已在上文出现)`.
|
||||
- Depth: when `depth === 3`, nested `relatedIds` must not create children.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add web-admin/src/utils/oa-approval-print-related.ts
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(web-admin): add related-approval print block assembler
|
||||
|
||||
Pure map-based recursion with maxDepth=3 and visited dedupe; async
|
||||
wrapper batch-fetches summaries for PDF export.
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Wire print model + sheet UI
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/utils/oa-approval-print.ts`
|
||||
- Modify: `web-admin/src/components/oa-approval/OaApprovalPrintSheet.vue`
|
||||
|
||||
- [ ] **Step 1: Extend `OaPrintFieldRow` and make form-row build async**
|
||||
|
||||
In `oa-approval-print.ts`:
|
||||
|
||||
```ts
|
||||
import {
|
||||
assembleRelatedPrintBlocks,
|
||||
normalizeRelatedIdsFromFormValue,
|
||||
type RelatedPrintBlock
|
||||
} from '@/utils/oa-approval-print-related'
|
||||
|
||||
export type OaPrintFieldRow = {
|
||||
label: string
|
||||
text?: string
|
||||
urls?: string[]
|
||||
/** 关联审批单多行块(优先于 text) */
|
||||
blocks?: RelatedPrintBlock[]
|
||||
}
|
||||
```
|
||||
|
||||
Change `walkRules` / `buildFormRows` to async:
|
||||
|
||||
```ts
|
||||
async function walkRules(
|
||||
rules: any[],
|
||||
formData: Record<string, any>,
|
||||
out: OaPrintFieldRow[]
|
||||
) {
|
||||
if (!Array.isArray(rules)) return
|
||||
for (const rule of rules) {
|
||||
if (!rule || typeof rule !== 'object') continue
|
||||
const type = String(rule.type || '').trim()
|
||||
const children = rule.children || rule.props?.rule
|
||||
if (Array.isArray(children) && children.length) {
|
||||
await walkRules(children, formData, out)
|
||||
}
|
||||
if (SKIP_RULE_TYPES.has(type)) continue
|
||||
const field = String(rule.field || '').trim()
|
||||
if (!field) continue
|
||||
const label = String(rule.title || rule.label || rule.props?.label || field).trim() || field
|
||||
const value = formData?.[field]
|
||||
|
||||
if (type === 'relatedApproval') {
|
||||
const ids = normalizeRelatedIdsFromFormValue(value)
|
||||
if (!ids.length) {
|
||||
out.push({ label, text: '-' })
|
||||
continue
|
||||
}
|
||||
const blocks = await assembleRelatedPrintBlocks(ids)
|
||||
out.push({ label, blocks: blocks.length ? blocks : [{ indexLabel: '1', title: '关联信息加载失败' }] })
|
||||
continue
|
||||
}
|
||||
|
||||
// … keep existing upload / url / formatScalar branches …
|
||||
}
|
||||
}
|
||||
|
||||
async function buildFormRows(detail: any): Promise<OaPrintFieldRow[]> {
|
||||
// same as today but await walkRules(...)
|
||||
}
|
||||
|
||||
export async function buildOaPrintModel(
|
||||
detail: any,
|
||||
opts: { printerName: string; printTime?: string }
|
||||
): Promise<OaPrintModel> {
|
||||
// …
|
||||
rows: [...headerRows, ...(await buildFormRows(detail))],
|
||||
// …
|
||||
}
|
||||
|
||||
export async function exportOaApprovalPdf(...) {
|
||||
const model = await buildOaPrintModel(detail, { printerName: opts.printerName })
|
||||
// unchanged mount/canvas
|
||||
}
|
||||
```
|
||||
|
||||
Search callers of `buildOaPrintModel` — only `exportOaApprovalPdf` today; ensure `await`.
|
||||
|
||||
- [ ] **Step 2: Render blocks in `OaApprovalPrintSheet.vue`**
|
||||
|
||||
In the table cell template:
|
||||
|
||||
```vue
|
||||
<template v-if="row.blocks?.length">
|
||||
<div class="oa-print-related">
|
||||
<div
|
||||
v-for="(b, bi) in row.blocks"
|
||||
:key="`b-${idx}-${bi}`"
|
||||
class="oa-print-related__block"
|
||||
>
|
||||
<div class="oa-print-related__title">{{ b.indexLabel }}. {{ b.title }}</div>
|
||||
<div
|
||||
v-for="(line, li) in b.lines || []"
|
||||
:key="`l-${bi}-${li}`"
|
||||
class="oa-print-related__line"
|
||||
>
|
||||
<span class="oa-print-related__k">{{ line.label }}</span>
|
||||
<span class="oa-print-related__v">{{ line.value }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="b.children?.length"
|
||||
class="oa-print-related__children"
|
||||
>
|
||||
<!-- recurse: reuse a small inner component or inline second level -->
|
||||
<div
|
||||
v-for="(c, ci) in b.children"
|
||||
:key="`c-${bi}-${ci}`"
|
||||
class="oa-print-related__block"
|
||||
>
|
||||
<div class="oa-print-related__title">{{ c.indexLabel }}. {{ c.title }}</div>
|
||||
<div
|
||||
v-for="(line, li) in c.lines || []"
|
||||
:key="`cl-${ci}-${li}`"
|
||||
class="oa-print-related__line"
|
||||
>
|
||||
<span class="oa-print-related__k">{{ line.label }}</span>
|
||||
<span class="oa-print-related__v">{{ line.value }}</span>
|
||||
</div>
|
||||
<div v-if="c.children?.length" class="oa-print-related__children">
|
||||
<div
|
||||
v-for="(g, gi) in c.children"
|
||||
:key="`g-${ci}-${gi}`"
|
||||
class="oa-print-related__block"
|
||||
>
|
||||
<div class="oa-print-related__title">{{ g.indexLabel }}. {{ g.title }}</div>
|
||||
<div
|
||||
v-for="(line, li) in g.lines || []"
|
||||
:key="`gl-${gi}-${li}`"
|
||||
class="oa-print-related__line"
|
||||
>
|
||||
<span class="oa-print-related__k">{{ line.label }}</span>
|
||||
<span class="oa-print-related__v">{{ line.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="row.urls?.length">
|
||||
...
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="oa-print-text">{{ row.text || '-' }}</span>
|
||||
</template>
|
||||
```
|
||||
|
||||
SCSS:
|
||||
|
||||
```scss
|
||||
.oa-print-related__block { margin: 0 0 8px; }
|
||||
.oa-print-related__title { font-weight: 600; margin-bottom: 2px; }
|
||||
.oa-print-related__line {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.oa-print-related__k { color: #64748b; flex: 0 0 auto; }
|
||||
.oa-print-related__v { color: #334155; word-break: break-all; }
|
||||
.oa-print-related__children {
|
||||
margin: 4px 0 0 14px;
|
||||
padding-left: 8px;
|
||||
border-left: 2px solid #cbd5e1;
|
||||
}
|
||||
```
|
||||
|
||||
(Three nested template levels cover maxDepth=3 without a recursive SFC.)
|
||||
|
||||
- [ ] **Step 3: Manual PDF check**
|
||||
|
||||
1. Print instance with 1 related approval that has normal fields → PDF shows multi-line block, not UUID.
|
||||
2. Chain A→B→C→D → D appears only as「已关联 M 条」under C (3rd related layer).
|
||||
3. Cycle A→B→A → second A is「(已在上文出现)」.
|
||||
4. Instance without relatedApproval → PDF unchanged.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add web-admin/src/utils/oa-approval-print.ts web-admin/src/components/oa-approval/OaApprovalPrintSheet.vue
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(web-admin): print relatedApproval as recursive multi-line blocks
|
||||
|
||||
PDF export fetches summaries and renders up to three nested levels with
|
||||
dedupe, instead of dumping raw instance ids.
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Spec status + graph + final verify
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/superpowers/specs/2026-07-22-oa-related-approval-nested-print-design.md` (status line)
|
||||
- Run: `./scripts/update-graph.sh frontend` (and backend if Python signatures changed)
|
||||
|
||||
- [ ] **Step 1: Mark spec status**
|
||||
|
||||
Change header status to: `已批准(实现计划见 docs/superpowers/plans/2026-07-22-oa-related-approval-nested-print.md)`
|
||||
|
||||
Confirm V1 §5 already cross-references nested-print design (done in brainstorm).
|
||||
|
||||
- [ ] **Step 2: Regression commands**
|
||||
|
||||
```bash
|
||||
cd backend && source venv/bin/activate && pytest tests/test_oa_related_approval.py -v
|
||||
cd ../web-admin && pnpm exec vue-tsc --noEmit 2>&1 | head -50
|
||||
./scripts/update-graph.sh all
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit docs/graph if dirty**
|
||||
|
||||
```bash
|
||||
git add docs/superpowers/specs/2026-07-22-oa-related-approval-nested-print-design.md \
|
||||
docs/superpowers/plans/2026-07-22-oa-related-approval-nested-print.md \
|
||||
backend/graphify-out web-admin/graphify-out
|
||||
git commit -m "$(cat <<'EOF'
|
||||
docs(oa): add nested relatedApproval print plan and mark spec ready
|
||||
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Spec coverage checklist
|
||||
|
||||
| Spec requirement | Task |
|
||||
|------------------|------|
|
||||
| enrichment `relatedIds` | Task 1 |
|
||||
| Screen clickable「已关联 M 条」 | Task 2 |
|
||||
| Print multi-line + business fields | Task 3–4 |
|
||||
| Recurse max 3 levels | Task 3 (`OA_RELATED_PRINT_MAX_DEPTH`) |
|
||||
| Visited dedupe | Task 3 |
|
||||
| Unavailable / load fail placeholders | Task 3–4 |
|
||||
| No screen nested cards | Task 2 (explicit non-goal) |
|
||||
| Old apps unchanged | Task 4 (only `relatedApproval` branch) |
|
||||
| V1 cross-ref | Task 5 (verify) |
|
||||
|
||||
---
|
||||
|
||||
## Self-review notes
|
||||
|
||||
- No TBD placeholders in steps.
|
||||
- `buildOaPrintModel` becomes async — only `exportOaApprovalPdf` awaits it.
|
||||
- Sheet uses 3 hardcoded nest levels matching maxDepth=3 (no recursive Vue component required).
|
||||
- Prefetch waves may over-fetch slightly; acceptable vs complex BFS queue.
|
||||
@@ -163,7 +163,7 @@ V1 **不**改详情主接口形状:前端根据 form_data / `relatedInstanceId
|
||||
|--------------|----------|
|
||||
| 常规输入/选择/日期/金额等 | `label` + 格式化后的文本值 |
|
||||
| 附件 / 图片 / 上传 | `N 个`(不内嵌文件列表) |
|
||||
| 嵌套 `relatedApproval` | `已关联 M 条`(不递归展开) |
|
||||
| 嵌套 `relatedApproval` | 屏幕:`已关联 M 条`(可跳转,不嵌套缩略卡);打印:多行递归展开业务字段,最多 3 层。细则见 `2026-07-22-oa-related-approval-nested-print-design.md` |
|
||||
| 布局 / 说明 / 纯展示 | 跳过 |
|
||||
| 长文本 | 可标 `fullWidth`;展开时单独占行 |
|
||||
|
||||
@@ -238,3 +238,5 @@ V1 **不**改详情主接口形状:前端根据 form_data / `relatedInstanceId
|
||||
## 11. 修订说明
|
||||
|
||||
相对 2026-07-14 总设 §5.5「选择有权查看的实例」:本需求收窄为 **本人发起 + 已通过**;展示从「可跳转」增强为 **可展开的全字段概要**。若与总设冲突,**以本文为关联审批单专项权威**,总设可在归档时回写一句交叉引用。
|
||||
|
||||
**2026-07-22 嵌套/打印修订**:屏幕嵌套与打印递归行为以 `2026-07-22-oa-related-approval-nested-print-design.md` 为准(本文 §5 嵌套行已改为交叉引用)。
|
||||
@@ -0,0 +1,244 @@
|
||||
# OA「关联审批单」嵌套展示与打印修订设计
|
||||
|
||||
**日期**: 2026-07-22
|
||||
**状态**: 已批准(实现计划见 `docs/superpowers/plans/2026-07-22-oa-related-approval-nested-print.md`)
|
||||
**方案**: 方案 1 — 屏幕保持计数可跳转;打印专用递归组装(最多 3 层)
|
||||
**界面模式**: 屏幕对齐既有关联卡片;打印对齐现有钉钉风 PDF 主表
|
||||
**前置文档**:
|
||||
- `docs/superpowers/specs/2026-07-22-oa-related-approval-design.md`(V1 基线)
|
||||
- `docs/superpowers/specs/2026-07-15-oa-approval-print-pdf-design.md`(打印 PDF)
|
||||
- Demo:`docs/mockups/oa-related-approval-nested-demo.html`、`docs/mockups/oa-related-approval-print-demo.html`
|
||||
|
||||
**决策记录**:
|
||||
- 屏幕 · 嵌套关联:方案 **B** — 显示「已关联 M 条」,可跳转详情,**不**嵌套缩略卡
|
||||
- 打印 · 布局:方案 **3** — 多行清单,每条关联单独成块
|
||||
- 打印 · 内容:打出对方单 **业务字段摘要**;嵌套关联继续递归
|
||||
- 打印 · 深度:最多 **3 层**;同一 `instanceId` 去重防环
|
||||
- 实现路径:方案 **1** — 打印专用递归;不改为统一树形 enrichment API
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与问题
|
||||
|
||||
V1 规格 §5 规定:嵌套 `relatedApproval` 扁平化为「已关联 M 条(不递归展开)」。
|
||||
|
||||
实测:发起/详情选中一张本身也含「关联审批单」的历史单时,卡片上只见「已关联 M 条」,无缩略、无展开子内容;同时打印路径未特判该控件,会把 `string[]` instanceId 打成 UUID,纸面不可读。
|
||||
|
||||
产品确认:
|
||||
|
||||
| 端 | 期望 |
|
||||
|----|------|
|
||||
| 屏幕 | 嵌套保持计数文案,但须可跳转;不在卡片内画子树 |
|
||||
| 打印 | 多行展示关联单信息,含业务字段;嵌套最多递归 3 层 |
|
||||
|
||||
本文件 **修订** V1 在嵌套/打印上的行为;`form_data` 契约与选单范围不变。
|
||||
|
||||
---
|
||||
|
||||
## 2. 目标与范围
|
||||
|
||||
### 2.1 目标
|
||||
|
||||
1. 屏幕:嵌套「已关联 M 条」可跳转至被关联实例详情
|
||||
2. 打印:`relatedApproval` 字段输出可读多行块(含对方业务字段),支持最多 3 层递归
|
||||
3. 防环:同一 instanceId 在一份 PDF 中不重复展开字段体
|
||||
|
||||
### 2.2 包含
|
||||
|
||||
| 项 | 说明 |
|
||||
|----|------|
|
||||
| enrichment 扁平化 | 嵌套 `relatedApproval` 字段增加可选 `relatedIds: string[]`,`value` 仍为「已关联 M 条」 |
|
||||
| `OaRelatedApproval.vue` | 识别 `relatedIds`,渲染可点跳转 |
|
||||
| `oa-approval-print.ts` + `OaApprovalPrintSheet.vue` | 特判关联控件;递归组装打印块;版式多行缩进 |
|
||||
| 规格交叉引用 | 回写 V1 §5 嵌套行,指向本文 |
|
||||
|
||||
### 2.3 不包含
|
||||
|
||||
- 屏幕端一层/无限嵌套缩略卡片(否决嵌套 Demo C/D)
|
||||
- 改选单范围、自动串子流程、回填其它字段
|
||||
- 后端生成 PDF
|
||||
- 统一 `summaries?depth=` 树形 API(否决实现方案 2)
|
||||
- 打印时对每个关联 id 全量 `get_instance_detail`(否决实现方案 3)
|
||||
|
||||
---
|
||||
|
||||
## 3. 屏幕行为
|
||||
|
||||
### 3.1 直接关联(不变)
|
||||
|
||||
- 应用名 · 时间;收起前 3 字段 / 展开全字段;点标题进详情
|
||||
- 详情页 `form.disabled` 下展开钮须可点(已用原生 button 规避 `el-button` 继承禁用)
|
||||
|
||||
### 3.2 嵌套 `relatedApproval` 字段
|
||||
|
||||
| 规则 | 行为 |
|
||||
|------|------|
|
||||
| 展示文案 | `已关联 M 条`(M 为 id 个数;0 则「已关联 0 条」) |
|
||||
| 不展示 | 子卡片缩略、子级展开树 |
|
||||
| 可点跳转 | 字段带 `relatedIds` 时:`M=1` 进该实例;`M>1` 进列表顺序**第一条** |
|
||||
| 不可见 | 保持现有「无法查看」;无 `relatedIds` 时纯文本、不可点 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 打印行为
|
||||
|
||||
### 4.1 主表内版式
|
||||
|
||||
普通字段:两列「标签 | 值」。
|
||||
|
||||
`relatedApproval` 字段:右侧为**多行块**,示意:
|
||||
|
||||
```
|
||||
关联审批单
|
||||
├─ 1. 外出申请 · 2026-07-10
|
||||
│ 目的地:上海
|
||||
│ 起止:07-10 ~ 07-12
|
||||
│ 关联审批单:
|
||||
│ └─ 1.1 立项申请 · 2026-06-01
|
||||
│ 项目名称:XX
|
||||
│ 关联审批单:已关联 1 条 ← 已到第 3 层,不再展开
|
||||
└─ 2. 另一张单 · 2026-06-15
|
||||
…
|
||||
```
|
||||
|
||||
### 4.2 递归与安全
|
||||
|
||||
| 规则 | 定义 |
|
||||
|------|------|
|
||||
| 层数 | 当前被打印单上的直接关联 = 第 1 层;其子关联 = 第 2 层;再下 = 第 3 层 |
|
||||
| 上限 | 最多展开到第 3 层;更深嵌套字段只保留文案「已关联 M 条」 |
|
||||
| 去重 | `visited` 集合;已出现过的 id 输出「(已在上文出现)」,不重复字段体 |
|
||||
| 不可见 | 该条标题「无法查看」,无字段体;不中断整份 PDF |
|
||||
| 加载失败 | 该层提示「关联信息加载失败」;其它行照常 |
|
||||
|
||||
### 4.3 单条块内容
|
||||
|
||||
1. 标题行:`{序号} {appName} · {applyTime 日期}`
|
||||
2. 正文:enrichment `fields[]` 的 `label` + `value`(附件等沿用现打印 URL/既有格式)
|
||||
3. 若某 field 含 `relatedIds` 且 `depth < 3`:在该字段下缩进递归子块
|
||||
|
||||
---
|
||||
|
||||
## 5. 数据契约
|
||||
|
||||
### 5.1 不变
|
||||
|
||||
- Schema `type: relatedApproval`,`form_data` 为 `string[]`
|
||||
- 选单:本人发起 + `completed` + 租户隔离
|
||||
|
||||
### 5.2 enrichment field 扩展(向后兼容)
|
||||
|
||||
```ts
|
||||
{
|
||||
label: string
|
||||
value: string
|
||||
fullWidth?: boolean
|
||||
relatedIds?: string[] // 仅嵌套/本控件扁平化为「已关联 M 条」时可选带出
|
||||
}
|
||||
```
|
||||
|
||||
- 旧客户端忽略 `relatedIds` 仍可展示文案
|
||||
- 屏幕跳转依赖 `relatedIds`;缺省则不可点
|
||||
|
||||
### 5.3 打印行模型扩展(前端)
|
||||
|
||||
在现有 `OaPrintFieldRow` 上扩展其一即可(实现时择一,保持单一来源):
|
||||
|
||||
- `text` / `urls`(现有),或
|
||||
- `blocks?: RelatedPrintBlock[]`(关联专用多行树)
|
||||
|
||||
`RelatedPrintBlock` 建议:
|
||||
|
||||
```ts
|
||||
{
|
||||
indexLabel: string // "1" | "1.1"
|
||||
title: string // "外出申请 · 2026-07-10" | "无法查看" | "(已在上文出现)"
|
||||
lines?: { label: string; value: string }[]
|
||||
children?: RelatedPrintBlock[]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 数据流(方案 1)
|
||||
|
||||
```
|
||||
列表「打印」
|
||||
→ fetchOaInstanceDetail(id)
|
||||
→ buildOaPrintModel(detail)
|
||||
→ walk schema
|
||||
→ relatedApproval 字段:
|
||||
assembleRelatedPrintBlocks(ids, depth=1, visited, maxDepth=3)
|
||||
→ fetchOaRelatedSummaries(batch ids)
|
||||
→ 每条 summary → RelatedPrintBlock
|
||||
→ fields 中带 relatedIds 且 depth < maxDepth → 递归
|
||||
→ OaApprovalPrintSheet 渲染块
|
||||
→ html2canvas + jspdf
|
||||
```
|
||||
|
||||
屏幕路径不走上述递归;仅消费扁平 `fields` + `relatedIds` 跳转。
|
||||
|
||||
---
|
||||
|
||||
## 7. 模块落点
|
||||
|
||||
| 模块 | 改动 |
|
||||
|------|------|
|
||||
| `backend/app/services/oa_related_approval.py` | 扁平化 `relatedApproval` 时写入 `relatedIds`;`value` 保持「已关联 M 条」 |
|
||||
| `backend/tests/test_oa_related_approval.py` | 断言 `relatedIds` |
|
||||
| `web-admin/src/components/oa-approval/OaRelatedApproval.vue` | 「已关联 M 条」可点跳转 |
|
||||
| `web-admin/src/api/oaApproval.ts` | 类型补 `relatedIds?`(若有) |
|
||||
| `web-admin/src/utils/oa-approval-print.ts` | 特判 + 递归组装 + 批量去重请求 |
|
||||
| `web-admin/src/components/oa-approval/OaApprovalPrintSheet.vue` | 关联块多行缩进 UI |
|
||||
| `docs/superpowers/specs/2026-07-22-oa-related-approval-design.md` | §5 嵌套行改为交叉引用本文 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 错误处理
|
||||
|
||||
| 场景 | 行为 |
|
||||
|------|------|
|
||||
| 嵌套 id 不可见 | 打印块「无法查看」;屏幕跳转后走现有详情权限 |
|
||||
| summaries 部分失败 | 失败 id 占位;成功 id 正常 |
|
||||
| 环引用 | visited 去重 |
|
||||
| 无关联控件旧单 | 打印行为与现网一致 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 验收标准
|
||||
|
||||
1. 屏幕:嵌套字段为「已关联 M 条」;`M≥1` 且有 `relatedIds` 时可点进详情;无嵌套缩略卡
|
||||
2. 打印:关联字段为多行清单,含对方业务字段;非 UUID、非仅计数
|
||||
3. PDF 中嵌套最多 3 层;更深为计数文案
|
||||
4. 重复 id 不重复字段体,有「已在上文出现」
|
||||
5. 部分不可见时整份 PDF 仍可生成
|
||||
6. 无该控件旧应用打印回归通过
|
||||
7. 页眉/页脚/印章/水印等既有打印行为不变
|
||||
|
||||
---
|
||||
|
||||
## 10. 测试建议
|
||||
|
||||
- 后端:`flatten_form_fields_for_summary` 对嵌套关联产出 `relatedIds`
|
||||
- 前端打印:depth=1..3、visited 环、并列多条、unavailable
|
||||
- 手工:A←B←C←D 链;A↔B 环;列表打印目检 PDF
|
||||
|
||||
---
|
||||
|
||||
## 11. 实现顺序建议
|
||||
|
||||
1. 后端扁平化补 `relatedIds` + 单测
|
||||
2. 屏幕「已关联 M 条」可跳转
|
||||
3. 打印组装递归 + PrintSheet 版式
|
||||
4. 回归旧应用与权限占位
|
||||
5. 回写 V1 规格交叉引用
|
||||
|
||||
---
|
||||
|
||||
## 12. 修订说明(相对 V1)
|
||||
|
||||
| V1 §5 原文 | 本文 |
|
||||
|------------|------|
|
||||
| 嵌套 `relatedApproval` →「已关联 M 条(不递归展开)」 | **屏幕**仍不递归缩略,但可跳转;**打印**最多递归 3 层打出业务字段 |
|
||||
|
||||
若与 V1 冲突,**嵌套展示与打印以本文为准**;V1 其余条款(选单、持久化、四层注册等)不变。
|
||||
Reference in new issue
Block a user