Files
shanxia2.0/docs/demos/sidebar-group-leaf-tabs-demo.html
T
2026-10-05 12:57:51 +08:00

629 lines
20 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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 ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" })[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>