提交
This commit is contained in:
1 parent
06956b742b
commit
e59b2aa6cb
104 files changed
+653227
-7621
No files matched your search
@@ -0,0 +1,786 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Fresh Mint · 报价单审批人设置 Demo</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<style>
|
||||
:root {
|
||||
--primary: #10b981;
|
||||
--primary-dark: #059669;
|
||||
--primary-bg: #ecfdf5;
|
||||
--text: #1e293b;
|
||||
--text-muted: #64748b;
|
||||
--border: #d9e8e2;
|
||||
--surface: #ffffff;
|
||||
--table-head: #f8fafc;
|
||||
--bg-page: #f8fafc;
|
||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
--radius: 10px;
|
||||
--warn-bg: #fffbeb;
|
||||
--warn: #d97706;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
font-family: Inter, -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: var(--text);
|
||||
background: var(--bg-page);
|
||||
}
|
||||
|
||||
.demo-page { max-width: 1180px; margin: 0 auto; padding: 32px 24px 96px; }
|
||||
.demo-head { margin-bottom: 28px; }
|
||||
.demo-head h1 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.demo-head h1 span { color: var(--primary); }
|
||||
.demo-head p { margin: 0; color: var(--text-muted); max-width: 820px; }
|
||||
|
||||
.toc {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin: 20px 0 0;
|
||||
}
|
||||
.toc a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.toc a:hover,
|
||||
.toc a.rec {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary-dark);
|
||||
background: var(--primary-bg);
|
||||
}
|
||||
|
||||
.section { margin-top: 40px; }
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.section-head h2 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.section-head p { margin: 0; color: var(--text-muted); font-size: 13px; max-width: 720px; }
|
||||
|
||||
.badge {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 26px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.badge-ok { background: var(--primary-bg); color: var(--primary-dark); }
|
||||
.badge-bad { background: #fef2f2; color: #b91c1c; }
|
||||
.badge-mid { background: #eff6ff; color: #1d4ed8; }
|
||||
.badge-warn { background: var(--warn-bg); color: var(--warn); }
|
||||
|
||||
.split {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.split { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
.panel-top {
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
.panel-top h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
position: relative;
|
||||
padding-left: 10px;
|
||||
}
|
||||
.panel-top h3::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 2px;
|
||||
bottom: 2px;
|
||||
width: 3px;
|
||||
border-radius: 2px;
|
||||
background: var(--primary);
|
||||
}
|
||||
.panel-top .hint {
|
||||
margin: 6px 0 0;
|
||||
padding-left: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.panel-body { padding: 16px; }
|
||||
|
||||
.layout-mock {
|
||||
display: grid;
|
||||
grid-template-columns: 180px 1fr;
|
||||
min-height: 420px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.side {
|
||||
background: var(--surface);
|
||||
border-right: 1px solid var(--border);
|
||||
padding: 16px 12px;
|
||||
}
|
||||
.side .logo {
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
margin-bottom: 16px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
.side .logo span { color: var(--primary); }
|
||||
.nav-item {
|
||||
display: block;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.nav-item.active {
|
||||
background: var(--primary-bg);
|
||||
color: var(--primary-dark);
|
||||
font-weight: 600;
|
||||
}
|
||||
.main { padding: 16px; background: var(--bg-page); }
|
||||
.crumb {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 14px 16px;
|
||||
}
|
||||
.form-item { display: flex; flex-direction: column; gap: 6px; }
|
||||
.form-item.full { grid-column: 1 / -1; }
|
||||
.form-item label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.form-item label .tip {
|
||||
margin-left: 6px;
|
||||
font-weight: 400;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.ctrl {
|
||||
height: 36px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: #fff;
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
}
|
||||
.ctrl.multi {
|
||||
height: auto;
|
||||
min-height: 36px;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
.ctrl.textarea {
|
||||
height: auto;
|
||||
min-height: 88px;
|
||||
align-items: flex-start;
|
||||
padding: 10px 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.ctrl.muted { color: var(--text-muted); }
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
padding: 0 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
background: var(--primary-bg);
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
.tag.muted {
|
||||
background: var(--table-head);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.tag.warn {
|
||||
background: var(--warn-bg);
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.btn-row {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
height: 36px;
|
||||
padding: 0 14px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-main {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.12);
|
||||
overflow: hidden;
|
||||
max-width: 520px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.dialog-hd {
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
}
|
||||
.dialog-bd { padding: 16px; }
|
||||
.dialog-ft {
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--table-head);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
.callout {
|
||||
margin-bottom: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.callout.ok {
|
||||
background: var(--primary-bg);
|
||||
color: var(--primary-dark);
|
||||
border: 1px solid #a7f3d0;
|
||||
}
|
||||
.callout.bad {
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
.flow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 14px 16px;
|
||||
background: var(--table-head);
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 13px;
|
||||
}
|
||||
.flow-step {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
.flow-step.on {
|
||||
background: var(--primary-bg);
|
||||
border-color: #a7f3d0;
|
||||
color: var(--primary-dark);
|
||||
font-weight: 600;
|
||||
}
|
||||
.flow-arrow { color: #94a3b8; }
|
||||
|
||||
.options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.options { grid-template-columns: 1fr; }
|
||||
}
|
||||
.option {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
padding: 14px;
|
||||
cursor: pointer;
|
||||
transition: border-color .15s, box-shadow .15s, background .15s;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
position: relative;
|
||||
}
|
||||
.option:hover {
|
||||
border-color: #a7f3d0;
|
||||
background: #f8fffc;
|
||||
}
|
||||
.option.selected {
|
||||
border-color: var(--primary);
|
||||
background: var(--primary-bg);
|
||||
box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.18);
|
||||
}
|
||||
.option .letter {
|
||||
display: inline-flex;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
background: var(--table-head);
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.option.selected .letter {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
.option h3 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.option p {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.option .rec-flag {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--primary-dark);
|
||||
background: #fff;
|
||||
border: 1px solid #a7f3d0;
|
||||
border-radius: 999px;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
.option ul {
|
||||
margin: 10px 0 0;
|
||||
padding-left: 16px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.option ul li { margin-bottom: 4px; }
|
||||
|
||||
.pick-bar {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
margin-top: 20px;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
box-shadow: 0 -4px 20px rgba(15, 23, 42, 0.06);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.pick-bar strong { color: var(--primary-dark); }
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
.table th {
|
||||
text-align: left;
|
||||
padding: 10px 12px;
|
||||
background: var(--table-head);
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.table td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.table tr:last-child td { border-bottom: none; }
|
||||
|
||||
.note {
|
||||
margin-top: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="demo-page">
|
||||
<header class="demo-head">
|
||||
<h1>报价单审批人设置 <span>Demo</span></h1>
|
||||
<p>
|
||||
客户痛点:每次「提交审批」都要手选审批人,且每次选的人几乎相同。
|
||||
本 Demo 展示:① 新增设置页如何配置默认审批人;② 提交弹窗如何自动带出;③ 三种实现路径供选型。
|
||||
</p>
|
||||
<nav class="toc">
|
||||
<a href="#pain">① 现状痛点</a>
|
||||
<a href="#setting" class="rec">② 设置页方案</a>
|
||||
<a href="#submit">③ 提交弹窗改造</a>
|
||||
<a href="#approaches">④ 三种路径选型</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<!-- ① 现状 -->
|
||||
<section class="section" id="pain">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2>① 现状:每次都要手选</h2>
|
||||
<p>打开「提交审批」弹窗时,审批人为空,必须重新选择;备注也常重复填写。</p>
|
||||
</div>
|
||||
<span class="badge badge-bad">当前体验</span>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="flow">
|
||||
<span class="flow-step">选中报价单</span>
|
||||
<span class="flow-arrow">→</span>
|
||||
<span class="flow-step">点击提交审批</span>
|
||||
<span class="flow-arrow">→</span>
|
||||
<span class="flow-step on">手选审批人(重复操作)</span>
|
||||
<span class="flow-arrow">→</span>
|
||||
<span class="flow-step">填写备注</span>
|
||||
<span class="flow-arrow">→</span>
|
||||
<span class="flow-step">确定</span>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="dialog">
|
||||
<div class="dialog-hd">提交审批</div>
|
||||
<div class="dialog-bd">
|
||||
<div class="callout bad">审批人为空,每次都要重新点选;用户反馈「系统不好用」。</div>
|
||||
<div class="form-item" style="margin-bottom:12px">
|
||||
<label>选择审批人 <span class="tip">可选择多个审批人,按顺序依次审批</span></label>
|
||||
<div class="ctrl muted">请选择审批人</div>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>备注说明</label>
|
||||
<div class="ctrl textarea">请输入备注说明</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dialog-ft">
|
||||
<button class="btn" type="button">取消</button>
|
||||
<button class="btn btn-main" type="button">确定</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ② 设置页 -->
|
||||
<section class="section" id="setting">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2>② 新增模块:报价单审批人设置</h2>
|
||||
<p>建议挂在 CRM → 报价相关菜单下(如「报价单审批人设置」)。管理员配置一次,提交时默认带出。</p>
|
||||
</div>
|
||||
<span class="badge badge-ok">推荐示意</span>
|
||||
</div>
|
||||
|
||||
<div class="layout-mock">
|
||||
<aside class="side">
|
||||
<div class="logo">山峡 <span>CRM</span></div>
|
||||
<div class="nav-item">报价单管理</div>
|
||||
<div class="nav-item active">报价单审批人设置</div>
|
||||
<div class="nav-item">合同管理</div>
|
||||
<div class="nav-item">客户管理</div>
|
||||
</aside>
|
||||
<div class="main">
|
||||
<div class="crumb">CRM / 报价 / 报价单审批人设置</div>
|
||||
<div class="panel">
|
||||
<div class="panel-top">
|
||||
<h3>报价单审批人设置</h3>
|
||||
<p class="hint">配置租户级默认审批人;提交报价单审批时自动预填,仍可临时调整。</p>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="form-grid">
|
||||
<div class="form-item full">
|
||||
<label>
|
||||
默认审批人
|
||||
<span class="tip">支持多人,按选择顺序依次审批</span>
|
||||
</label>
|
||||
<div class="ctrl multi">
|
||||
<span class="tag">1. 张经理</span>
|
||||
<span class="tag">2. 李总</span>
|
||||
<span class="tag muted">+ 添加</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>生效范围 <span class="tip">本 Demo 默认租户级</span></label>
|
||||
<div class="ctrl">全租户统一</div>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>状态</label>
|
||||
<div class="ctrl"><span class="tag">启用</span></div>
|
||||
</div>
|
||||
<div class="form-item full">
|
||||
<label>默认备注模板 <span class="tip">可选;提交时可改</span></label>
|
||||
<div class="ctrl textarea" style="color:var(--text)">请按常规折扣政策审批。</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn" type="button">重置</button>
|
||||
<button class="btn btn-main" type="button">保存设置</button>
|
||||
</div>
|
||||
<p class="note">说明:本示意采用「租户级一份配置」。若后续要按部门/金额分岔,可再扩展,不必首版做满。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ③ 提交弹窗改造 -->
|
||||
<section class="section" id="submit">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2>③ 提交审批:自动带出默认审批人</h2>
|
||||
<p>打开弹窗即预填设置中的审批人;用户多数情况只需点「确定」,特殊单仍可改人。</p>
|
||||
</div>
|
||||
<span class="badge badge-ok">改造后</span>
|
||||
</div>
|
||||
|
||||
<div class="split">
|
||||
<div class="panel">
|
||||
<div class="panel-top">
|
||||
<h3>改造前</h3>
|
||||
<p class="hint">每次空白,强制手选</p>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="dialog">
|
||||
<div class="dialog-hd">提交审批</div>
|
||||
<div class="dialog-bd">
|
||||
<div class="callout bad">必须重新选择审批人</div>
|
||||
<div class="form-item" style="margin-bottom:12px">
|
||||
<label>选择审批人</label>
|
||||
<div class="ctrl muted">请选择审批人</div>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>备注说明</label>
|
||||
<div class="ctrl textarea">(空)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dialog-ft">
|
||||
<button class="btn" type="button">取消</button>
|
||||
<button class="btn btn-main" type="button">确定</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-top">
|
||||
<h3>改造后</h3>
|
||||
<p class="hint">默认带出,可改可确认</p>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="dialog">
|
||||
<div class="dialog-hd">提交审批</div>
|
||||
<div class="dialog-bd">
|
||||
<div class="callout ok">已按「报价单审批人设置」预填:张经理 → 李总。可修改后提交。</div>
|
||||
<div class="form-item" style="margin-bottom:12px">
|
||||
<label>选择审批人 <span class="tip">已预填,可调整</span></label>
|
||||
<div class="ctrl multi">
|
||||
<span class="tag">1. 张经理</span>
|
||||
<span class="tag">2. 李总</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>备注说明</label>
|
||||
<div class="ctrl textarea" style="color:var(--text)">请按常规折扣政策审批。</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dialog-ft">
|
||||
<button class="btn" type="button">取消</button>
|
||||
<button class="btn btn-main" type="button">确定</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ④ 三种路径 -->
|
||||
<section class="section" id="approaches">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2>④ 实现路径选型(请点击选择)</h2>
|
||||
<p>三种都能解决「重复选人」;范围与复杂度不同。点击卡片选型,底部会显示当前选择。</p>
|
||||
</div>
|
||||
<span class="badge badge-mid">待确认</span>
|
||||
</div>
|
||||
|
||||
<div class="options" id="approachOptions">
|
||||
<button type="button" class="option selected" data-choice="a" onclick="pickApproach(this)">
|
||||
<span class="rec-flag">推荐</span>
|
||||
<div class="letter">A</div>
|
||||
<h3>租户级默认审批人</h3>
|
||||
<p>新增「报价单审批人设置」页,存一份有序审批人列表;提交弹窗自动预填,仍可改。</p>
|
||||
<ul>
|
||||
<li>改动小,最快落地</li>
|
||||
<li>贴合「每次都选同一批人」</li>
|
||||
<li>不引入完整工作流引擎</li>
|
||||
</ul>
|
||||
</button>
|
||||
|
||||
<button type="button" class="option" data-choice="b" onclick="pickApproach(this)">
|
||||
<div class="letter">B</div>
|
||||
<h3>记住「上次选的人」</h3>
|
||||
<p>不做设置页;按当前用户缓存最近一次提交的审批人,下次自动回填。</p>
|
||||
<ul>
|
||||
<li>开发更快</li>
|
||||
<li>无统一管控,新人无默认</li>
|
||||
<li>换人时可能误用旧配置</li>
|
||||
</ul>
|
||||
</button>
|
||||
|
||||
<button type="button" class="option" data-choice="c" onclick="pickApproach(this)">
|
||||
<div class="letter">C</div>
|
||||
<h3>接入通用审批流</h3>
|
||||
<p>走 OpenSpec 工作流引擎(指定角色/条件分支)。长期正确,但本期过重。</p>
|
||||
<ul>
|
||||
<li>能力完整、可复用合同等业务</li>
|
||||
<li>依赖尚未落地的工作流子系统</li>
|
||||
<li>交付周期长,超出当前痛点</li>
|
||||
</ul>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="pick-bar">
|
||||
<div>
|
||||
当前选择:<strong id="pickLabel">A · 租户级默认审批人</strong>
|
||||
<div class="note" style="margin:4px 0 0">看完后请在对话里回复选型(如「选 A」),我们再继续澄清细节。</div>
|
||||
</div>
|
||||
<span class="badge badge-ok" id="pickBadge">方案 A</span>
|
||||
</div>
|
||||
|
||||
<div class="panel" style="margin-top:16px">
|
||||
<div class="panel-top">
|
||||
<h3>方案 A 若落地:涉及面速览</h3>
|
||||
</div>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>层</th>
|
||||
<th>内容</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>菜单 / 页面</td>
|
||||
<td>CRM 下新增「报价单审批人设置」(表单页,非复杂列表)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>数据</td>
|
||||
<td>租户维度配置表:有序 approver_ids、可选默认备注、启用状态</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>提交审批</td>
|
||||
<td>打开弹窗时读取配置并预填;未配置则保持现状必选</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>权限</td>
|
||||
<td>设置页建议管理员可改;普通业务员只读使用默认值</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const labels = {
|
||||
a: 'A · 租户级默认审批人',
|
||||
b: 'B · 记住上次选的人',
|
||||
c: 'C · 接入通用审批流'
|
||||
};
|
||||
const badges = { a: '方案 A', b: '方案 B', c: '方案 C' };
|
||||
|
||||
function pickApproach(el) {
|
||||
document.querySelectorAll('#approachOptions .option').forEach((n) => n.classList.remove('selected'));
|
||||
el.classList.add('selected');
|
||||
const choice = el.getAttribute('data-choice');
|
||||
document.getElementById('pickLabel').textContent = labels[choice] || choice;
|
||||
document.getElementById('pickBadge').textContent = badges[choice] || choice;
|
||||
try {
|
||||
localStorage.setItem('quote-approver-demo-choice', choice);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
try {
|
||||
const saved = localStorage.getItem('quote-approver-demo-choice');
|
||||
if (saved) {
|
||||
const el = document.querySelector('#approachOptions .option[data-choice="' + saved + '"]');
|
||||
if (el) pickApproach(el);
|
||||
}
|
||||
} catch (e) {}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,557 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>门户大面板宽度 · 一级菜单较少时</title>
|
||||
<style>
|
||||
:root {
|
||||
--primary: #10b981;
|
||||
--primary-dark: #059669;
|
||||
--primary-bg: #ecfdf5;
|
||||
--header: #1ca851;
|
||||
--text: #0f172a;
|
||||
--muted: #64748b;
|
||||
--border: #e2e8f0;
|
||||
--bg: #f1f5f9;
|
||||
--panel-min: 720px;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: Inter, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||
color: var(--text);
|
||||
background: #f8fafc;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.page { padding: 20px 24px 48px; max-width: 1280px; margin: 0 auto; }
|
||||
h1 { font-size: 22px; margin: 0 0 6px; }
|
||||
.lead { color: var(--muted); font-size: 14px; margin: 0 0 14px; }
|
||||
.note {
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fde68a;
|
||||
color: #92400e;
|
||||
border-radius: 10px;
|
||||
padding: 10px 14px;
|
||||
font-size: 13px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.ok {
|
||||
background: var(--primary-bg);
|
||||
border: 1px solid #a7f3d0;
|
||||
color: #065f46;
|
||||
border-radius: 10px;
|
||||
padding: 10px 14px;
|
||||
font-size: 13px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px 16px;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
padding: 12px 14px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
}
|
||||
.controls label {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
margin-right: 4px;
|
||||
}
|
||||
.seg {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.seg button {
|
||||
border: 1px solid var(--border);
|
||||
background: #fff;
|
||||
border-radius: 999px;
|
||||
padding: 7px 12px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
color: #334155;
|
||||
}
|
||||
.seg button.active {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
.card {
|
||||
background: #fff;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.card-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: #fafafa;
|
||||
}
|
||||
.card-head h2 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 15px;
|
||||
}
|
||||
.card-head p {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.badge {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
background: #e2e8f0;
|
||||
color: #475569;
|
||||
}
|
||||
.badge.bad { background: #fee2e2; color: #b91c1c; }
|
||||
.badge.good { background: #d1fae5; color: #047857; }
|
||||
.badge.rec { background: #ecfdf5; color: #065f46; border: 1px solid #a7f3d0; }
|
||||
.frame {
|
||||
height: 360px;
|
||||
background: var(--bg);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.app {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 13px;
|
||||
}
|
||||
.top {
|
||||
position: relative;
|
||||
height: 56px;
|
||||
background: var(--header);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
flex-shrink: 0;
|
||||
color: #fff;
|
||||
z-index: 2;
|
||||
}
|
||||
.brand {
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
margin-right: 12px;
|
||||
}
|
||||
.nav-slot {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
max-width: min(720px, calc(100% - 280px));
|
||||
}
|
||||
.l1 {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 2px;
|
||||
height: 100%;
|
||||
}
|
||||
.l1-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0 14px;
|
||||
height: 100%;
|
||||
white-space: nowrap;
|
||||
border: none;
|
||||
border-bottom: 4px solid transparent;
|
||||
background: transparent;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.l1-item.on {
|
||||
font-weight: 600;
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
border-bottom-color: #fff;
|
||||
}
|
||||
.l1-item .caret { font-size: 10px; opacity: 0.75; }
|
||||
.end {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
.avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #6ee7b7, #34d399);
|
||||
border: 2px solid rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
.main {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
background: #eef2f7;
|
||||
overflow: hidden;
|
||||
}
|
||||
.page-fake {
|
||||
padding: 20px 24px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.page-fake .bar {
|
||||
height: 10px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.page-fake .bar.w2 { width: 70%; }
|
||||
.page-fake .bar.w3 { width: 48%; }
|
||||
.mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.25);
|
||||
z-index: 3;
|
||||
}
|
||||
.panel {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
z-index: 4;
|
||||
background: #fff;
|
||||
border: 1px solid var(--border);
|
||||
border-top: none;
|
||||
border-radius: 0 0 10px 10px;
|
||||
box-shadow: 0 14px 32px rgba(15, 23, 42, 0.12);
|
||||
max-height: calc(100% - 8px);
|
||||
overflow: auto;
|
||||
}
|
||||
.panel-body { padding: 12px 16px 16px; }
|
||||
.split {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0 16px;
|
||||
}
|
||||
.pane + .pane {
|
||||
padding-left: 16px;
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 88px 1fr;
|
||||
gap: 8px 12px;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
align-items: start;
|
||||
}
|
||||
.row:last-child { border-bottom: 0; }
|
||||
.l2 {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #666;
|
||||
padding-top: 4px;
|
||||
}
|
||||
.links { display: flex; flex-direction: column; gap: 2px; }
|
||||
.links span {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #0f172a;
|
||||
padding: 3px 0;
|
||||
}
|
||||
.links span.on { color: var(--primary-dark); font-weight: 600; }
|
||||
.measure {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 10px;
|
||||
z-index: 5;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #b45309;
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fde68a;
|
||||
border-radius: 6px;
|
||||
padding: 3px 8px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.measure.ok-m {
|
||||
color: #047857;
|
||||
background: #ecfdf5;
|
||||
border-color: #a7f3d0;
|
||||
}
|
||||
.pick {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.pick button {
|
||||
border: 1px solid var(--primary);
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 10px 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.pick button.ghost {
|
||||
background: #fff;
|
||||
color: #334155;
|
||||
border-color: var(--border);
|
||||
}
|
||||
.pick button.alt {
|
||||
background: var(--primary-bg);
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<h1>大面板宽度 Demo:一级菜单较少时</h1>
|
||||
<p class="lead">
|
||||
现状:二级大面板宽度 = 一级菜单区域宽度。权限较少(仅 2 / 4 个一级)时,面板会跟着变窄,两栏二级排不开。
|
||||
</p>
|
||||
<div class="note">
|
||||
先用上方开关模拟「一级数量」,再对比三种宽度策略。点选卡片或底部按钮告诉我偏好。
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<div>
|
||||
<label>一级菜单数量</label>
|
||||
<div class="seg" id="countSeg">
|
||||
<button type="button" data-count="2">2 个(窄权限)</button>
|
||||
<button type="button" data-count="4" class="active">4 个</button>
|
||||
<button type="button" data-count="7">7 个(接近满)</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label>高亮模块</label>
|
||||
<div class="seg" id="activeSeg">
|
||||
<button type="button" class="active" data-active="0">第一个</button>
|
||||
<button type="button" data-active="1">第二个</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ok">
|
||||
推荐倾向:<strong>方案 B</strong> — 面板有最小宽度(如 720px),相对一级区域居中;一级很多时仍可随区域变宽,但不超过顶栏中间槽位上限。
|
||||
</div>
|
||||
|
||||
<div class="grid" id="cards"></div>
|
||||
|
||||
<div class="pick">
|
||||
<button type="button" class="alt" data-choice="a">选 A · 跟一级同宽(现状)</button>
|
||||
<button type="button" data-choice="b">选 B · 最小宽度居中(推荐)</button>
|
||||
<button type="button" class="ghost" data-choice="c">选 C · 固定铺满中间槽</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const ALL_L1 = ['CRM', '仓储', '质检', '财务', '系统', '报表', '运营']
|
||||
const PANEL_ROWS_LEFT = [
|
||||
{ l2: '客户管理', links: ['客户列表', '联系人', '公海客户'] },
|
||||
{ l2: '商机报价', links: ['商机列表', '报价单', '价格函'] },
|
||||
{ l2: '合同订单', links: ['合同审批', '订单跟踪'] },
|
||||
{ l2: '拜访计划', links: ['拜访日历', '拜访记录'] },
|
||||
{ l2: '基础资料', links: ['产品目录', '单位管理'] }
|
||||
]
|
||||
const PANEL_ROWS_RIGHT = [
|
||||
{ l2: '审批中心', links: ['待办审批', '已办审批'] },
|
||||
{ l2: '设置', links: ['审批人设置', '业务参数'] }
|
||||
]
|
||||
|
||||
const strategies = [
|
||||
{
|
||||
id: 'a',
|
||||
title: 'A · 跟一级区域同宽(现状)',
|
||||
desc: '面板 left/width 完全等于一级 nav 的 getBoundingClientRect。一级少 → 面板窄。',
|
||||
badge: '现状问题',
|
||||
badgeClass: 'bad',
|
||||
calc(navRect, slotMax) {
|
||||
return { left: navRect.left, width: navRect.width }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'b',
|
||||
title: 'B · 最小宽度 + 相对一级居中(推荐)',
|
||||
desc: 'width = max(一级宽度, min(720, 槽位上限)),相对一级中心对齐;一级很多时仍可更宽。',
|
||||
badge: '推荐',
|
||||
badgeClass: 'rec',
|
||||
calc(navRect, slotMax, viewportW) {
|
||||
const minW = Math.min(720, slotMax, viewportW - 32)
|
||||
const width = Math.max(navRect.width, minW)
|
||||
const center = navRect.left + navRect.width / 2
|
||||
let left = center - width / 2
|
||||
left = Math.max(16, Math.min(left, viewportW - width - 16))
|
||||
return { left, width }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'c',
|
||||
title: 'C · 始终铺满顶栏中间槽位',
|
||||
desc: '面板宽度固定为中间槽 max-width(约 720),与一级数量无关,始终居中于顶栏。',
|
||||
badge: '固定宽',
|
||||
badgeClass: 'good',
|
||||
calc(navRect, slotMax, viewportW, slotRect) {
|
||||
const width = Math.min(720, slotMax, viewportW - 32)
|
||||
const center = slotRect.left + slotRect.width / 2
|
||||
let left = center - width / 2
|
||||
left = Math.max(16, Math.min(left, viewportW - width - 16))
|
||||
return { left, width }
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
let l1Count = 4
|
||||
let activeIdx = 0
|
||||
|
||||
function rowHtml(row) {
|
||||
return `<div class="row">
|
||||
<div class="l2">${row.l2}</div>
|
||||
<div class="links">${row.links.map((t, i) => `<span class="${i === 0 ? 'on' : ''}">${t}</span>`).join('')}</div>
|
||||
</div>`
|
||||
}
|
||||
|
||||
function panelInner() {
|
||||
return `<div class="panel-body">
|
||||
<div class="split">
|
||||
<div class="pane">${PANEL_ROWS_LEFT.map(rowHtml).join('')}</div>
|
||||
<div class="pane">${PANEL_ROWS_RIGHT.map(rowHtml).join('')}</div>
|
||||
</div>
|
||||
</div>`
|
||||
}
|
||||
|
||||
function render() {
|
||||
const cards = document.getElementById('cards')
|
||||
cards.innerHTML = strategies.map((s) => `
|
||||
<div class="card" data-choice="${s.id}" onclick="window.__pick('${s.id}')">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<h2>${s.title}</h2>
|
||||
<p>${s.desc}</p>
|
||||
</div>
|
||||
<span class="badge ${s.badgeClass}">${s.badge}</span>
|
||||
</div>
|
||||
<div class="frame" id="frame-${s.id}">
|
||||
<div class="app">
|
||||
<div class="top">
|
||||
<div class="brand">山峡化工</div>
|
||||
<div class="nav-slot" id="slot-${s.id}">
|
||||
<div class="l1" id="nav-${s.id}">
|
||||
${ALL_L1.slice(0, l1Count).map((name, i) => `
|
||||
<button type="button" class="l1-item ${i === activeIdx ? 'on' : ''}">
|
||||
<span>${name}</span><span class="caret">▾</span>
|
||||
</button>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="end"><div class="avatar"></div></div>
|
||||
</div>
|
||||
<div class="main" id="main-${s.id}">
|
||||
<div class="page-fake">
|
||||
<div class="bar"></div>
|
||||
<div class="bar w2"></div>
|
||||
<div class="bar w3"></div>
|
||||
<p>内容区(示意)</p>
|
||||
</div>
|
||||
<div class="mask"></div>
|
||||
<div class="panel" id="panel-${s.id}">${panelInner()}</div>
|
||||
<div class="measure" id="measure-${s.id}"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('')
|
||||
|
||||
requestAnimationFrame(layoutPanels)
|
||||
}
|
||||
|
||||
function layoutPanels() {
|
||||
strategies.forEach((s) => {
|
||||
const frame = document.getElementById(`frame-${s.id}`)
|
||||
const nav = document.getElementById(`nav-${s.id}`)
|
||||
const slot = document.getElementById(`slot-${s.id}`)
|
||||
const main = document.getElementById(`main-${s.id}`)
|
||||
const panel = document.getElementById(`panel-${s.id}`)
|
||||
const measure = document.getElementById(`measure-${s.id}`)
|
||||
if (!frame || !nav || !slot || !main || !panel) return
|
||||
|
||||
const frameRect = frame.getBoundingClientRect()
|
||||
const navRect = nav.getBoundingClientRect()
|
||||
const slotRect = slot.getBoundingClientRect()
|
||||
const mainRect = main.getBoundingClientRect()
|
||||
const slotMax = Math.min(720, frameRect.width - 280)
|
||||
|
||||
const localNav = {
|
||||
left: navRect.left - mainRect.left,
|
||||
width: navRect.width
|
||||
}
|
||||
const localSlot = {
|
||||
left: slotRect.left - mainRect.left,
|
||||
width: slotRect.width
|
||||
}
|
||||
const viewportW = mainRect.width
|
||||
|
||||
const pos = s.calc(localNav, slotMax, viewportW, localSlot)
|
||||
panel.style.left = `${Math.round(pos.left)}px`
|
||||
panel.style.width = `${Math.round(pos.width)}px`
|
||||
|
||||
const narrow = pos.width < 520
|
||||
measure.textContent = `面板宽 ≈ ${Math.round(pos.width)}px · 一级区 ≈ ${Math.round(navRect.width)}px`
|
||||
measure.className = `measure ${narrow ? '' : 'ok-m'}`
|
||||
})
|
||||
}
|
||||
|
||||
window.__pick = function (id) {
|
||||
const names = { a: 'A 现状同宽', b: 'B 最小宽度居中', c: 'C 固定铺满中间槽' }
|
||||
alert(`已记录偏好:${names[id] || id}\n\n请在对话里回复确认,或继续改参数再看。`)
|
||||
if (typeof toggleSelect === 'function') {
|
||||
const el = document.querySelector(`[data-choice="${id}"]`)
|
||||
if (el) toggleSelect(el)
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('countSeg').addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('button[data-count]')
|
||||
if (!btn) return
|
||||
l1Count = Number(btn.dataset.count)
|
||||
document.querySelectorAll('#countSeg button').forEach((b) => b.classList.toggle('active', b === btn))
|
||||
if (activeIdx >= l1Count) activeIdx = 0
|
||||
render()
|
||||
})
|
||||
|
||||
document.getElementById('activeSeg').addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('button[data-active]')
|
||||
if (!btn) return
|
||||
activeIdx = Math.min(Number(btn.dataset.active), l1Count - 1)
|
||||
document.querySelectorAll('#activeSeg button').forEach((b) => b.classList.toggle('active', b === btn))
|
||||
render()
|
||||
})
|
||||
|
||||
document.querySelector('.pick').addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('button[data-choice]')
|
||||
if (!btn) return
|
||||
window.__pick(btn.dataset.choice)
|
||||
})
|
||||
|
||||
window.addEventListener('resize', layoutPanels)
|
||||
render()
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,21 @@
|
||||
# 门户大面板最小宽度(方案 B)
|
||||
|
||||
> **For agentic workers:** 仅改前端定位;完成后人工在窄权限账号下点开一级验证。
|
||||
|
||||
**Goal:** 一级菜单较少时,二级大面板仍有足够宽度(最小约 720px),相对一级居中。
|
||||
|
||||
**Architecture:** 在 `ArtPortalMegaMenu.syncPanelPosition` 中计算 `max(一级宽, min(720, 视口−32))`,再按一级中心水平居中并钳制边距。
|
||||
|
||||
**Tech Stack:** Vue 3 · `art-portal-mega-menu/index.vue`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 更新面板定位
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/components/core/layouts/art-portal-mega-menu/index.vue`
|
||||
- Modify: `docs/superpowers/specs/2026-08-01-portal-mega-menu-design.md`(已补充规则)
|
||||
|
||||
**Step:** 改写 `syncPanelPosition` 按方案 B 计算 `left` / `width`;`top` 仍为一级底边。
|
||||
|
||||
**Verify:** 一级仅 2/4 项时面板宽 ≈ 720;一级很多时面板随一级变宽;窗口很窄时不超出视口。
|
||||
@@ -0,0 +1,761 @@
|
||||
# 报价单审批人设置 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:** 新表 `crm_quote_approver_setting` 存有序 `approver_ids` + 启用状态;CRM POST 风格 get/save API;前端设置页维护配置;报价提交弹窗打开时 GET 并预填。`submitQuote` 入参与审批链路不变。
|
||||
|
||||
**Tech Stack:** FastAPI + SQLAlchemy + MySQL;Vue 3 + Element Plus(web-admin);菜单权限 `crm:quote:approverSetting`
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-08-01-quote-approver-setting-design.md`
|
||||
**UI Demo:** `docs/fresh-mint-quote-approver-setting-demo.html`
|
||||
**UI 模式:** 单对象配置表单页(对照 Demo;非 P01 列表)。PR 注明「配置表单 + 提交预填」。
|
||||
|
||||
---
|
||||
|
||||
## File map
|
||||
|
||||
| File | Responsibility |
|
||||
|------|----------------|
|
||||
| `backend/scripts/migrate_20260801_crm_quote_approver_setting.sql` | 建表 + 菜单 |
|
||||
| `docs/sql/shanxia2.0.sql` | 同步 CREATE TABLE / 菜单 INSERT(与 migrate 一致) |
|
||||
| `backend/app/models/crm_models.py` | `CrmQuoteApproverSetting` |
|
||||
| `backend/app/crud/crm/quote_approver_setting.py` | parse/join helpers + get/save upsert |
|
||||
| `backend/app/schemas/crm/quote_approver_setting.py` | Save body |
|
||||
| `backend/app/api/crm_quote_routes.py` | 注册 get/save 路由 |
|
||||
| `backend/tests/test_quote_approver_setting.py` | 纯函数单测 |
|
||||
| `web-admin/src/api/crm/quote/index.ts` + `types.ts` | 前端 API/类型 |
|
||||
| `web-admin/src/views/crm/quote/approver-setting/index.vue` | 设置页 |
|
||||
| `web-admin/src/router/modules/sqxp.ts` | 路由 |
|
||||
| `web-admin/src/views/crm/quote/index.vue` | 提交弹窗预填 |
|
||||
| `web-admin/src/views/crm/quote/components/QuoteFormPanel.vue` | 提交弹窗预填 |
|
||||
| `web-admin/src/views/crm/quote/components/ApproverSelector.vue` | 打开时预填(若仍被调用) |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 纯函数与单测(TDD)
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/crud/crm/quote_approver_setting.py`(先只放 helpers)
|
||||
- Create: `backend/tests/test_quote_approver_setting.py`
|
||||
|
||||
- [ ] **Step 1: 写失败单测**
|
||||
|
||||
```python
|
||||
# backend/tests/test_quote_approver_setting.py
|
||||
from app.crud.crm.quote_approver_setting import (
|
||||
join_approver_ids,
|
||||
parse_approver_ids,
|
||||
prefill_approver_ids,
|
||||
)
|
||||
|
||||
|
||||
def test_parse_and_join_roundtrip():
|
||||
assert parse_approver_ids("a,b,c") == ["a", "b", "c"]
|
||||
assert parse_approver_ids(" a,, b ") == ["a", "b"]
|
||||
assert parse_approver_ids("") == []
|
||||
assert parse_approver_ids(None) == []
|
||||
assert join_approver_ids(["a", "b", ""]) == "a,b"
|
||||
|
||||
|
||||
def test_prefill_requires_enabled_and_nonempty():
|
||||
assert prefill_approver_ids(None) == []
|
||||
assert prefill_approver_ids({"status": "1", "approverIds": ["u1"]}) == []
|
||||
assert prefill_approver_ids({"status": "0", "approverIds": []}) == []
|
||||
assert prefill_approver_ids({"status": "0", "approverIds": ["u1", "u2"]}) == ["u1", "u2"]
|
||||
|
||||
|
||||
def test_prefill_filters_against_valid_ids():
|
||||
setting = {"status": "0", "approverIds": ["u1", "gone", "u2"]}
|
||||
assert prefill_approver_ids(setting, valid_ids={"u1", "u2"}) == ["u1", "u2"]
|
||||
assert prefill_approver_ids(setting, valid_ids=set()) == []
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测确认失败**
|
||||
|
||||
```bash
|
||||
cd backend && source venv/bin/activate && pytest tests/test_quote_approver_setting.py -v
|
||||
```
|
||||
|
||||
Expected: FAIL(模块/函数不存在)
|
||||
|
||||
- [ ] **Step 3: 实现 helpers**
|
||||
|
||||
```python
|
||||
# backend/app/crud/crm/quote_approver_setting.py
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Iterable, List, Optional, Set
|
||||
|
||||
|
||||
def parse_approver_ids(raw: Optional[str]) -> List[str]:
|
||||
if not raw:
|
||||
return []
|
||||
out: List[str] = []
|
||||
for part in str(raw).split(","):
|
||||
pid = part.strip()
|
||||
if pid:
|
||||
out.append(pid)
|
||||
return out
|
||||
|
||||
|
||||
def join_approver_ids(ids: Iterable[str]) -> str:
|
||||
return ",".join([str(i).strip() for i in ids if str(i).strip()])
|
||||
|
||||
|
||||
def prefill_approver_ids(
|
||||
setting: Optional[Dict[str, Any]],
|
||||
*,
|
||||
valid_ids: Optional[Set[str]] = None,
|
||||
) -> List[str]:
|
||||
"""返回可预填的审批人 ID 列表;不可用则空列表。"""
|
||||
if not setting:
|
||||
return []
|
||||
if str(setting.get("status") or "") != "0":
|
||||
return []
|
||||
ids = list(setting.get("approverIds") or [])
|
||||
if valid_ids is not None:
|
||||
ids = [i for i in ids if str(i) in valid_ids]
|
||||
return [str(i) for i in ids if str(i).strip()]
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 再跑测确认通过**
|
||||
|
||||
```bash
|
||||
cd backend && source venv/bin/activate && pytest tests/test_quote_approver_setting.py -v
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/crud/crm/quote_approver_setting.py backend/tests/test_quote_approver_setting.py
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(crm): add quote approver setting id helpers and unit tests
|
||||
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: SQL 迁移(表 + 菜单)
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/scripts/migrate_20260801_crm_quote_approver_setting.sql`
|
||||
- Modify: `docs/sql/shanxia2.0.sql`(在 `crm_quote_approval` 表段落后插入 CREATE;在报价单管理菜单附近插入菜单行)
|
||||
|
||||
- [ ] **Step 1: 写 migrate SQL**
|
||||
|
||||
```sql
|
||||
-- backend/scripts/migrate_20260801_crm_quote_approver_setting.sql
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `crm_quote_approver_setting` (
|
||||
`id` varchar(64) NOT NULL COMMENT '主键ID(UUID)',
|
||||
`approver_ids` varchar(1000) NOT NULL DEFAULT '' COMMENT '有序审批人用户ID,逗号分隔',
|
||||
`status` char(1) NOT NULL DEFAULT '0' COMMENT '0启用 1停用',
|
||||
`tenant_no` varchar(64) NOT NULL DEFAULT 'shanxia2.0' COMMENT '租户编号',
|
||||
`order_num` int(11) DEFAULT NULL COMMENT '排序号',
|
||||
`remark` varchar(500) DEFAULT NULL COMMENT '备注',
|
||||
`create_by` varchar(64) DEFAULT NULL COMMENT '创建者',
|
||||
`create_dept_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建部门',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_by` varchar(64) DEFAULT NULL COMMENT '更新者',
|
||||
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`del_flag` smallint(6) NOT NULL DEFAULT '0' COMMENT '删除标志',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_tenant_no` (`tenant_no`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='CRM-报价单审批人设置(租户级)';
|
||||
|
||||
-- 说明:MySQL 5.7 无部分唯一索引;租户「有效一条」由 save_setting upsert 保证,不加 (tenant_no, del_flag) 唯一键。
|
||||
|
||||
-- 菜单:报价单审批人设置(CRM 根下,紧挨报价单管理)
|
||||
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`
|
||||
) VALUES (
|
||||
'CRM3000000000000000000000000015',
|
||||
'报价单审批人设置',
|
||||
'CRM00000000000000000000000000001',
|
||||
'C',
|
||||
'quote-approver-setting',
|
||||
'/crm/quote/approver-setting/index',
|
||||
'crm:quote:approverSetting',
|
||||
1, '1', '1', 'shanxia2.0', 55,
|
||||
'租户级默认报价审批人',
|
||||
'system', '', NOW(), NULL, NOW(), 0
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`menu_name`=VALUES(`menu_name`),
|
||||
`component`=VALUES(`component`),
|
||||
`perms`=VALUES(`perms`),
|
||||
`path`=VALUES(`path`);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 同步 `docs/sql/shanxia2.0.sql`**
|
||||
|
||||
将相同 `CREATE TABLE` 与 `INSERT INTO sys_menu` 片段并入快照(位置:报价相关表/菜单附近),避免文档与库漂移。
|
||||
|
||||
- [ ] **Step 3: 在本地库执行 migrate**(开发环境)
|
||||
|
||||
```bash
|
||||
# 按本地 .env 连接执行;示例:
|
||||
mysql -h... -u... -p... your_db < backend/scripts/migrate_20260801_crm_quote_approver_setting.sql
|
||||
```
|
||||
|
||||
Expected: 表与菜单创建成功。
|
||||
|
||||
- [ ] **Step 4: 给需要维护配置的角色勾选新菜单**
|
||||
|
||||
在「角色管理」中为管理员角色勾选「报价单审批人设置」(或按环境向 `sys_role_menu` 插入对应 `menu_id`)。`super_admin` 本身放行,可不依赖角色菜单。
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/scripts/migrate_20260801_crm_quote_approver_setting.sql docs/sql/shanxia2.0.sql
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(crm): add quote approver setting table and menu SQL
|
||||
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Model + CRUD upsert + Schema
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/models/crm_models.py`(在 `CrmQuoteApproval` 前或后新增模型)
|
||||
- Modify: `backend/app/crud/crm/quote_approver_setting.py`(补 get/save)
|
||||
- Create: `backend/app/schemas/crm/quote_approver_setting.py`
|
||||
|
||||
- [ ] **Step 1: 增加 SQLAlchemy 模型**
|
||||
|
||||
```python
|
||||
class CrmQuoteApproverSetting(Base):
|
||||
"""CRM-报价单审批人设置(租户级)"""
|
||||
|
||||
__tablename__ = "crm_quote_approver_setting"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID(UUID)")
|
||||
approver_ids = Column(String(1000), nullable=False, default="", comment="有序审批人用户ID,逗号分隔")
|
||||
status = Column(String(1), nullable=False, default="0", comment="0启用 1停用")
|
||||
tenant_no = Column(String(64), nullable=False, default="shanxia2.0", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(500), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建者")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建部门")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新者")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Schema**
|
||||
|
||||
```python
|
||||
# backend/app/schemas/crm/quote_approver_setting.py
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class QuoteApproverSettingSaveBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
approverIds: List[str] = Field(default_factory=list)
|
||||
status: str = Field(default="0") # 0启用 1停用
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 实现 get_setting / save_setting**
|
||||
|
||||
在 `quote_approver_setting.py` 追加(要点):
|
||||
|
||||
```python
|
||||
from datetime import datetime
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.models.crm_models import CrmQuoteApproverSetting
|
||||
from app.models.models import SysUser
|
||||
|
||||
|
||||
def _row_to_dict(row: CrmQuoteApproverSetting) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": row.id,
|
||||
"approverIds": parse_approver_ids(row.approver_ids),
|
||||
"status": row.status or "0",
|
||||
"tenantNo": row.tenant_no,
|
||||
"updateTime": row.update_time.isoformat(sep=" ") if row.update_time else None,
|
||||
}
|
||||
|
||||
|
||||
def get_setting(db: Session, *, tenant_no: str) -> Optional[Dict[str, Any]]:
|
||||
row = (
|
||||
db.query(CrmQuoteApproverSetting)
|
||||
.filter(
|
||||
CrmQuoteApproverSetting.tenant_no == tenant_no,
|
||||
CrmQuoteApproverSetting.del_flag == 0,
|
||||
)
|
||||
.order_by(CrmQuoteApproverSetting.update_time.desc())
|
||||
.first()
|
||||
)
|
||||
return _row_to_dict(row) if row else None
|
||||
|
||||
|
||||
def _assert_users_exist(db: Session, *, tenant_no: str, approver_ids: List[str]) -> None:
|
||||
ids = [str(i).strip() for i in approver_ids if str(i).strip()]
|
||||
if not ids:
|
||||
return
|
||||
rows = (
|
||||
db.query(SysUser.id)
|
||||
.filter(SysUser.id.in_(ids), SysUser.del_flag == 0)
|
||||
.all()
|
||||
)
|
||||
found = {str(r[0]) for r in rows}
|
||||
missing = [i for i in ids if i not in found]
|
||||
if missing:
|
||||
raise ValueError(f"审批人无效或不存在:{','.join(missing)}")
|
||||
|
||||
|
||||
def save_setting(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_no: str,
|
||||
approver_ids: List[str],
|
||||
status: str,
|
||||
user_id: str = None,
|
||||
dept_id: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
status_norm = "1" if str(status) == "1" else "0"
|
||||
ids = [str(i).strip() for i in (approver_ids or []) if str(i).strip()]
|
||||
_assert_users_exist(db, tenant_no=tenant_no, approver_ids=ids)
|
||||
|
||||
row = (
|
||||
db.query(CrmQuoteApproverSetting)
|
||||
.filter(
|
||||
CrmQuoteApproverSetting.tenant_no == tenant_no,
|
||||
CrmQuoteApproverSetting.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
now = datetime.now()
|
||||
if row:
|
||||
row.approver_ids = join_approver_ids(ids)
|
||||
row.status = status_norm
|
||||
row.update_by = user_id
|
||||
row.update_time = now
|
||||
else:
|
||||
row = CrmQuoteApproverSetting(
|
||||
id=generate_uuid(),
|
||||
approver_ids=join_approver_ids(ids),
|
||||
status=status_norm,
|
||||
tenant_no=tenant_no,
|
||||
create_by=user_id,
|
||||
create_dept_id=dept_id or "",
|
||||
create_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return _row_to_dict(row)
|
||||
```
|
||||
|
||||
注意:若 `SysUser` 有租户字段且项目惯例按租户过滤用户,在 `_assert_users_exist` 中追加与现有用户查询一致的 `tenant_no` 条件。
|
||||
|
||||
- [ ] **Step 4: 再跑 Task 1 单测**(确保 helpers 未破坏)
|
||||
|
||||
```bash
|
||||
cd backend && source venv/bin/activate && pytest tests/test_quote_approver_setting.py -v
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/models/crm_models.py backend/app/crud/crm/quote_approver_setting.py backend/app/schemas/crm/quote_approver_setting.py
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(crm): implement quote approver setting model and upsert CRUD
|
||||
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: API 路由
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/api/crm_quote_routes.py`
|
||||
|
||||
- [ ] **Step 1: 在 `register_quote_routes` 末尾注册两个 POST**
|
||||
|
||||
风格对齐现有 `/quote/*`(POST + body),路径:
|
||||
|
||||
- `POST /quote-approver-setting/get` — `Depends(require_admin_or_super_admin)`(业务员可读以预填)
|
||||
- `POST /quote-approver-setting/save` — `Depends(require_menu_code("crm:quote:approverSetting"))`
|
||||
|
||||
```python
|
||||
from app.core.menu_guard import require_admin_or_super_admin, require_menu_code
|
||||
from app.crud.crm.quote_approver_setting import get_setting, save_setting
|
||||
from app.schemas.crm.quote_approver_setting import QuoteApproverSettingSaveBody
|
||||
|
||||
# 在 register_quote_routes 内:
|
||||
|
||||
@router.post("/quote-approver-setting/get")
|
||||
async def quote_approver_setting_get(
|
||||
_user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = get_tenant_no(db, _user)
|
||||
data = get_setting(db, tenant_no=tenant_no)
|
||||
return success_response(data or {"approverIds": [], "status": "0"})
|
||||
except Exception as e:
|
||||
return error_response(500, str(e))
|
||||
|
||||
|
||||
@router.post("/quote-approver-setting/save")
|
||||
async def quote_approver_setting_save(
|
||||
body: QuoteApproverSettingSaveBody,
|
||||
_user: dict = Depends(require_menu_code("crm:quote:approverSetting")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = get_tenant_no(db, _user)
|
||||
user_info = crm_user_info(_user)
|
||||
data = save_setting(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
approver_ids=body.approverIds or [],
|
||||
status=body.status or "0",
|
||||
user_id=user_info.get("user_id") or _user.get("user_id"),
|
||||
dept_id=user_info.get("dept_id") or "",
|
||||
)
|
||||
return success_response(data, message="保存成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
except Exception as e:
|
||||
return error_response(500, str(e))
|
||||
```
|
||||
|
||||
未配置时 GET 返回 `{ approverIds: [], status: "0" }`(表单默认启用;`approverIds` 为空故预填仍为空)。勿用 `status:"1"` 作为空配置哨兵,否则设置页首次打开会落到「停用」。
|
||||
|
||||
- [ ] **Step 2: 手工用已登录 token 冒烟**(Swagger `/docs` 或 curl)
|
||||
|
||||
Expected: get 返回空配置;save 后 get 带回 `approverIds`;无菜单权限用户 save → 403。
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/api/crm_quote_routes.py
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(crm): expose quote approver setting get/save APIs
|
||||
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 前端 API 与类型
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/api/crm/quote/types.ts`
|
||||
- Modify: `web-admin/src/api/crm/quote/index.ts`
|
||||
|
||||
- [ ] **Step 1: 类型**
|
||||
|
||||
```ts
|
||||
export interface QuoteApproverSettingVO {
|
||||
id?: string
|
||||
approverIds: (string | number)[]
|
||||
status: '0' | '1' | string
|
||||
updateTime?: string
|
||||
}
|
||||
|
||||
export interface QuoteApproverSettingForm {
|
||||
approverIds: (string | number)[]
|
||||
status: '0' | '1'
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: API**
|
||||
|
||||
```ts
|
||||
const SETTING_BASE = '/web/crm/quote-approver-setting'
|
||||
|
||||
export async function getQuoteApproverSetting() {
|
||||
const res = await request.post({ url: `${SETTING_BASE}/get`, data: {} })
|
||||
const data = unwrapData(res) || {}
|
||||
return {
|
||||
data: {
|
||||
id: data.id,
|
||||
approverIds: (data.approverIds || []).map(String),
|
||||
status: data.status ?? '1',
|
||||
updateTime: data.updateTime
|
||||
} as QuoteApproverSettingVO
|
||||
}
|
||||
}
|
||||
|
||||
export function saveQuoteApproverSetting(data: QuoteApproverSettingForm) {
|
||||
return request.post({
|
||||
url: `${SETTING_BASE}/save`,
|
||||
data: {
|
||||
approverIds: (data.approverIds || []).map(String),
|
||||
status: data.status
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add web-admin/src/api/crm/quote/types.ts web-admin/src/api/crm/quote/index.ts
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(web-admin): add quote approver setting API client
|
||||
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 设置页 + 路由
|
||||
|
||||
**Files:**
|
||||
- Create: `web-admin/src/views/crm/quote/approver-setting/index.vue`
|
||||
- Modify: `web-admin/src/router/modules/sqxp.ts`(在 `CrmQuote` 路由后插入)
|
||||
|
||||
- [ ] **Step 1: 路由**
|
||||
|
||||
```ts
|
||||
{
|
||||
id: 'CRM3000000000000000000000000015',
|
||||
name: 'CrmQuoteApproverSetting',
|
||||
path: 'quote-approver-setting',
|
||||
component: '/crm/quote/approver-setting/index',
|
||||
meta: {
|
||||
title: '报价单审批人设置',
|
||||
keepAlive: true,
|
||||
menuCode: 'crm:quote:approverSetting'
|
||||
}
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 页面骨架(对齐 Demo / Fresh Mint)**
|
||||
|
||||
要点:
|
||||
|
||||
- `page-container` → `section.panel` → `panel-top` + `h2.title-bar-heading`「报价单审批人设置」
|
||||
- `el-form`:`label-position="top"`,`form-grid-2`
|
||||
- 字段:
|
||||
- 默认审批人:`el-select` multiple(复用报价页 `fetchUserOptions` / 同款 label 格式)
|
||||
- 状态:`el-radio-group` 启用 `0` / 停用 `1`
|
||||
- 底栏:重置 + 保存(`el-icon` Close/RefreshLeft + Check;保存 `type="primary"`)
|
||||
- `onMounted` 调 `getQuoteApproverSetting` 回填;保存调 `saveQuoteApproverSetting`
|
||||
- 审批人允许空数组保存(清空后不再预填)
|
||||
|
||||
参考结构(精简):
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<section class="panel">
|
||||
<div class="panel-top">
|
||||
<h2 class="title-bar-heading">报价单审批人设置</h2>
|
||||
</div>
|
||||
<div class="panel-body" style="padding: 16px 20px 24px">
|
||||
<el-form ref="formRef" :model="form" label-position="top" class="crm-dialog-form">
|
||||
<div class="form-grid-2">
|
||||
<el-form-item class="form-col-span-2">
|
||||
<template #label>
|
||||
默认审批人<span class="form-label-tip">支持多人,按选择顺序依次审批</span>
|
||||
</template>
|
||||
<el-select v-model="form.approverIds" multiple filterable placeholder="请选择审批人" style="width: 100%">
|
||||
<el-option
|
||||
v-for="user in userList"
|
||||
:key="user.userId"
|
||||
:label="formatApproverOptionLabel(user)"
|
||||
:value="user.userId"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-radio-group v-model="form.status">
|
||||
<el-radio value="0">启用</el-radio>
|
||||
<el-radio value="1">停用</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:16px">
|
||||
<el-button @click="loadSetting"><el-icon><RefreshLeft /></el-icon>重置</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="handleSave">
|
||||
<el-icon><Check /></el-icon>保存设置
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
(`el-radio` 的 `value`/`label` 写法与项目 Element Plus 版本保持一致。)
|
||||
|
||||
- [ ] **Step 3: 本地验证**
|
||||
|
||||
打开 `/#/crm/quote-approver-setting`(需菜单权限)→ 选人保存 → 刷新仍在。
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add web-admin/src/views/crm/quote/approver-setting/index.vue web-admin/src/router/modules/sqxp.ts
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(web-admin): add quote approver setting page and route
|
||||
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: 提交审批弹窗预填
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/views/crm/quote/index.vue`
|
||||
- Modify: `web-admin/src/views/crm/quote/components/QuoteFormPanel.vue`
|
||||
- Modify: `web-admin/src/views/crm/quote/components/ApproverSelector.vue`(若 `open` 仍被使用)
|
||||
|
||||
- [ ] **Step 1: 抽共用预填逻辑(可内联,避免过度抽象)**
|
||||
|
||||
在打开提交弹窗处(`handleSubmit` / 表单内提交):
|
||||
|
||||
```ts
|
||||
import { getQuoteApproverSetting } from '@/api/crm/quote'
|
||||
// 若后端 helpers 不在前端,前端等价:
|
||||
async function resolvePrefillApproverIds(userList: { userId: string | number }[]) {
|
||||
try {
|
||||
const { data } = await getQuoteApproverSetting()
|
||||
if (!data || String(data.status) !== '0') return [] as string[]
|
||||
const valid = new Set(userList.map((u) => String(u.userId)))
|
||||
return (data.approverIds || []).map(String).filter((id) => valid.has(id))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: `index.vue` 的 `handleSubmit` / 详情内提交**
|
||||
|
||||
在 `loadUserList()` 之后:
|
||||
|
||||
```ts
|
||||
const prefillIds = await resolvePrefillApproverIds(userList.value)
|
||||
submitForm.value = {
|
||||
id,
|
||||
approverIds: prefillIds,
|
||||
remark: ''
|
||||
}
|
||||
submitPrefillTip.value = prefillIds.length > 0
|
||||
submitDialog.visible = true
|
||||
```
|
||||
|
||||
模板中审批人表单项上方(有预填时):
|
||||
|
||||
```vue
|
||||
<el-alert
|
||||
v-if="submitPrefillTip"
|
||||
type="success"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="已按「报价单审批人设置」预填审批人,可修改后提交"
|
||||
style="margin-bottom: 12px"
|
||||
/>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: `QuoteFormPanel.vue` 同样改造**打开提交对话框的逻辑。
|
||||
|
||||
- [ ] **Step 4: `ApproverSelector.vue`**
|
||||
|
||||
在 `open()` 内 `loadUsers` 后预填 `form.approverIds`(类型与选项 `userId` 一致;若当前是 `number[]`,统一为 `string[]` 更稳妥)。
|
||||
|
||||
- [ ] **Step 5: 手工验收**
|
||||
|
||||
1. 设置页启用并保存张经理→李总
|
||||
2. 报价列表提交审批:弹窗已预填,提示出现
|
||||
3. 改人后提交成功
|
||||
4. 设置停用或清空:弹窗为空,须手选
|
||||
5. 断网/接口失败:弹窗仍可打开,审批人为空
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add web-admin/src/views/crm/quote/index.vue \
|
||||
web-admin/src/views/crm/quote/components/QuoteFormPanel.vue \
|
||||
web-admin/src/views/crm/quote/components/ApproverSelector.vue
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(web-admin): prefill quote submit approvers from tenant setting
|
||||
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: 收尾
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/superpowers/specs/2026-08-01-quote-approver-setting-design.md`(状态改为「已实现」)
|
||||
- Optional: `docs/web-admin-ui-pattern-index.md` 追加一行业务页对照(报价审批人设置 → 配置表单)
|
||||
- Run: `./scripts/update-graph.sh backend` 与 `./scripts/update-graph.sh frontend`(或 `all`),若本会话改了代码
|
||||
|
||||
- [ ] **Step 1: 更新设计文档状态**
|
||||
|
||||
将 **状态:** 改为 `已实现`。
|
||||
|
||||
- [ ] **Step 2: 回归 pytest + 关键页面**
|
||||
|
||||
```bash
|
||||
cd backend && source venv/bin/activate && pytest tests/test_quote_approver_setting.py -v
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/superpowers/specs/2026-08-01-quote-approver-setting-design.md docs/web-admin-ui-pattern-index.md
|
||||
git commit -m "$(cat <<'EOF'
|
||||
docs: mark quote approver setting design implemented
|
||||
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Spec coverage checklist
|
||||
|
||||
| Spec 项 | Task |
|
||||
|---------|------|
|
||||
| 租户级设置表 | Task 2–3 |
|
||||
| GET/PUT(实现为 POST get/save) | Task 4 |
|
||||
| 提交可改预填 | Task 7 |
|
||||
| 菜单权限维护 | Task 2 + 4 save 守卫 |
|
||||
| 未配置=手选 | Task 4 空响应 + Task 7 |
|
||||
| 不做默认备注 | 全任务未引入 |
|
||||
| 无效用户过滤/校验 | Task 3 save + Task 7 prefill |
|
||||
| 验收标准 1–5 | Task 7 Step 5 |
|
||||
|
||||
## Self-review notes
|
||||
|
||||
- API 与设计文档的 GET/PUT 字面差异:本计划按 CRM 既有 **POST** 风格落地,语义等价。
|
||||
- 租户单条有效配置靠 `save_setting` upsert,不加部分唯一索引(MySQL 5.7)。
|
||||
- 前端不强制单测文件(与仓库现状一致);后端用 helpers 单测锁预填语义。
|
||||
@@ -0,0 +1,37 @@
|
||||
# 门户顶栏大面板菜单(方案 A)
|
||||
|
||||
## 目标
|
||||
|
||||
将 web-admin 门户布局从「顶栏一级下拉 + 左侧二/三级树」改为「顶栏横排一级 + 点击展开大面板」,取消左侧常驻菜单,内容区全宽。
|
||||
|
||||
## 交互
|
||||
|
||||
1. 顶栏横排展示全部一级模块。
|
||||
2. 点击一级展开大面板;再点同一级或遮罩关闭。
|
||||
3. 面板内左右两栏,二级行左右均分(如 12→6+6);建议每栏可见约 5 行,超出后面板整体纵向滚动。
|
||||
4. 每行:左侧二级标题(不可点)+ 右侧三级文字链(可点)。
|
||||
5. 二级无子节点且自身可导航时:生成与二级同名的虚拟三级,路由继承该二级。
|
||||
6. 点击三级跳转并关闭面板。
|
||||
|
||||
## 范围
|
||||
|
||||
- 仅 `MenuTypeEnum.PORTAL` 布局。
|
||||
- 不改后端菜单数据模型;虚拟三级仅在前端展示层生成。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 方案 B(5 列网格)本期不落地。
|
||||
- 面板内搜索可后续增强。
|
||||
|
||||
## 面板宽度(一级较少时)
|
||||
|
||||
权限导致一级模块仅 2~4 个时,一级横排区域会变窄。大面板宽度不得简单等于一级区域宽度。
|
||||
|
||||
**策略(已确认):最小宽度 + 相对一级居中**
|
||||
|
||||
1. `width = max(一级区域宽度, min(720px, 视口宽度 − 32px))`
|
||||
2. 水平以一级区域中心为基准居中;左右各保留约 16px,避免贴边溢出
|
||||
3. 一级区域已 ≥ 720px 时,面板仍跟随一级区域宽度(与原先「同宽」一致)
|
||||
4. 纵向仍贴在一级栏下方;面板内部左右分栏布局不变
|
||||
|
||||
参考 Demo:`docs/portal-mega-panel-width-demo.html`
|
||||
@@ -65,8 +65,8 @@
|
||||
|
||||
| 方法 | 路径(示意) | 说明 |
|
||||
|------|----------------|------|
|
||||
| GET | `/api/web/admin/crm/quote-approver-setting` | 返回当前租户配置;无记录返回空对象/`null`(前端当未配置) |
|
||||
| PUT | `/api/web/admin/crm/quote-approver-setting` | Body:`{ approverIds: string[], status: '0'\|'1' }`;有则更新、无则创建 |
|
||||
| POST | `/api/web/.../crm/quote-approver-setting/get` | 返回当前租户配置;无记录返回 `{ approverIds: [], status: '0' }`(空列表不预填;默认启用避免设置页首次落到停用) |
|
||||
| POST | `/api/web/.../crm/quote-approver-setting/save` | Body:`{ approverIds: string[], status: '0'\|'1' }`;有则更新、无则创建 |
|
||||
|
||||
约束:
|
||||
|
||||
|
||||
Reference in new issue
Block a user