| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628 |
- <!DOCTYPE html>
- <html lang="zh-CN">
- <head>
- <meta charset="UTF-8" />
- <meta name="viewport" content="width=device-width, initial-scale=1" />
- <title>侧栏目录 + 右侧叶子 Tab 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;
- --bg-page: #f8fafc;
- --surface: #ffffff;
- --table-head: #f8fafc;
- --row-alt: #f1f5f9;
- --shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
- --radius: 10px;
- --sidebar-w: 220px;
- --header-h: 56px;
- }
- * { box-sizing: border-box; }
- html, body { height: 100%; }
- body {
- margin: 0;
- font-family: Inter, -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif;
- font-size: 14px;
- line-height: 1.5;
- color: var(--text);
- background: var(--bg-page);
- }
- button { font-family: inherit; }
- .page {
- max-width: 1280px;
- margin: 0 auto;
- padding: 20px 20px 48px;
- }
- .intro h1 {
- margin: 0 0 6px;
- font-size: 20px;
- font-weight: 700;
- }
- .intro p {
- margin: 0 0 12px;
- color: var(--text-muted);
- font-size: 13px;
- max-width: 860px;
- }
- .intro ul {
- margin: 0 0 16px;
- padding: 10px 14px 10px 28px;
- background: #fff;
- border: 1px solid var(--border);
- border-radius: 8px;
- font-size: 13px;
- color: var(--text-muted);
- }
- .intro li { margin: 3px 0; }
- .intro b { color: var(--text); font-weight: 600; }
- .shell {
- display: grid;
- grid-template-columns: var(--sidebar-w) 1fr;
- grid-template-rows: var(--header-h) minmax(560px, calc(100vh - 220px));
- border: 1px solid var(--border);
- border-radius: 12px;
- overflow: hidden;
- background: var(--surface);
- box-shadow: var(--shadow);
- }
- .header {
- grid-column: 1 / -1;
- display: flex;
- align-items: center;
- gap: 12px;
- height: var(--header-h);
- padding: 0 16px;
- border-bottom: 1px solid var(--border);
- background: #fff;
- }
- .brand {
- width: var(--sidebar-w);
- margin-left: -16px;
- padding-left: 16px;
- font-weight: 700;
- color: var(--primary-dark);
- }
- .crumb {
- color: var(--text-muted);
- font-size: 13px;
- }
- .crumb strong { color: var(--text); font-weight: 600; }
- .sidebar {
- border-right: 1px solid var(--border);
- background: #fff;
- overflow: auto;
- padding: 10px 8px 16px;
- }
- .side-label {
- padding: 6px 10px 8px;
- font-size: 11px;
- font-weight: 600;
- letter-spacing: 0.04em;
- color: var(--text-muted);
- text-transform: uppercase;
- }
- .tree-item { margin: 2px 0; }
- .tree-btn {
- width: 100%;
- display: flex;
- align-items: center;
- gap: 6px;
- border: 0;
- background: transparent;
- color: var(--text);
- text-align: left;
- padding: 8px 10px;
- border-radius: 8px;
- cursor: pointer;
- font-size: 14px;
- }
- .tree-btn:hover { background: var(--row-alt); }
- .tree-btn.is-active {
- background: var(--primary-bg);
- color: var(--primary-dark);
- font-weight: 600;
- }
- .tree-btn .chev {
- width: 14px;
- color: var(--text-muted);
- font-size: 11px;
- flex: 0 0 14px;
- }
- .tree-btn.is-active .chev { color: var(--primary-dark); }
- .tree-btn.l1 { font-weight: 600; }
- .tree-children {
- padding-left: 18px;
- display: none;
- }
- .tree-children.open { display: block; }
- .main {
- display: flex;
- flex-direction: column;
- min-width: 0;
- background: var(--bg-page);
- }
- .tabs {
- display: flex;
- padding: 12px 16px 8px;
- overflow-x: auto;
- }
- .tabs-track {
- display: inline-flex;
- flex-wrap: wrap;
- gap: 4px;
- padding: 4px;
- background: #fff;
- border: 1px solid var(--border);
- border-radius: 10px;
- box-shadow: 0 1px 2px rgba(30, 41, 59, 0.04), 0 8px 24px rgba(30, 41, 59, 0.04);
- }
- .tab {
- appearance: none;
- display: inline-flex;
- align-items: center;
- height: 32px;
- padding: 0 12px;
- font-size: 13px;
- font-weight: 500;
- color: #4b5563;
- cursor: pointer;
- white-space: nowrap;
- background: transparent;
- border: 0;
- border-radius: 8px;
- }
- .tab:hover { color: var(--text); background: #f3f6f4; }
- .tab.is-on {
- color: var(--primary-dark);
- font-weight: 600;
- background: var(--primary-bg, #ecfdf5);
- }
- .tab .dot {
- display: inline-block;
- width: 6px;
- height: 6px;
- border-radius: 99px;
- background: #cbd5e1;
- margin-right: 6px;
- vertical-align: middle;
- }
- .tab.is-on .dot { background: var(--primary); }
- .tab.is-loaded .dot { background: #94a3b8; }
- .tab.is-on.is-loaded .dot { background: var(--primary); }
- .panel-wrap {
- flex: 1;
- position: relative;
- overflow: auto;
- padding: 16px;
- }
- .empty {
- height: 100%;
- min-height: 360px;
- display: grid;
- place-items: center;
- color: var(--text-muted);
- font-size: 13px;
- border: 1px dashed var(--border);
- border-radius: var(--radius);
- background: #fff;
- }
- .view {
- display: none;
- background: #fff;
- border: 1px solid var(--border);
- border-radius: var(--radius);
- min-height: 360px;
- overflow: hidden;
- }
- .view.is-show { display: block; }
- .view-head {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 12px 16px;
- border-bottom: 1px solid var(--border);
- background: var(--table-head);
- }
- .view-head h2 {
- margin: 0;
- font-size: 15px;
- font-weight: 700;
- display: flex;
- align-items: center;
- gap: 8px;
- }
- .view-head h2::before {
- content: "";
- width: 3px;
- height: 14px;
- border-radius: 2px;
- background: var(--primary);
- }
- .badge {
- font-size: 11px;
- font-weight: 600;
- color: var(--primary-dark);
- background: var(--primary-bg);
- border-radius: 999px;
- padding: 2px 8px;
- }
- .view-body { padding: 16px; }
- table {
- width: 100%;
- border-collapse: collapse;
- font-size: 13px;
- }
- th, td {
- padding: 10px 12px;
- text-align: left;
- border-bottom: 1px solid var(--border);
- }
- th {
- color: var(--text-muted);
- font-weight: 600;
- background: var(--table-head);
- }
- tbody tr:nth-child(even) { background: #fafefe; }
- .tag {
- display: inline-block;
- padding: 2px 8px;
- border-radius: 4px;
- font-size: 12px;
- font-weight: 600;
- color: #fff;
- background: #059669;
- }
- .tag.warn { background: #d97706; }
- .tag.info {
- background: transparent;
- color: var(--text);
- border: 1px solid var(--border);
- }
- .kv {
- display: grid;
- grid-template-columns: 140px 1fr;
- gap: 8px 16px;
- font-size: 13px;
- }
- .kv dt { color: var(--text-muted); }
- .kv dd { margin: 0; }
- .mask {
- display: none;
- position: absolute;
- inset: 16px;
- border-radius: var(--radius);
- background: rgba(255, 255, 255, 0.72);
- align-items: center;
- justify-content: center;
- color: var(--text-muted);
- font-size: 13px;
- font-weight: 600;
- }
- .mask.show { display: flex; }
- .spin {
- width: 18px;
- height: 18px;
- border: 2px solid #cbd5e1;
- border-top-color: var(--primary-dark);
- border-radius: 50%;
- margin-right: 8px;
- animation: rot 0.8s linear infinite;
- }
- @keyframes rot { to { transform: rotate(360deg); } }
- .log {
- margin-top: 14px;
- background: #fff;
- border: 1px solid var(--border);
- border-radius: 8px;
- padding: 10px 12px;
- font-size: 12px;
- color: var(--text-muted);
- max-height: 120px;
- overflow: auto;
- }
- .log b { color: var(--text); }
- </style>
- </head>
- <body>
- <div class="page">
- <div class="intro">
- <h1>侧栏非叶子目录 + 右侧叶子 Tab</h1>
- <p>
- 交互演示(非真实业务页):左侧只展示目录(非叶子)。点击<strong>最后一层非叶子</strong>后,右侧用 Tab 打开其全部叶子菜单。
- 切换目录会清空上一组 Tab。默认加载第一个 Tab;再点哪个 Tab 才加载哪个界面。
- </p>
- <ul>
- <li>试一下:点 <b>报销管理 → 基础配置</b>,再点 <b>备用金</b>,观察右侧 Tab 整组替换。</li>
- <li>Tab 上的灰点表示尚未加载;点过一次后视为已加载(本 Demo 做缓存,切回不再重复请求)。</li>
- </ul>
- </div>
- <div class="shell">
- <header class="header">
- <div class="brand">山峡化工</div>
- <div class="crumb" id="crumb">请选择左侧最后一层目录</div>
- </header>
- <aside class="sidebar" id="sidebar"></aside>
- <section class="main">
- <div class="tabs" id="tabs"></div>
- <div class="panel-wrap">
- <div class="empty" id="empty">点击左侧「基础配置 / 备用金 / 报销 / 视图中心」打开叶子菜单 Tab</div>
- <div id="views"></div>
- <div class="mask" id="mask"><span class="spin"></span><span id="maskText">正在加载界面…</span></div>
- </div>
- </section>
- </div>
- <div class="log" id="log"><b>加载日志</b>:尚未操作</div>
- </div>
- <script>
- const MENU = [
- {
- id: "bx",
- title: "报销管理",
- children: [
- {
- id: "bx-config",
- title: "基础配置",
- leaves: [
- { id: "expense-item", title: "费用项目管理", kind: "table",
- rows: [["差旅费", "费用", "启用"], ["招待费", "费用", "启用"], ["运费", "费用", "停用"]] },
- { id: "wf-advance", title: "工作流配置(备用金)", kind: "kv",
- fields: [["流程名称", "备用金审批流程"], ["状态", "启用"], ["节点", "部门负责人 → 财务"]] },
- { id: "wf-reimburse", title: "工作流配置(报销单)", kind: "kv",
- fields: [["流程名称", "报销单审批流程"], ["状态", "启用"], ["节点", "直属上级 → 财务 → 出纳"]] }
- ]
- },
- {
- id: "bx-advance",
- title: "备用金",
- leaves: [
- { id: "adv-apply", title: "备用金申请", kind: "table",
- rows: [["BYJ20260819002", "汪璇", "50000.00", "已通过"], ["BYJ20260819001", "汪璇", "50768.30", "已驳回"]] },
- { id: "adv-approve", title: "备用金审批", kind: "table",
- rows: [["BYJ20260722001", "汪璇", "待审批", "部门负责人"], ["BYJ20260806001", "汪璇", "待审批", "财务"]] },
- { id: "adv-issue", title: "备用金发放", kind: "table",
- rows: [["BYJ20260522002", "汪璇", "50000.00", "已发放"], ["BYJ20260703001", "汪璇", "20000.00", "已发放"]] }
- ]
- },
- {
- id: "bx-reimburse",
- title: "报销",
- leaves: [
- { id: "rb-apply", title: "报销申请", kind: "table",
- rows: [["BX20260901001", "张三", "1280.00", "待提交"], ["BX20260902002", "李四", "3560.50", "审批中"]] },
- { id: "rb-approve", title: "报销审批", kind: "table",
- rows: [["BX20260902002", "李四", "3560.50", "待审批"]] },
- { id: "rb-pay", title: "支付发放冲抵", kind: "table",
- rows: [["BX20260815001", "王五", "890.00", "待发放"]] },
- { id: "inv-admin", title: "发票夹管理", kind: "kv",
- fields: [["待核验", "12 张"], ["已入账", "86 张"], ["异常", "2 张"]] },
- { id: "inv-mine", title: "我的发票夹", kind: "kv",
- fields: [["本月上传", "4 张"], ["可报销", "3 张"], ["已占用", "1 张"]] }
- ]
- },
- {
- id: "bx-view",
- title: "视图中心",
- leaves: [
- { id: "view-mine", title: "我的视图", kind: "kv",
- fields: [["默认视图", "本月报销汇总"], ["自定义", "2 个"]] },
- { id: "view-team", title: "部门视图", kind: "kv",
- fields: [["可见范围", "本部门及下级"], ["刷新", "每日 08:00"]] }
- ]
- }
- ]
- }
- ]
- const sidebar = document.getElementById("sidebar")
- const tabsEl = document.getElementById("tabs")
- const viewsEl = document.getElementById("views")
- const emptyEl = document.getElementById("empty")
- const crumbEl = document.getElementById("crumb")
- const maskEl = document.getElementById("mask")
- const maskText = document.getElementById("maskText")
- const logEl = document.getElementById("log")
- let openL1 = "bx"
- let activeGroupId = null
- let activeLeafId = null
- let loaded = new Set()
- let loadTimer = null
- const logs = []
- function log(msg) {
- const t = new Date().toLocaleTimeString("zh-CN", { hour12: false })
- logs.unshift(t + " " + msg)
- logEl.innerHTML = "<b>加载日志</b><br>" + logs.slice(0, 8).join("<br>")
- }
- function findGroup(id) {
- for (const l1 of MENU) {
- const g = l1.children.find((c) => c.id === id)
- if (g) return { l1, g }
- }
- return null
- }
- function renderSidebar() {
- sidebar.innerHTML = '<div class="side-label">业务目录</div>'
- MENU.forEach((l1) => {
- const wrap = document.createElement("div")
- wrap.className = "tree-item"
- wrap.innerHTML =
- '<button class="tree-btn l1" data-l1="' + l1.id + '">' +
- '<span class="chev">' + (openL1 === l1.id ? "▼" : "▶") + "</span>" +
- l1.title + "</button>"
- const kids = document.createElement("div")
- kids.className = "tree-children" + (openL1 === l1.id ? " open" : "")
- l1.children.forEach((g) => {
- const b = document.createElement("button")
- b.className = "tree-btn" + (g.id === activeGroupId ? " is-active" : "")
- b.innerHTML = '<span class="chev"></span>' + g.title
- b.addEventListener("click", () => openGroup(g.id))
- kids.appendChild(b)
- })
- wrap.querySelector("[data-l1]").addEventListener("click", () => {
- openL1 = openL1 === l1.id ? "" : l1.id
- renderSidebar()
- })
- wrap.appendChild(kids)
- sidebar.appendChild(wrap)
- })
- }
- function openGroup(groupId) {
- if (activeGroupId === groupId) return
- const found = findGroup(groupId)
- if (!found) return
- loaded = new Set()
- activeGroupId = groupId
- activeLeafId = found.g.leaves[0].id
- crumbEl.innerHTML =
- "<strong>" + found.l1.title + "</strong> / " + found.g.title +
- " · 叶子 " + found.g.leaves.length + " 个"
- emptyEl.style.display = "none"
- renderSidebar()
- renderTabs(found.g)
- renderViews(found.g)
- log("切换目录「" + found.g.title + "」,清空上一组 Tab,准备打开 " +
- found.g.leaves.map((x) => x.title).join(" / "))
- loadLeaf(found.g.leaves[0], true)
- }
- function renderTabs(group) {
- tabsEl.innerHTML = ""
- if (!group || group.leaves.length <= 1) return
- const track = document.createElement("div")
- track.className = "tabs-track"
- group.leaves.forEach((leaf) => {
- const b = document.createElement("button")
- b.className = "tab" +
- (leaf.id === activeLeafId ? " is-on" : "") +
- (loaded.has(leaf.id) ? " is-loaded" : "")
- b.innerHTML = '<span class="dot"></span>' + leaf.title
- b.addEventListener("click", () => selectLeaf(leaf.id))
- track.appendChild(b)
- })
- tabsEl.appendChild(track)
- }
- function tableHtml(leaf) {
- const isStatus = leaf.rows[0] && leaf.rows[0].length === 4
- const heads = isStatus
- ? (leaf.id.indexOf("adv") === 0 || leaf.id.indexOf("rb") === 0
- ? ["单号", "申请人", leaf.rows[0][2].indexOf(".") >= 0 ? "金额" : "状态", "状态/节点"]
- : ["名称", "类型", "状态"])
- : ["列1", "列2", "列3"]
- if (leaf.id === "expense-item") {
- return headTable(["项目名称", "类型", "状态"], leaf.rows, (c, i) =>
- i === 2 ? tagHtml(c) : escapeHtml(c))
- }
- if (leaf.rows[0].length === 4) {
- return headTable(
- leaf.id === "adv-approve"
- ? ["单号", "申请人", "状态", "当前节点"]
- : ["单号", "申请人", "金额", "状态"],
- leaf.rows,
- (c, i) => i === 3 || (leaf.id === "adv-approve" && i === 2) ? tagHtml(c) : escapeHtml(c)
- )
- }
- return headTable(heads, leaf.rows, (c) => escapeHtml(c))
- }
- function headTable(heads, rows, cellFn) {
- let h = "<table><thead><tr>" + heads.map((x) => "<th>" + x + "</th>").join("") + "</tr></thead><tbody>"
- rows.forEach((r) => {
- h += "<tr>" + r.map((c, i) => "<td>" + cellFn(c, i) + "</td>").join("") + "</tr>"
- })
- return h + "</tbody></table>"
- }
- function tagHtml(text) {
- const t = String(text)
- let cls = "tag"
- if (t.indexOf("驳回") >= 0 || t.indexOf("停用") >= 0) cls += " warn"
- if (t.indexOf("待") >= 0) cls += " info"
- return '<span class="' + cls + '">' + escapeHtml(t) + "</span>"
- }
- function kvHtml(leaf) {
- return '<dl class="kv">' + leaf.fields.map(function (f) {
- return "<dt>" + escapeHtml(f[0]) + "</dt><dd>" + escapeHtml(f[1]) + "</dd>"
- }).join("") + "</dl>"
- }
- function escapeHtml(s) {
- return String(s).replace(/[&<>]/g, function (ch) {
- return ({ "&": "&", "<": "<", ">": ">" })[ch]
- })
- }
- function renderViews(group) {
- viewsEl.innerHTML = ""
- group.leaves.forEach((leaf) => {
- const el = document.createElement("div")
- el.className = "view" + (leaf.id === activeLeafId ? " is-show" : "")
- el.id = "view-" + leaf.id
- const body = loaded.has(leaf.id)
- ? (leaf.kind === "kv" ? kvHtml(leaf) : tableHtml(leaf))
- : '<p style="color:#64748b;margin:0">尚未加载。切换到该 Tab 后才会请求并渲染本界面。</p>'
- el.innerHTML =
- '<div class="view-head"><h2>' + escapeHtml(leaf.title) +
- '</h2><span class="badge">' + (loaded.has(leaf.id) ? "已加载" : "未加载") +
- "</span></div><div class=\"view-body\">" + body + "</div>"
- viewsEl.appendChild(el)
- })
- }
- function selectLeaf(leafId) {
- const found = findGroup(activeGroupId)
- if (!found) return
- const leaf = found.g.leaves.find((x) => x.id === leafId)
- if (!leaf) return
- activeLeafId = leafId
- renderTabs(found.g)
- Array.from(viewsEl.children).forEach((el) => {
- el.classList.toggle("is-show", el.id === "view-" + leafId)
- })
- if (loaded.has(leafId)) {
- log("切换到「" + leaf.title + "」(已缓存,不再请求)")
- return
- }
- loadLeaf(leaf, false)
- }
- function loadLeaf(leaf, isDefault) {
- if (loadTimer) clearTimeout(loadTimer)
- maskText.textContent = "正在加载「" + leaf.title + "」…"
- maskEl.classList.add("show")
- log((isDefault ? "默认打开第一个 Tab:" : "切换 Tab,按需加载:") + "「" + leaf.title + "」")
- loadTimer = setTimeout(function () {
- loaded.add(leaf.id)
- maskEl.classList.remove("show")
- const found = findGroup(activeGroupId)
- renderTabs(found.g)
- renderViews(found.g)
- log("「" + leaf.title + "」加载完成")
- }, 420)
- }
- renderSidebar()
- </script>
- </body>
- </html>
|