Files
docs/悦/货代SAAS平台后台/柏悦物流后台栏目架构图(0805原型).html
T
2026-08-12 19:11:56 +08:00

1403 lines
44 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">
<meta name="color-scheme" content="light dark">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%230e6870'/%3E%3Cpath d='M8 9h6v14H8zm10 0h6v6h-6zm0 10h6v4h-6z' fill='white'/%3E%3C/svg%3E">
<title>柏悦物流后台栏目全景</title>
<style>
:root {
color-scheme: light;
--page: #e8eef2;
--surface: #ffffff;
--surface-raised: #f7f9fb;
--surface-strong: #132b38;
--text: #162834;
--text-soft: #536674;
--text-faint: #71828e;
--line: #ccd7de;
--line-strong: #aebdc7;
--primary: #0e6870;
--primary-strong: #0a4f56;
--primary-soft: #dceff0;
--accent: #b96536;
--accent-soft: #f6e7dd;
--focus: #1976a3;
--shadow: 0 12px 30px rgba(25, 48, 61, 0.12);
--shadow-soft: 0 5px 16px rgba(25, 48, 61, 0.08);
--dot: rgba(59, 86, 100, 0.18);
--connector: rgba(75, 105, 120, 0.34);
--danger: #a23c3c;
--header-text: #f4fbfc;
--header-soft: #bad0d8;
}
html[data-theme="dark"] {
color-scheme: dark;
--page: #0e171d;
--surface: #16232b;
--surface-raised: #1b2b34;
--surface-strong: #071d26;
--text: #e7f0f3;
--text-soft: #b3c3ca;
--text-faint: #8fa4ae;
--line: #31444e;
--line-strong: #49606b;
--primary: #65b9ba;
--primary-strong: #8fd0cf;
--primary-soft: #173a3e;
--accent: #e2a078;
--accent-soft: #462f25;
--focus: #74c7e8;
--shadow: 0 14px 34px rgba(0, 0, 0, 0.3);
--shadow-soft: 0 6px 18px rgba(0, 0, 0, 0.23);
--dot: rgba(178, 205, 216, 0.15);
--connector: rgba(168, 198, 210, 0.28);
--danger: #f19a9a;
--header-text: #eef9fa;
--header-soft: #a8c1ca;
}
* {
box-sizing: border-box;
letter-spacing: 0;
}
html {
min-width: 320px;
background: var(--page);
scroll-behavior: smooth;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
overflow-x: hidden;
background: var(--page);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", "PingFang SC", Arial, sans-serif;
font-size: 14px;
line-height: 1.55;
}
button,
input {
font: inherit;
}
button {
color: inherit;
}
button:focus-visible,
input:focus-visible {
outline: 3px solid color-mix(in srgb, var(--focus) 42%, transparent);
outline-offset: 2px;
}
.app {
width: min(100%, 1680px);
margin: 0 auto;
padding: 18px;
}
.masthead {
position: relative;
overflow: hidden;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 20px;
align-items: end;
min-height: 154px;
padding: 24px 26px;
border-radius: 12px 12px 0 0;
background: var(--surface-strong);
color: var(--header-text);
box-shadow: var(--shadow);
}
.masthead::after {
content: "";
position: absolute;
right: -70px;
bottom: -95px;
width: 320px;
height: 210px;
border: 1px solid rgba(177, 215, 221, 0.16);
border-radius: 50%;
transform: rotate(-12deg);
box-shadow:
0 0 0 38px rgba(177, 215, 221, 0.055),
0 0 0 82px rgba(177, 215, 221, 0.035);
pointer-events: none;
}
.masthead-copy,
.metrics {
position: relative;
z-index: 1;
}
.version-line {
display: flex;
align-items: center;
gap: 9px;
margin: 0 0 7px;
color: var(--header-soft);
font-size: 12px;
font-weight: 600;
}
.version-mark {
width: 9px;
height: 9px;
border: 2px solid #73c8c8;
border-radius: 50%;
box-shadow: 0 0 0 4px rgba(115, 200, 200, 0.12);
}
h1 {
max-width: none;
margin: 0;
font-size: clamp(28px, 4.1vw, 48px);
font-weight: 700;
line-height: 1.12;
}
.masthead p {
max-width: 68ch;
margin: 11px 0 0;
color: var(--header-soft);
font-size: 14px;
}
.metrics {
display: grid;
grid-template-columns: repeat(3, minmax(84px, 1fr));
gap: 8px;
min-width: 300px;
}
.metric {
min-height: 72px;
padding: 12px 13px;
border: 1px solid rgba(190, 219, 224, 0.17);
border-radius: 8px;
background: rgba(242, 252, 252, 0.075);
}
.metric-value {
display: block;
color: var(--header-text);
font-size: 25px;
font-weight: 700;
line-height: 1;
font-variant-numeric: tabular-nums;
}
.metric-label {
display: block;
margin-top: 8px;
color: var(--header-soft);
font-size: 12px;
}
.workspace {
overflow: hidden;
border-radius: 0 0 12px 12px;
background: var(--surface);
box-shadow: var(--shadow);
}
.toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 12px 14px;
border-bottom: 1px solid var(--line);
background: var(--surface);
}
.search-wrap {
position: relative;
flex: 1 1 280px;
max-width: 520px;
}
.search-icon {
position: absolute;
top: 50%;
left: 12px;
width: 17px;
height: 17px;
transform: translateY(-50%);
color: var(--text-faint);
pointer-events: none;
}
.search-input {
width: 100%;
height: 40px;
padding: 0 38px 0 38px;
border: 1px solid var(--line-strong);
border-radius: 8px;
background: var(--surface-raised);
color: var(--text);
}
.search-input::placeholder {
color: var(--text-faint);
}
.search-input::-webkit-search-cancel-button {
display: none;
appearance: none;
}
.search-clear {
position: absolute;
top: 50%;
right: 7px;
width: 28px;
height: 28px;
padding: 0;
border: 0;
border-radius: 6px;
transform: translateY(-50%);
background: transparent;
color: var(--text-soft);
cursor: pointer;
}
.search-clear[hidden] {
display: none;
}
.search-clear:hover {
background: var(--primary-soft);
color: var(--primary-strong);
}
.tool-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
min-height: 40px;
padding: 0 12px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--surface);
color: var(--text-soft);
font-weight: 600;
cursor: pointer;
}
.tool-button:hover {
border-color: var(--primary);
background: var(--primary-soft);
color: var(--primary-strong);
}
.tool-button svg {
width: 16px;
height: 16px;
flex: 0 0 auto;
}
.toolbar-spacer {
flex: 1 1 10px;
}
.match-status {
flex: 0 1 auto;
min-width: 150px;
margin: 0;
color: var(--text-soft);
text-align: right;
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.map-viewport {
position: relative;
height: min(72vh, 780px);
min-height: 560px;
overflow: hidden;
border-bottom: 1px solid var(--line);
background-color: var(--surface-raised);
background-image: radial-gradient(circle, var(--dot) 1px, transparent 1.3px);
background-size: 20px 20px;
cursor: grab;
touch-action: none;
user-select: none;
}
.map-viewport.is-dragging {
cursor: grabbing;
}
.map-canvas {
position: absolute;
inset: 0;
width: 100%;
min-width: 1080px;
min-height: 100%;
padding: 30px 34px 52px;
transform-origin: 0 0;
will-change: transform;
}
.connector-layer {
position: absolute;
inset: 0;
z-index: 0;
width: 100%;
height: 100%;
overflow: visible;
pointer-events: none;
}
.connector-line {
fill: none;
stroke: var(--connector);
stroke-width: 1.4;
vector-effect: non-scaling-stroke;
}
.root-node {
position: relative;
z-index: 2;
width: max-content;
max-width: calc(100% - 40px);
margin: 0 auto 42px;
padding: 13px 21px;
border: 1px solid rgba(156, 218, 218, 0.34);
border-radius: 9px;
background: var(--surface-strong);
color: var(--header-text);
box-shadow: var(--shadow);
text-align: center;
font-size: 16px;
font-weight: 700;
}
.root-node small {
display: block;
margin-top: 3px;
color: var(--header-soft);
font-size: 11px;
font-weight: 500;
}
.menu-grid {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: repeat(4, minmax(230px, 1fr));
gap: 14px;
align-items: start;
}
.menu-node {
position: relative;
min-width: 0;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--surface);
box-shadow: var(--shadow-soft);
transition:
border-color 180ms ease-out,
box-shadow 180ms ease-out,
opacity 180ms ease-out;
}
.menu-node.is-dimmed {
opacity: 0.28;
}
.menu-node.is-match,
.menu-node.is-path,
.menu-node.is-focused {
border-color: var(--primary);
box-shadow: 0 7px 20px rgba(14, 104, 112, 0.16);
}
.menu-node.is-focused {
box-shadow:
0 0 0 3px color-mix(in srgb, var(--primary) 20%, transparent),
0 10px 26px rgba(14, 104, 112, 0.2);
}
.menu-node-header {
display: flex;
align-items: center;
gap: 9px;
min-width: 0;
flex: 1;
min-height: 46px;
padding: 10px 5px 10px 11px;
border: 0;
border-radius: 9px;
background: transparent;
color: var(--text);
text-align: left;
cursor: pointer;
}
.menu-node-top {
display: flex;
align-items: stretch;
min-width: 0;
padding-right: 5px;
}
.menu-node-header:hover {
background: var(--primary-soft);
}
.menu-focus-button {
display: grid;
place-items: center;
width: 40px;
height: 40px;
flex: 0 0 40px;
align-self: center;
border: 0;
border-radius: 7px;
background: transparent;
color: var(--text-faint);
cursor: pointer;
transition: background 160ms ease-out, color 160ms ease-out;
}
.menu-focus-button:hover {
background: var(--primary-soft);
color: var(--primary-strong);
}
.menu-focus-button svg {
width: 17px;
height: 17px;
}
.menu-index {
display: inline-grid;
place-items: center;
width: 25px;
height: 25px;
flex: 0 0 auto;
border-radius: 6px;
background: var(--surface-strong);
color: var(--header-text);
font-size: 11px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.menu-node.is-match .menu-index,
.menu-node.is-path .menu-index,
.menu-node.is-focused .menu-index {
background: var(--primary-strong);
}
.menu-title {
min-width: 0;
flex: 1;
overflow-wrap: anywhere;
font-weight: 700;
}
.menu-count {
flex: 0 0 auto;
color: var(--text-faint);
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.chevron {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: var(--text-faint);
transition: transform 180ms ease-out;
}
.menu-node[aria-expanded="false"] .chevron {
transform: rotate(-90deg);
}
.menu-children {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 0 11px 12px;
border-top: 1px solid transparent;
}
.menu-node[aria-expanded="false"] .menu-children {
display: none;
}
.child-node,
.direct-entry {
max-width: 100%;
padding: 5px 8px;
border: 1px solid transparent;
border-radius: 6px;
background: var(--surface-raised);
color: var(--text-soft);
overflow-wrap: anywhere;
font-size: 12px;
line-height: 1.35;
}
.child-node.is-match {
border-color: var(--accent);
background: var(--accent-soft);
color: var(--text);
font-weight: 700;
}
.child-node.is-dimmed {
opacity: 0.35;
}
.direct-entry {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--primary-strong);
font-weight: 700;
}
.direct-entry::before {
content: "";
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--primary);
}
.empty-search {
position: absolute;
inset: 50% auto auto 50%;
z-index: 5;
width: min(360px, calc(100% - 40px));
padding: 16px;
border: 1px solid var(--line);
border-radius: 9px;
transform: translate(-50%, -50%);
background: var(--surface);
box-shadow: var(--shadow);
color: var(--text-soft);
text-align: center;
}
.empty-search[hidden] {
display: none;
}
.viewport-controls {
position: absolute;
right: 14px;
bottom: 14px;
z-index: 10;
display: flex;
align-items: center;
gap: 4px;
padding: 4px;
border: 1px solid var(--line);
border-radius: 9px;
background: var(--surface);
box-shadow: var(--shadow-soft);
}
.viewport-button {
display: inline-grid;
place-items: center;
width: 34px;
height: 34px;
padding: 0;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--text-soft);
cursor: pointer;
}
.viewport-button:hover {
background: var(--primary-soft);
color: var(--primary-strong);
}
.viewport-button svg {
width: 17px;
height: 17px;
}
.zoom-value {
min-width: 48px;
color: var(--text-soft);
text-align: center;
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.workspace-footer {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px 22px;
padding: 12px 15px;
background: var(--surface);
color: var(--text-faint);
font-size: 12px;
}
.legend {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-left: auto;
}
.legend-item {
display: inline-flex;
align-items: center;
gap: 6px;
}
.legend-swatch {
width: 11px;
height: 11px;
border-radius: 3px;
background: var(--surface-strong);
}
.legend-swatch.child {
border: 1px solid var(--line);
background: var(--surface-raised);
}
.legend-swatch.match {
border: 1px solid var(--accent);
background: var(--accent-soft);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.noscript-panel {
margin: 18px;
padding: 18px;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--surface);
}
.noscript-panel h2 {
margin: 0 0 12px;
font-size: 20px;
}
.noscript-panel details {
padding: 8px 0;
border-bottom: 1px solid var(--line);
}
.noscript-panel li {
margin: 5px 0;
}
@keyframes map-arrive {
from {
filter: blur(4px);
transform: translate3d(0, 12px, 0) scale(0.99);
}
to {
filter: blur(0);
transform: translate3d(0, 0, 0) scale(1);
}
}
@media (prefers-reduced-motion: no-preference) {
.map-canvas.is-initial {
animation: map-arrive 520ms cubic-bezier(0.16, 1, 0.3, 1);
}
}
@media (max-width: 1050px) {
.masthead {
grid-template-columns: 1fr;
align-items: start;
}
.metrics {
min-width: 0;
width: min(100%, 460px);
}
.menu-grid {
grid-template-columns: repeat(3, minmax(230px, 1fr));
}
}
@media (max-width: 760px) {
.app {
padding: 10px;
}
.masthead {
min-height: 0;
padding: 20px;
}
.metrics {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.metric {
min-height: 66px;
padding: 10px;
}
.metric-value {
font-size: 22px;
}
.toolbar-spacer {
display: none;
}
.match-status {
order: 5;
width: 100%;
min-width: 0;
text-align: left;
}
.map-viewport {
height: 68vh;
min-height: 570px;
}
.map-canvas {
min-width: 780px;
padding: 24px 24px 58px;
}
.menu-grid {
grid-template-columns: repeat(2, minmax(230px, 1fr));
}
.workspace-footer {
align-items: flex-start;
flex-direction: column;
}
.legend {
margin-left: 0;
}
}
@media (max-width: 480px) {
.masthead {
border-radius: 9px 9px 0 0;
}
h1 {
font-size: 30px;
}
.metrics {
gap: 5px;
}
.metric-label {
font-size: 11px;
}
.workspace {
border-radius: 0 0 9px 9px;
}
.search-wrap {
flex-basis: 100%;
max-width: none;
}
.tool-button {
flex: 1 1 auto;
padding-inline: 9px;
}
.theme-label {
display: none;
}
.map-viewport {
min-height: 600px;
}
.map-canvas {
width: 100%;
min-width: 100%;
padding: 24px 14px 58px;
}
.menu-grid {
grid-template-columns: minmax(0, 1fr);
}
.viewport-controls {
top: 9px;
right: auto;
bottom: auto;
left: 9px;
}
.root-node {
margin-top: 54px;
}
}
</style>
</head>
<body>
<!--
THESIS: 用可操作的全景关系图取代静态树状文档,让真实栏目一屏可见、一搜可达。
OWN-WORLD: 深海军蓝、冷白与青绿色构成物流控制台;点阵画布、路径和紧凑节点承载结构。
STORY: 使用者先看全局,再搜索或聚焦栏目,最终核对准确归属。
FIRST VIEWPORT: 标题与工具栏压缩在顶部,全景画布占据主体,搜索是首要操作。
FORM: 已确认的 C 全景关系图方案;采用中心根节点与分区节点矩阵,不使用营销式卡片墙。
-->
<div class="app">
<header class="masthead">
<div class="masthead-copy">
<div class="version-line"><span class="version-mark" aria-hidden="true"></span>信息架构 · 最新 0805 原型</div>
<h1>柏悦物流后台栏目全景</h1>
<p>基于最新 0805 原型左侧导航整理。搜索、聚焦或缩放查看栏目关系,未挂载页面不计入栏目。</p>
</div>
<div class="metrics" aria-label="栏目统计">
<div class="metric"><strong id="metricPrimary" class="metric-value">0</strong><span class="metric-label">一级栏目</span></div>
<div class="metric"><strong id="metricSecondary" class="metric-value">0</strong><span class="metric-label">二级栏目</span></div>
<div class="metric"><strong id="metricEntries" class="metric-value">0</strong><span class="metric-label">导航入口</span></div>
</div>
</header>
<main class="workspace">
<section class="toolbar" aria-label="架构图工具栏">
<label class="search-wrap">
<span class="sr-only">搜索栏目</span>
<svg class="search-icon" viewBox="0 0 24 24" fill="none" aria-hidden="true"><circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="2"/><path d="m20 20-3.4-3.4" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
<input id="searchInput" class="search-input" type="search" role="searchbox" aria-label="搜索栏目" placeholder="搜索一级或二级栏目,例如:入库单">
<button id="clearSearchBtn" class="search-clear" type="button" aria-label="清空搜索" hidden>×</button>
</label>
<button id="expandAllBtn" class="tool-button" type="button" aria-label="全部收起">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="m7 9 5 5 5-5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
<span>全部收起</span>
</button>
<button id="resetBtn" class="tool-button" type="button" aria-label="重置视图">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M3 12a9 9 0 1 0 3-6.7L3 8" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><path d="M3 3v5h5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
<span>重置视图</span>
</button>
<button id="themeBtn" class="tool-button" type="button" aria-label="切换为深色主题">
<svg id="themeIcon" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M12 3a9 9 0 1 0 9 9 7 7 0 0 1-9-9Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></svg>
<span class="theme-label">深色</span>
</button>
<span class="toolbar-spacer" aria-hidden="true"></span>
<p id="matchStatus" class="match-status">显示全部 59 个栏目节点</p>
</section>
<section id="mapViewport" class="map-viewport" aria-label="栏目全景关系图">
<div id="mapCanvas" class="map-canvas is-initial">
<svg id="connectorLayer" class="connector-layer" aria-hidden="true"></svg>
<div id="rootNode" class="root-node">柏悦物流后台<small>最新 0805 原型导航</small></div>
<div id="menuGrid" class="menu-grid"></div>
</div>
<div id="emptySearch" class="empty-search" hidden>没有找到匹配栏目,请尝试更短的关键词。</div>
<div class="viewport-controls" aria-label="画布缩放">
<button id="zoomOutBtn" class="viewport-button" type="button" aria-label="缩小">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M5 12h14" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
</button>
<span id="zoomValue" class="zoom-value" aria-live="polite">100%</span>
<button id="zoomInBtn" class="viewport-button" type="button" aria-label="放大">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
</button>
</div>
</section>
<footer class="workspace-footer">
<span>画布缩放 · 拖动浏览 · 栏目聚焦 · 视图复位</span>
<div class="legend" aria-label="图例">
<span class="legend-item"><i class="legend-swatch" aria-hidden="true"></i>一级栏目</span>
<span class="legend-item"><i class="legend-swatch child" aria-hidden="true"></i>二级栏目</span>
<span class="legend-item"><i class="legend-swatch match" aria-hidden="true"></i>搜索命中</span>
</div>
</footer>
<p id="liveStatus" class="sr-only" aria-live="polite"></p>
</main>
</div>
<noscript>
<section class="noscript-panel">
<h2>完整栏目清单</h2>
<details open><summary>首页数据看板</summary><p>直接进入看板页面</p></details>
<details><summary>客户管理</summary><ul><li>客户列表</li><li>海外仓阶梯费用</li></ul></details>
<details><summary>国内仓</summary><ul><li>订单列表</li><li>订单测量</li><li>测量数据审核</li><li>库位管理</li><li>托盘管理</li><li>整柜业务</li></ul></details>
<details><summary>海外仓</summary><ul><li>尾程订单</li><li>入库单</li><li>排车列表</li><li>出库单</li><li>换标单</li><li>托盘管理</li><li>箱号管理</li><li>产品库</li><li>库存管理</li></ul></details>
<details><summary>海外派送</summary><ul><li>派送单列表</li></ul></details>
<details><summary>供应商中心</summary><ul><li>供应商列表</li><li>快递公司</li><li>往来单位</li></ul></details>
<details><summary>货柜管理</summary><ul><li>订舱列表</li><li>拖车列表</li><li>货柜列表</li><li>装柜清单</li><li>预装清单</li></ul></details>
<details><summary>海外业务</summary><ul><li>采购订单</li><li>本土卡车</li><li>本土散板</li></ul></details>
<details><summary>财务管理</summary><ul><li>客户账户</li><li>客户应收应付</li><li>客户对账单</li><li>结算单应收</li><li>结算单应付</li><li>费用分析</li><li>利润表</li></ul></details>
<details><summary>销售管理</summary><ul><li>拜访记录</li><li>销售提成</li></ul></details>
<details><summary>通用</summary><ul><li>产品信息</li><li>费用项目</li><li>仓库管理</li><li>下载中心</li><li>船期价格表</li><li>车辆列表</li></ul></details>
<details><summary>系统管理</summary><ul><li>用户管理</li><li>角色权限</li><li>菜单权限</li></ul></details>
</section>
</noscript>
<script>
(() => {
"use strict";
const MENU_DATA = [
{ name: "首页数据看板", children: [] },
{ name: "客户管理", children: ["客户列表", "海外仓阶梯费用"] },
{ name: "国内仓", children: ["订单列表", "订单测量", "测量数据审核", "库位管理", "托盘管理", "整柜业务"] },
{ name: "海外仓", children: ["尾程订单", "入库单", "排车列表", "出库单", "换标单", "托盘管理", "箱号管理", "产品库", "库存管理"] },
{ name: "海外派送", children: ["派送单列表"] },
{ name: "供应商中心", children: ["供应商列表", "快递公司", "往来单位"] },
{ name: "货柜管理", children: ["订舱列表", "拖车列表", "货柜列表", "装柜清单", "预装清单"] },
{ name: "海外业务", children: ["采购订单", "本土卡车", "本土散板"] },
{ name: "财务管理", children: ["客户账户", "客户应收应付", "客户对账单", "结算单应收", "结算单应付", "费用分析", "利润表"] },
{ name: "销售管理", children: ["拜访记录", "销售提成"] },
{ name: "通用", children: ["产品信息", "费用项目", "仓库管理", "下载中心", "船期价格表", "车辆列表"] },
{ name: "系统管理", children: ["用户管理", "角色权限", "菜单权限"] },
];
function getMapNodeCount() {
return 1 + MENU_DATA.length + MENU_DATA.reduce((sum, menu) => sum + menu.children.length, 0);
}
const els = {
root: document.documentElement,
metricPrimary: document.getElementById("metricPrimary"),
metricSecondary: document.getElementById("metricSecondary"),
metricEntries: document.getElementById("metricEntries"),
search: document.getElementById("searchInput"),
clearSearch: document.getElementById("clearSearchBtn"),
expandAll: document.getElementById("expandAllBtn"),
reset: document.getElementById("resetBtn"),
theme: document.getElementById("themeBtn"),
themeIcon: document.getElementById("themeIcon"),
matchStatus: document.getElementById("matchStatus"),
liveStatus: document.getElementById("liveStatus"),
viewport: document.getElementById("mapViewport"),
canvas: document.getElementById("mapCanvas"),
rootNode: document.getElementById("rootNode"),
grid: document.getElementById("menuGrid"),
connectors: document.getElementById("connectorLayer"),
emptySearch: document.getElementById("emptySearch"),
zoomValue: document.getElementById("zoomValue"),
zoomIn: document.getElementById("zoomInBtn"),
zoomOut: document.getElementById("zoomOutBtn"),
};
const view = { x: 0, y: 0, scale: 1 };
const limits = { min: 0.6, max: 1.6 };
const expanded = new Map();
let searchSnapshot = null;
let focusedId = null;
let pointerState = null;
let connectorFrame = 0;
let searchFocusFrame = 0;
const iconChevron = '<svg class="chevron" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="m7 9 5 5 5-5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
function slug(index) {
return `menu-${String(index + 1).padStart(2, "0")}`;
}
function normalize(value) {
return String(value || "").trim().toLocaleLowerCase("zh-CN");
}
function makeMenuNode(menu, index) {
const id = slug(index);
const hasChildren = menu.children.length > 0;
expanded.set(id, hasChildren);
const article = document.createElement("article");
article.className = `menu-node${hasChildren ? "" : " no-children"}`;
article.dataset.menuNode = id;
article.dataset.menuName = menu.name;
if (hasChildren) article.setAttribute("aria-expanded", "true");
const header = document.createElement("button");
header.type = "button";
header.className = "menu-node-header";
if (hasChildren) {
header.setAttribute("aria-controls", `${id}-children`);
header.setAttribute("aria-expanded", "true");
}
header.innerHTML = `
<span class="menu-index">${String(index + 1).padStart(2, "0")}</span>
<span class="menu-title" data-menu-title>${menu.name}</span>
<span class="menu-count">${hasChildren ? `${menu.children.length} 项` : "直接入口"}</span>
${hasChildren ? iconChevron : ""}
`;
header.addEventListener("click", () => {
if (hasChildren) {
setExpanded(id, !expanded.get(id), true);
} else {
focusMenu(id);
}
});
header.addEventListener("dblclick", (event) => {
event.stopPropagation();
focusMenu(id);
});
const focusButton = document.createElement("button");
focusButton.type = "button";
focusButton.className = "menu-focus-button";
focusButton.setAttribute("aria-label", `聚焦${menu.name}`);
focusButton.title = `聚焦${menu.name}`;
focusButton.innerHTML = '<svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="2"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.93 4.93l2.12 2.12M16.95 16.95l2.12 2.12M19.07 4.93l-2.12 2.12M7.05 16.95l-2.12 2.12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>';
focusButton.addEventListener("click", () => focusMenu(id));
const top = document.createElement("div");
top.className = "menu-node-top";
top.append(header, focusButton);
const children = document.createElement("div");
children.id = `${id}-children`;
children.className = "menu-children";
children.dataset.children = "";
if (hasChildren) {
menu.children.forEach((childName) => {
const child = document.createElement("span");
child.className = "child-node";
child.dataset.childNode = "";
child.dataset.navEntry = "";
child.textContent = childName;
children.append(child);
});
} else {
const direct = document.createElement("span");
direct.className = "direct-entry";
direct.dataset.navEntry = "";
direct.textContent = "看板页面";
children.append(direct);
}
article.append(top, children);
return article;
}
function renderMap() {
const fragment = document.createDocumentFragment();
MENU_DATA.forEach((menu, index) => fragment.append(makeMenuNode(menu, index)));
els.grid.replaceChildren(fragment);
const secondaryCount = MENU_DATA.reduce((sum, menu) => sum + menu.children.length, 0);
els.metricPrimary.textContent = String(MENU_DATA.length);
els.metricSecondary.textContent = String(secondaryCount);
els.metricEntries.textContent = String(secondaryCount + 1);
els.matchStatus.textContent = `显示全部 ${getMapNodeCount()} 个栏目节点`;
scheduleConnectors();
}
function setExpanded(id, value, announce = false) {
const node = els.grid.querySelector(`[data-menu-node="${id}"]`);
if (!node || node.classList.contains("no-children")) return;
expanded.set(id, value);
node.setAttribute("aria-expanded", String(value));
node.querySelector(".menu-node-header").setAttribute("aria-expanded", String(value));
updateExpandAllButton();
scheduleConnectors();
if (announce) {
const name = node.dataset.menuName;
els.liveStatus.textContent = `${name}已${value ? "展开" : "收起"}`;
}
}
function setAllExpanded(value) {
MENU_DATA.forEach((menu, index) => {
if (menu.children.length) setExpanded(slug(index), value);
});
els.liveStatus.textContent = value ? "已展开全部一级栏目" : "已收起全部一级栏目";
}
function updateExpandAllButton() {
const expandable = MENU_DATA.filter((menu) => menu.children.length).length;
const expandedCount = [...expanded.values()].filter(Boolean).length;
const shouldCollapse = expandedCount === expandable;
const label = shouldCollapse ? "全部收起" : "全部展开";
els.expandAll.setAttribute("aria-label", label);
els.expandAll.querySelector("span").textContent = label;
els.expandAll.dataset.action = shouldCollapse ? "collapse" : "expand";
}
function applySearch(rawQuery) {
const query = normalize(rawQuery);
const nodes = [...els.grid.querySelectorAll("[data-menu-node]")];
let matches = 0;
let firstMatchedId = null;
if (query && !searchSnapshot) {
searchSnapshot = new Map(expanded);
}
nodes.forEach((node) => {
node.classList.remove("is-match", "is-path", "is-dimmed");
const parentMatch = normalize(node.dataset.menuName).includes(query);
const children = [...node.querySelectorAll("[data-child-node]")];
let childMatches = 0;
children.forEach((child) => {
child.classList.remove("is-match", "is-dimmed");
const childMatch = Boolean(query) && normalize(child.textContent).includes(query);
if (childMatch) {
child.classList.add("is-match");
childMatches += 1;
matches += 1;
} else if (query) {
child.classList.add("is-dimmed");
}
});
if (query && parentMatch) {
node.classList.add("is-match");
matches += 1;
firstMatchedId ||= node.dataset.menuNode;
} else if (query && childMatches > 0) {
node.classList.add("is-path");
firstMatchedId ||= node.dataset.menuNode;
} else if (query) {
node.classList.add("is-dimmed");
}
if (query && childMatches > 0) {
setExpanded(node.dataset.menuNode, true);
}
});
if (!query && searchSnapshot) {
searchSnapshot.forEach((value, id) => setExpanded(id, value));
searchSnapshot = null;
}
els.clearSearch.hidden = !query;
els.emptySearch.hidden = !query || matches > 0;
if (query) {
els.matchStatus.textContent = matches > 0 ? `找到 ${matches} 个匹配栏目` : "未找到匹配栏目";
els.liveStatus.textContent = els.matchStatus.textContent;
} else {
els.matchStatus.textContent = `显示全部 ${getMapNodeCount()} 个栏目节点`;
els.liveStatus.textContent = "已清空搜索,恢复全部栏目";
}
cancelAnimationFrame(searchFocusFrame);
if (query && firstMatchedId) {
searchFocusFrame = requestAnimationFrame(() => focusMenu(firstMatchedId));
}
scheduleConnectors();
}
function clearSearch() {
els.search.value = "";
applySearch("");
els.search.focus();
}
function setTheme(theme, persist = true) {
const resolved = theme === "dark" ? "dark" : "light";
els.root.dataset.theme = resolved;
const next = resolved === "dark" ? "浅色" : "深色";
els.theme.setAttribute("aria-label", `切换为${next}主题`);
els.theme.querySelector(".theme-label").textContent = next;
els.themeIcon.innerHTML = resolved === "dark"
? '<circle cx="12" cy="12" r="4" stroke="currentColor" stroke-width="2"/><path d="M12 2v2M12 20v2M4.93 4.93l1.42 1.42M17.65 17.65l1.42 1.42M2 12h2M20 12h2M4.93 19.07l1.42-1.42M17.65 6.35l1.42-1.42" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>'
: '<path d="M12 3a9 9 0 1 0 9 9 7 7 0 0 1-9-9Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/>';
if (persist) {
try {
localStorage.setItem("baiyue-architecture-theme", resolved);
} catch (_) {
// Local file privacy settings may disable storage.
}
}
}
function initTheme() {
let saved = null;
try {
saved = localStorage.getItem("baiyue-architecture-theme");
} catch (_) {
saved = null;
}
const systemDark = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
setTheme(saved || (systemDark ? "dark" : "light"), false);
}
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
function applyView() {
els.canvas.style.transform = `translate3d(${view.x}px, ${view.y}px, 0) scale(${view.scale})`;
els.zoomValue.textContent = `${Math.round(view.scale * 100)}%`;
}
function zoomAt(nextScale, clientX, clientY) {
const scale = clamp(nextScale, limits.min, limits.max);
const rect = els.viewport.getBoundingClientRect();
const px = clientX - rect.left;
const py = clientY - rect.top;
const worldX = (px - view.x) / view.scale;
const worldY = (py - view.y) / view.scale;
view.x = px - worldX * scale;
view.y = py - worldY * scale;
view.scale = scale;
applyView();
}
function resetView(announce = true) {
view.x = 0;
view.y = 0;
view.scale = 1;
focusedId = null;
els.grid.querySelectorAll(".is-focused").forEach((node) => node.classList.remove("is-focused"));
applyView();
if (announce) els.liveStatus.textContent = "画布视图已复位";
}
function focusMenu(id) {
const node = els.grid.querySelector(`[data-menu-node="${id}"]`);
if (!node) return;
focusedId = id;
els.grid.querySelectorAll(".is-focused").forEach((item) => item.classList.remove("is-focused"));
node.classList.add("is-focused");
const viewportRect = els.viewport.getBoundingClientRect();
const canvasRect = els.canvas.getBoundingClientRect();
const nodeRect = node.getBoundingClientRect();
const worldCenterX = (nodeRect.left + nodeRect.width / 2 - canvasRect.left) / view.scale;
const worldCenterY = (nodeRect.top + nodeRect.height / 2 - canvasRect.top) / view.scale;
view.x = viewportRect.width / 2 - worldCenterX * view.scale;
view.y = viewportRect.height / 2 - worldCenterY * view.scale;
applyView();
els.liveStatus.textContent = `已聚焦${node.dataset.menuName}`;
}
function scheduleConnectors() {
cancelAnimationFrame(connectorFrame);
connectorFrame = requestAnimationFrame(drawConnectors);
}
function drawConnectors() {
const canvasRect = els.canvas.getBoundingClientRect();
const rootRect = els.rootNode.getBoundingClientRect();
const scale = view.scale || 1;
const width = els.canvas.offsetWidth;
const height = Math.max(els.canvas.offsetHeight, els.grid.offsetTop + els.grid.offsetHeight + 48);
els.connectors.setAttribute("viewBox", `0 0 ${width} ${height}`);
els.connectors.setAttribute("width", String(width));
els.connectors.setAttribute("height", String(height));
els.connectors.innerHTML = "";
const startX = (rootRect.left + rootRect.width / 2 - canvasRect.left) / scale;
const startY = (rootRect.bottom - canvasRect.top) / scale;
els.grid.querySelectorAll("[data-menu-node]").forEach((node) => {
const rect = node.getBoundingClientRect();
const endX = (rect.left + rect.width / 2 - canvasRect.left) / scale;
const endY = (rect.top - canvasRect.top) / scale;
const midY = startY + Math.max(18, (endY - startY) * 0.45);
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("class", "connector-line");
path.setAttribute("d", `M ${startX} ${startY} C ${startX} ${midY}, ${endX} ${midY}, ${endX} ${endY}`);
els.connectors.append(path);
});
}
function bindEvents() {
els.search.addEventListener("input", () => applySearch(els.search.value));
els.search.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
event.preventDefault();
clearSearch();
}
});
els.clearSearch.addEventListener("click", clearSearch);
els.expandAll.addEventListener("click", () => setAllExpanded(els.expandAll.dataset.action === "expand"));
els.reset.addEventListener("click", () => resetView());
els.theme.addEventListener("click", () => setTheme(els.root.dataset.theme === "dark" ? "light" : "dark"));
els.zoomIn.addEventListener("click", () => {
const rect = els.viewport.getBoundingClientRect();
zoomAt(view.scale + 0.1, rect.left + rect.width / 2, rect.top + rect.height / 2);
});
els.zoomOut.addEventListener("click", () => {
const rect = els.viewport.getBoundingClientRect();
zoomAt(view.scale - 0.1, rect.left + rect.width / 2, rect.top + rect.height / 2);
});
els.viewport.addEventListener("wheel", (event) => {
event.preventDefault();
zoomAt(view.scale * (event.deltaY < 0 ? 1.1 : 0.9), event.clientX, event.clientY);
}, { passive: false });
els.viewport.addEventListener("pointerdown", (event) => {
if (event.target.closest("button")) return;
pointerState = { id: event.pointerId, x: event.clientX, y: event.clientY, viewX: view.x, viewY: view.y };
els.viewport.setPointerCapture(event.pointerId);
els.viewport.classList.add("is-dragging");
});
els.viewport.addEventListener("pointermove", (event) => {
if (!pointerState || pointerState.id !== event.pointerId) return;
view.x = pointerState.viewX + event.clientX - pointerState.x;
view.y = pointerState.viewY + event.clientY - pointerState.y;
applyView();
});
const endPointer = (event) => {
if (!pointerState || pointerState.id !== event.pointerId) return;
pointerState = null;
els.viewport.classList.remove("is-dragging");
};
els.viewport.addEventListener("pointerup", endPointer);
els.viewport.addEventListener("pointercancel", endPointer);
els.viewport.addEventListener("dblclick", (event) => {
if (!event.target.closest("[data-menu-node]")) resetView();
});
window.addEventListener("resize", scheduleConnectors);
}
initTheme();
renderMap();
bindEvents();
updateExpandAllButton();
resetView(false);
applySearch("");
requestAnimationFrame(() => els.canvas.classList.remove("is-initial"));
})();
</script>
</body>
</html>