| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712 |
- <!DOCTYPE html>
- <html lang="zh-CN">
- <head>
- <meta charset="UTF-8" />
- <meta name="viewport" content="width=device-width, initial-scale=1" />
- <title>门户顶栏 · 一级下拉 + 中间平铺(更多溢出)Demo</title>
- <style>
- :root {
- --primary: #10b981;
- --primary-dark: #059669;
- --primary-bg: #ecfdf5;
- --header: #1ca851;
- --text: #0f172a;
- --muted: #64748b;
- --border: #e2e8f0;
- --bg: #f1f5f9;
- }
- * { 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 56px; 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; }
- .ok, .note {
- border-radius: 10px;
- padding: 10px 14px;
- font-size: 13px;
- margin-bottom: 12px;
- }
- .ok {
- background: var(--primary-bg);
- border: 1px solid #a7f3d0;
- color: #065f46;
- }
- .note {
- background: #fffbeb;
- border: 1px solid #fde68a;
- color: #92400e;
- }
- .controls {
- display: flex;
- flex-wrap: wrap;
- gap: 10px 18px;
- align-items: center;
- margin-bottom: 14px;
- padding: 12px 14px;
- background: #fff;
- border: 1px solid var(--border);
- border-radius: 12px;
- font-size: 13px;
- }
- .stage-label {
- display: flex;
- justify-content: space-between;
- align-items: baseline;
- gap: 12px;
- margin: 8px 0 8px;
- }
- .stage-label h2 { margin: 0; font-size: 16px; }
- .stage-label span { font-size: 12px; color: var(--muted); }
- .frame {
- border: 1px solid var(--border);
- border-radius: 12px;
- overflow: visible;
- background: #fff;
- box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
- }
- .portal-header {
- position: relative;
- display: flex;
- align-items: center;
- gap: 8px;
- height: 56px;
- padding: 0 16px 0 0;
- background: var(--header);
- color: #fff;
- user-select: none;
- overflow: visible;
- }
- .brand {
- display: flex;
- align-items: center;
- gap: 10px;
- flex-shrink: 0;
- padding-left: 16px;
- z-index: 2;
- }
- .logo {
- width: 28px;
- height: 28px;
- border-radius: 8px;
- background: rgba(255, 255, 255, 0.2);
- display: grid;
- place-items: center;
- font-size: 12px;
- font-weight: 700;
- }
- .brand-title {
- margin: 0;
- font-size: 15px;
- font-weight: 600;
- white-space: nowrap;
- }
- .l1-select {
- position: relative;
- flex-shrink: 0;
- z-index: 5;
- }
- .l1-trigger {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- height: 32px;
- padding: 0 10px 0 12px;
- border: 1px solid rgba(255, 255, 255, 0.35);
- border-radius: 8px;
- background: rgba(255, 255, 255, 0.12);
- color: #fff;
- font-size: 13px;
- font-weight: 600;
- cursor: pointer;
- white-space: nowrap;
- }
- .l1-trigger:hover,
- .l1-select.is-open .l1-trigger {
- background: rgba(255, 255, 255, 0.22);
- border-color: rgba(255, 255, 255, 0.55);
- }
- .l1-trigger .caret { opacity: 0.85; font-size: 11px; }
- .dropdown-panel {
- display: none;
- position: absolute;
- top: calc(100% + 6px);
- left: 0;
- min-width: 168px;
- max-height: 320px;
- overflow: auto;
- padding: 6px;
- background: #fff;
- border: 1px solid var(--border);
- border-radius: 10px;
- box-shadow: 0 10px 28px rgba(15, 23, 42, 0.12);
- z-index: 40;
- }
- .dropdown-panel.align-right { left: auto; right: 0; }
- .is-open > .dropdown-panel { display: block; }
- .opt {
- display: block;
- width: 100%;
- text-align: left;
- border: 0;
- background: transparent;
- border-radius: 8px;
- padding: 8px 10px;
- font-size: 13px;
- color: #1e293b;
- cursor: pointer;
- white-space: nowrap;
- }
- .opt:hover { background: var(--primary-bg); color: var(--primary-dark); }
- .opt.is-active {
- background: var(--primary-bg);
- color: var(--primary-dark);
- font-weight: 600;
- }
- .opt-group {
- padding: 6px 10px 4px;
- font-size: 11px;
- font-weight: 700;
- color: var(--muted);
- letter-spacing: 0.02em;
- }
- .opt-sub {
- padding-left: 18px;
- color: #475569;
- }
- /* 中间平铺:可伸缩,禁止横向滚动 */
- .nav-slot {
- flex: 1 1 auto;
- min-width: 0;
- height: 100%;
- display: flex;
- align-items: stretch;
- justify-content: center;
- overflow: hidden;
- z-index: 3;
- }
- .l2-bar {
- display: flex;
- align-items: stretch;
- justify-content: center;
- gap: 2px;
- height: 100%;
- max-width: 100%;
- min-width: 0;
- overflow: hidden;
- }
- .l2-item,
- .more-item {
- position: relative;
- display: inline-flex;
- align-items: center;
- gap: 4px;
- height: 100%;
- padding: 0 12px;
- border: 0;
- background: transparent;
- color: rgba(255, 255, 255, 0.92);
- font-size: 13px;
- font-weight: 500;
- cursor: pointer;
- white-space: nowrap;
- border-bottom: 2px solid transparent;
- flex-shrink: 0;
- }
- .l2-item:hover,
- .more-item:hover,
- .more-wrap.is-open .more-item {
- background: rgba(255, 255, 255, 0.12);
- }
- .l2-item.is-active,
- .more-item.has-active {
- background: rgba(255, 255, 255, 0.16);
- border-bottom-color: #fff;
- font-weight: 600;
- }
- .mini-caret { font-size: 10px; opacity: 0.8; }
- .l2-flyout {
- display: none;
- position: absolute;
- top: calc(100% - 2px);
- left: 50%;
- transform: translateX(-50%);
- min-width: 160px;
- padding: 6px;
- background: #fff;
- border: 1px solid var(--border);
- border-radius: 10px;
- box-shadow: 0 10px 28px rgba(15, 23, 42, 0.12);
- z-index: 45;
- }
- .l2-item.is-open .l2-flyout { display: block; }
- .more-wrap { position: relative; height: 100%; display: flex; align-items: stretch; }
- .more-wrap.is-open .dropdown-panel { display: block; }
- .header-end {
- margin-left: 4px;
- display: flex;
- align-items: center;
- gap: 8px;
- z-index: 2;
- flex-shrink: 0;
- }
- .ghost-btn {
- width: 32px;
- height: 32px;
- border-radius: 8px;
- border: 0;
- background: rgba(255, 255, 255, 0.12);
- color: #fff;
- cursor: pointer;
- }
- .avatar {
- width: 32px;
- height: 32px;
- border-radius: 50%;
- background: rgba(255, 255, 255, 0.25);
- display: grid;
- place-items: center;
- font-size: 12px;
- font-weight: 700;
- }
- .content {
- padding: 20px 22px 24px;
- min-height: 200px;
- background: var(--bg);
- border-radius: 0 0 12px 12px;
- }
- .crumb { font-size: 12px; color: var(--muted); margin-bottom: 8px; }
- .content h3 { margin: 0 0 8px; font-size: 18px; }
- .content p { margin: 0; font-size: 14px; color: #334155; max-width: 62ch; }
- .meta {
- margin-top: 10px;
- font-size: 12px;
- color: var(--muted);
- }
- .meta strong { color: #334155; }
- .legend { margin-top: 18px; display: grid; gap: 10px; }
- .card {
- background: #fff;
- border: 1px solid var(--border);
- border-radius: 12px;
- padding: 14px 16px;
- }
- .card h2 { margin: 0 0 8px; font-size: 15px; }
- .card ul { margin: 0; padding-left: 18px; font-size: 13px; color: #334155; }
- .card code {
- font-size: 12px;
- background: #f8fafc;
- border: 1px solid var(--border);
- border-radius: 6px;
- padding: 1px 6px;
- }
- </style>
- </head>
- <body>
- <div class="page">
- <h1>门户顶栏 Demo:一级下拉 + 中间平铺(无横向滚动)</h1>
- <p class="lead">
- 中间最多平铺 5 个二级;超过 5 个的全部收入「更多」下拉选择。无横向滚动。
- (线上已改为按宽度动态适配,本 Demo 仍可作三级交互示意。)
- </p>
- <div class="ok">
- <strong>溢出策略(Demo):前 5 个平铺,第 6 个起进「更多」。</strong>
- 当前激活项若在「更多」内,「更多」按钮会高亮。
- </div>
- <div class="note">
- <strong>三级菜单已覆盖两种入口:</strong><br />
- ① 平铺区:二级带 ▾,点击展开三级小下拉(如「客户管理」);无三级的二级可直接点选(如「产品目录」)。<br />
- ② 「更多」内:无三级的二级直接可选;有三级的以「二级分组标题 + 三级选项」列出(如「活动管理」下的子页)。
- </div>
- <div class="controls">
- <div id="fitMeta">平铺 — · 更多 —</div>
- <div style="color: var(--muted); font-size: 12px;">阈值 <code>MAX_VISIBLE = 5</code>,可按产品再调</div>
- </div>
- <div class="stage-label">
- <h2>可交互原型</h2>
- <span>左侧切换一级 · 中间最多 5 个二级 · 超出进「更多」下拉</span>
- </div>
- <div class="frame">
- <header class="portal-header" id="header">
- <div class="brand" title="回到工作台">
- <div class="logo">峡</div>
- <p class="brand-title">山峡化工一体化门户</p>
- </div>
- <div class="l1-select" id="l1Select">
- <button type="button" class="l1-trigger" id="l1Trigger" aria-haspopup="listbox" aria-expanded="false">
- <span id="l1Label">CRM</span>
- <span class="caret">▾</span>
- </button>
- <div class="dropdown-panel" id="l1Menu" role="listbox"></div>
- </div>
- <div class="nav-slot" aria-label="当前模块二级菜单">
- <nav class="l2-bar" id="l2Bar"></nav>
- </div>
- <div class="header-end">
- <button type="button" class="ghost-btn" title="全屏" aria-label="全屏">⛶</button>
- <div class="avatar" title="用户">管</div>
- </div>
- </header>
- <main class="content">
- <div class="crumb" id="crumb">CRM / 客户管理 / 客户列表</div>
- <h3 id="pageTitle">客户列表</h3>
- <p id="pageDesc">示意内容区。</p>
- <p class="meta" id="overflowHint"></p>
- </main>
- </div>
- <div class="legend">
- <div class="card">
- <h2>三级菜单规则(已确认)</h2>
- <ul>
- <li><strong>平铺二级有三级</strong>:显示 ▾,点击<strong>只展开</strong>三级小下拉,不跳转页面;选中某个三级后才进入该页</li>
- <li><strong>平铺二级无三级</strong>:直接点击进入该页</li>
- <li><strong>「更多」内有三级</strong>:二级作分组标题(不可点跳转),下列三级供选择</li>
- <li><strong>「更多」内无三级</strong>:二级本身可点选</li>
- </ul>
- </div>
- <div class="card">
- <h2>仍待确认</h2>
- <ul>
- <li>切换一级后是否跳转该模块默认首页?</li>
- <li>「更多」阈值是否固定 5,还是可配置?</li>
- </ul>
- </div>
- </div>
- </div>
- <script>
- const MODULES = [
- {
- id: 'crm',
- title: 'CRM',
- children: [
- // 前 5 个平铺:含「有三级 / 无三级」两种
- { title: '客户管理', children: [{ title: '客户列表' }, { title: '客户公海' }] },
- { title: '商机管理', children: [{ title: '商机列表' }, { title: '跟进记录' }] },
- { title: '合同管理', children: [{ title: '合同列表' }] },
- { title: '产品目录' },
- { title: '拜访计划', children: [{ title: '计划列表' }, { title: '拜访记录' }] },
- // 第 6 个起进「更多」:同样覆盖有/无三级
- { title: '销售报表' },
- { title: '价格策略', children: [{ title: '价格表' }, { title: '折扣规则' }] },
- { title: '渠道管理' },
- { title: '线索池', children: [{ title: '线索列表' }, { title: '线索分配' }] },
- { title: '活动管理', children: [{ title: '活动列表' }, { title: '报名明细' }] }
- ]
- },
- {
- id: 'bx',
- title: '报销',
- children: [
- { title: '我的报销', children: [{ title: '报销申请' }, { title: '报销草稿' }] },
- { title: '报销审批' },
- { title: '费用统计' },
- { title: '科目配置' },
- { title: '预算中心' }
- ]
- },
- {
- id: 'db',
- title: '地磅',
- children: [
- { title: '过磅记录' },
- { title: '车辆档案' },
- { title: '称重统计', children: [{ title: '日报' }, { title: '月报' }] },
- { title: '黑名单' },
- { title: '设备监控' },
- { title: '异常复核' }
- ]
- },
- {
- id: 'pl',
- title: '配送',
- children: [
- { title: '配送单' },
- { title: '线路规划' },
- { title: '配送员' },
- { title: '运力看板' }
- ]
- },
- {
- id: 'workflow',
- title: '流程',
- children: [
- { title: '待办任务' },
- { title: '已办任务' },
- { title: '流程定义' },
- { title: '流程实例' }
- ]
- },
- {
- id: 'system',
- title: '系统',
- children: [
- { title: '用户管理' },
- { title: '角色权限' },
- { title: '菜单管理' },
- { title: '租户管理' },
- { title: '字典管理' },
- { title: '操作日志' },
- { title: '参数配置' },
- { title: '消息中心' }
- ]
- }
- ]
- const MAX_VISIBLE = 5
- let activeL1 = MODULES[0].id
- let activeL2 = MODULES[0].children[0].title
- let activeL3 = MODULES[0].children[0].children?.[0]?.title || null
- let openL2Title = null
- let moreOpen = false
- const fitMeta = document.getElementById('fitMeta')
- const l1Select = document.getElementById('l1Select')
- const l1Trigger = document.getElementById('l1Trigger')
- const l1Menu = document.getElementById('l1Menu')
- const l1Label = document.getElementById('l1Label')
- const l2Bar = document.getElementById('l2Bar')
- const crumb = document.getElementById('crumb')
- const pageTitle = document.getElementById('pageTitle')
- const pageDesc = document.getElementById('pageDesc')
- const overflowHint = document.getElementById('overflowHint')
- function currentModule() {
- return MODULES.find((m) => m.id === activeL1) || MODULES[0]
- }
- function hasKids(item) {
- return Array.isArray(item.children) && item.children.length > 0
- }
- function closePopovers() {
- l1Select.classList.remove('is-open')
- l1Trigger.setAttribute('aria-expanded', 'false')
- openL2Title = null
- moreOpen = false
- }
- function renderL1() {
- const mod = currentModule()
- l1Label.textContent = mod.title
- l1Menu.innerHTML = MODULES.map((m) => {
- const active = m.id === activeL1 ? ' is-active' : ''
- return `<button type="button" class="opt${active}" data-id="${m.id}" role="option">${m.title}</button>`
- }).join('')
- }
- function itemButtonHtml(item, { open = false } = {}) {
- const kids = hasKids(item)
- const isActive = item.title === activeL2
- const flyout = kids
- ? `<div class="l2-flyout">${item.children
- .map((c) => {
- const a = c.title === activeL3 && isActive ? ' is-active' : ''
- return `<button type="button" class="opt${a}" data-l2="${item.title}" data-l3="${c.title}">${c.title}</button>`
- })
- .join('')}</div>`
- : ''
- return `
- <button
- type="button"
- class="l2-item${isActive ? ' is-active' : ''}${open ? ' is-open' : ''}"
- data-l2="${item.title}"
- data-has-kids="${kids ? '1' : '0'}"
- >
- <span>${item.title}</span>
- ${kids ? `<span class="mini-caret">${open ? '▴' : '▾'}</span>` : ''}
- ${flyout}
- </button>
- `
- }
- function morePanelHtml(hiddenItems) {
- return hiddenItems
- .map((item) => {
- if (!hasKids(item)) {
- const a = item.title === activeL2 ? ' is-active' : ''
- return `<button type="button" class="opt${a}" data-l2="${item.title}" data-has-kids="0">${item.title}</button>`
- }
- const groupActive = item.title === activeL2
- const kids = item.children
- .map((c) => {
- const a = groupActive && c.title === activeL3 ? ' is-active' : ''
- return `<button type="button" class="opt opt-sub${a}" data-l2="${item.title}" data-l3="${c.title}">${c.title}</button>`
- })
- .join('')
- return `<div class="opt-group">${item.title}</div>${kids}`
- })
- .join('')
- }
- function renderL2() {
- const mod = currentModule()
- const items = mod.children
- const visible = items.slice(0, MAX_VISIBLE)
- const hidden = items.slice(MAX_VISIBLE)
- const hiddenHasActive = hidden.some((item) => item.title === activeL2)
- let html = visible
- .map((item) => itemButtonHtml(item, { open: openL2Title === item.title }))
- .join('')
- if (hidden.length) {
- html += `
- <div class="more-wrap${moreOpen ? ' is-open' : ''}" id="moreWrap">
- <button type="button" class="more-item${hiddenHasActive ? ' has-active' : ''}" id="moreBtn">
- <span>更多</span>
- <span class="mini-caret">${moreOpen ? '▴' : '▾'}</span>
- </button>
- <div class="dropdown-panel align-right" id="moreMenu">${morePanelHtml(hidden)}</div>
- </div>
- `
- }
- l2Bar.innerHTML = html
- fitMeta.textContent = `平铺 ${visible.length} · 更多 ${hidden.length}`
- overflowHint.textContent = hidden.length
- ? `二级超过 ${MAX_VISIBLE} 个:前 ${MAX_VISIBLE} 个平铺,另 ${hidden.length} 个在「更多」中下拉选择。`
- : `二级不超过 ${MAX_VISIBLE} 个,全部平铺,不显示「更多」。`
- }
- function syncContent() {
- const mod = currentModule()
- const leaf = activeL3 || activeL2
- crumb.textContent = [mod.title, activeL2, activeL3].filter(Boolean).join(' / ')
- pageTitle.textContent = leaf
- pageDesc.textContent =
- `当前一级「${mod.title}」,二级共 ${mod.children.length} 项。` +
- (activeL3 ? ` 已进入「${activeL3}」。` : ' 当前为二级直达页。')
- }
- function selectL1(id) {
- activeL1 = id
- const mod = currentModule()
- const first = mod.children[0]
- activeL2 = first.title
- activeL3 = first.children?.[0]?.title || null
- closePopovers()
- renderL1()
- renderL2()
- syncContent()
- }
- l1Trigger.addEventListener('click', (e) => {
- e.stopPropagation()
- const willOpen = !l1Select.classList.contains('is-open')
- openL2Title = null
- moreOpen = false
- renderL2()
- l1Select.classList.toggle('is-open', willOpen)
- l1Trigger.setAttribute('aria-expanded', willOpen ? 'true' : 'false')
- })
- l1Menu.addEventListener('click', (e) => {
- const btn = e.target.closest('.opt')
- if (!btn) return
- selectL1(btn.dataset.id)
- })
- l2Bar.addEventListener('click', (e) => {
- e.stopPropagation()
- const moreBtn = e.target.closest('#moreBtn')
- if (moreBtn) {
- moreOpen = !moreOpen
- openL2Title = null
- l1Select.classList.remove('is-open')
- renderL2()
- return
- }
- const fromMore = e.target.closest('#moreMenu .opt')
- if (fromMore) {
- activeL2 = fromMore.dataset.l2
- activeL3 = fromMore.dataset.l3 || null
- moreOpen = false
- openL2Title = null
- renderL2()
- syncContent()
- return
- }
- const l3 = e.target.closest('.l2-flyout .opt')
- if (l3) {
- activeL2 = l3.dataset.l2
- activeL3 = l3.dataset.l3
- openL2Title = null
- moreOpen = false
- renderL2()
- syncContent()
- return
- }
- const l2 = e.target.closest('.l2-item')
- if (!l2) return
- const title = l2.dataset.l2
- const kids = l2.dataset.hasKids === '1'
- const item = currentModule().children.find((c) => c.title === title)
- if (!item) return
- if (kids) {
- openL2Title = openL2Title === title ? null : title
- moreOpen = false
- l1Select.classList.remove('is-open')
- renderL2()
- return
- }
- activeL2 = title
- activeL3 = null
- openL2Title = null
- moreOpen = false
- renderL2()
- syncContent()
- })
- document.addEventListener('click', () => {
- closePopovers()
- renderL2()
- })
- document.addEventListener('keydown', (e) => {
- if (e.key === 'Escape') {
- closePopovers()
- renderL2()
- }
- })
- renderL1()
- renderL2()
- syncContent()
- </script>
- </body>
- </html>
|