1403 lines
44 KiB
HTML
1403 lines
44 KiB
HTML
<!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>
|