<style>
:root {
--sidebar-width: 420px;
--primary: #1a6b52;
--primary-light: #e8f5e9;
--primary-dark: #0d4a37;
--accent: #2196f3;
--accent-light: #e3f2fd;
--danger: #e53935;
--danger-light: #ffebee;
--warning: #f57c00;
--warning-light: #fff3e0;
--bg: #eef2f5;
--surface: #fff;
--text: #1e293b;
--text-secondary: #475569;
--text-light: #94a3b8;
--border: #e2e8f0;
--border-light: #f1f5f9;
--radius-sm: 6px;
--radius: 10px;
--radius-lg: 16px;
--transition: 0.2s cubic-bezier(0.4, 0, 0.2, 1);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04);
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.07);
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.1);
--shadow-xl: 0 16px 48px rgba(0, 0, 0, 0.13);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
'Microsoft YaHei', 'Helvetica Neue', sans-serif;
background: var(--bg);
color: var(--text);
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
padding: 12px;
-webkit-font-smoothing: antialiased;
user-select: none;
-webkit-user-select: none;
}
.app-container {
display: flex;
width: 100%;
max-width: 1360px;
height: 94vh;
max-height: 920px;
background: var(--surface);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-xl);
overflow: hidden;
}
/* ========== 左侧树面板 ========== */
.tree-panel {
width: var(--sidebar-width);
min-width: var(--sidebar-width);
display: flex;
flex-direction: column;
background: #fafbfd;
border-right: 1px solid var(--border);
position: relative;
z-index: 2;
height: 100%;
}
.panel-header {
padding: 12px 16px 10px 16px;
background: #fff;
border-bottom: 1px solid var(--border-light);
flex-shrink: 0;
}
.panel-header .brand {
display: flex;
align-items: center;
gap: 8px;
font-weight: 700;
font-size: 0.95rem;
color: var(--primary-dark);
letter-spacing: 0.02em;
}
.panel-header .brand .icon {
width: 30px;
height: 30px;
border-radius: 7px;
background: linear-gradient(135deg, #1a6b52, #2e9d76);
display: flex;
align-items: center;
justify-content: center;
font-size: 0.9rem;
color: #fff;
flex-shrink: 0;
}
.panel-header .subtitle {
font-size: 0.66rem;
color: var(--text-light);
margin-top: 2px;
padding-left: 38px;
letter-spacing: 0.04em;
}
/* 树容器 */
.tree-scroll {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 8px 4px 12px 8px;
scroll-behavior: smooth;
-webkit-overflow-scrolling: touch;
background: #fafbfd;
}
.tree-scroll::-webkit-scrollbar {
width: 4px;
}
.tree-scroll::-webkit-scrollbar-track {
background: transparent;
}
.tree-scroll::-webkit-scrollbar-thumb {
background: #d0d7de;
border-radius: 8px;
}
.tree-scroll::-webkit-scrollbar-thumb:hover {
background: #b0b9c2;
}
/* 状态栏 */
.panel-footer {
padding: 7px 14px;
border-top: 1px solid var(--border-light);
background: #fff;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
font-size: 0.68rem;
color: var(--text-light);
letter-spacing: 0.03em;
gap: 6px;
flex-wrap: wrap;
}
.panel-footer .count-badge {
font-weight: 600;
color: var(--primary-dark);
background: var(--primary-light);
padding: 2px 10px;
border-radius: 10px;
font-size: 0.7rem;
transition: all var(--transition);
}
.panel-footer .count-badge.zero {
background: #f1f5f9;
color: #94a3b8;
}
.panel-footer .footer-link {
cursor: pointer;
color: var(--accent);
font-weight: 500;
transition: color var(--transition);
text-decoration: none;
font-size: 0.68rem;
}
.panel-footer .footer-link:hover {
color: #0d47a1;
text-decoration: underline;
}
/* ========== 总节点样式 ========== */
.root-node.summary-container {
padding: 6px 8px;
background: #f8fafd;
border-radius: var(--radius-sm);
margin-bottom: 6px;
border: 1px solid #e9eef3;
transition: all var(--transition);
}
.root-node.summary-container:hover {
border-color: #cbd5e1;
}
.root-node > summary {
display: flex;
align-items: center;
list-style: none;
cursor: pointer;
outline: none;
gap: 4px;
min-height: 42px;
padding: 2px 4px;
}
.root-node > summary::-webkit-details-marker,
.root-node > summary::marker {
display: none;
}
.root-node .tree-arrow {
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
transition: transform var(--transition);
color: #9aa8b4;
font-size: 0.55rem;
flex-shrink: 0;
}
.root-node[open] > summary .tree-arrow {
transform: rotate(90deg);
color: #556575;
}
.root-node .tree-checkbox {
width: 16px;
height: 16px;
flex-shrink: 0;
cursor: pointer;
accent-color: var(--accent);
margin: 0 3px;
transition: transform var(--transition);
}
.root-node .tree-checkbox:active {
transform: scale(0.85);
}
.root-node .node-dot.root-dot {
width: 14px;
height: 14px;
border-radius: 50%;
background: #1a6b52;
box-shadow: 0 0 0 4px rgba(26, 107, 82, 0.15);
flex-shrink: 0;
}
.root-node .node-name {
font-size: 0.95rem;
font-weight: 700;
color: #0d4a37;
letter-spacing: 0.03em;
white-space: nowrap;
margin-right: 8px;
flex-shrink: 0;
}
.root-node .root-badge {
font-size: 0.62rem;
padding: 2px 8px;
border-radius: 10px;
font-weight: 600;
background: #e8f5e9;
color: #1a6b52;
letter-spacing: 0.04em;
flex-shrink: 0;
}
/* 总节点右侧操作区 */
.root-actions {
display: flex;
align-items: center;
gap: 5px;
margin-left: auto;
flex-shrink: 0;
flex-wrap: wrap;
justify-content: flex-end;
}
.root-actions .search-box {
position: relative;
display: flex;
align-items: center;
width: 120px;
}
.root-actions .search-box .search-icon {
position: absolute;
left: 7px;
top: 50%;
transform: translateY(-50%);
color: #b0b8c1;
font-size: 0.7rem;
pointer-events: none;
z-index: 1;
}
.root-actions .search-input {
width: 100%;
height: 30px;
padding: 0 28px 0 26px;
border: 2px solid #e2e8f0;
border-radius: 15px;
font-size: 0.72rem;
color: var(--text);
background: #fff;
outline: none;
transition: all var(--transition);
letter-spacing: 0.02em;
}
.root-actions .search-input::placeholder {
color: #bcc4cc;
}
.root-actions .search-input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(33, 150, 243, 0.06);
}
.root-actions .search-clear {
position: absolute;
right: 5px;
top: 50%;
transform: translateY(-50%);
width: 18px;
height: 18px;
border-radius: 50%;
border: none;
background: #d5dbe3;
color: #fff;
cursor: pointer;
font-size: 0.55rem;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
pointer-events: none;
transition: all var(--transition);
z-index: 1;
}
.root-actions .search-clear.visible {
opacity: 1;
pointer-events: auto;
}
.root-actions .search-clear:hover {
background: #e57373;
transform: translateY(-50%) scale(1.1);
}
.root-actions .sort-select {
height: 30px;
padding: 0 6px;
border: 1.5px solid #e2e8f0;
border-radius: 6px;
font-size: 0.68rem;
color: var(--text-secondary);
background: #fff;
cursor: pointer;
outline: none;
letter-spacing: 0.03em;
transition: all var(--transition);
min-width: 75px;
}
.root-actions .sort-select:hover {
border-color: #c0c8d2;
}
.root-actions .sort-select:focus {
border-color: var(--accent);
}
.root-actions .action-btn {
height: 28px;
width: 28px;
border: 1.5px solid #e2e8f0;
border-radius: 6px;
background: #fff;
cursor: pointer;
font-size: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
color: #5a6c7d;
transition: all var(--transition);
flex-shrink: 0;
}
.root-actions .action-btn:hover {
background: #f0f4f8;
border-color: #c0c8d2;
color: #1e293b;
}
.root-actions .action-btn.danger {
color: #c62828;
border-color: #f5c6cb;
}
.root-actions .action-btn.danger:hover {
background: #ffebee;
}
.root-actions .action-btn:active {
transform: scale(0.9);
}
/* 普通树节点 (保持不变) */
.tree-details {
display: block;
margin: 0;
padding: 0;
}
.tree-details > summary {
display: flex;
align-items: center;
cursor: pointer;
list-style: none;
padding: 5px 7px 5px 3px;
margin: 1px 0;
border-radius: var(--radius-sm);
transition: all var(--transition);
position: relative;
outline: none;
gap: 3px;
min-height: 32px;
letter-spacing: 0.02em;
}
.tree-details > summary::-webkit-details-marker,
.tree-details > summary::marker {
display: none;
}
.tree-details > summary:hover {
background: #f0f4f8;
}
.tree-details[open] > summary {
background: #f5f8fb;
font-weight: 600;
}
.tree-details .tree-arrow {
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
transition: transform var(--transition);
color: #9aa8b4;
font-size: 0.5rem;
border-radius: 3px;
margin-right: 1px;
}
.tree-details[open] > summary .tree-arrow {
transform: rotate(90deg);
color: #556575;
}
.tree-checkbox {
width: 15px;
height: 15px;
flex-shrink: 0;
cursor: pointer;
accent-color: var(--accent);
margin: 0 2px;
}
.tree-checkbox:active {
transform: scale(0.82);
}
.node-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
transition: all var(--transition);
}
.node-dot.level-1 {
width: 11px;
height: 11px;
background: #1a6b52;
box-shadow: 0 0 0 3px rgba(26, 107, 82, 0.1);
}
.node-dot.level-2 {
width: 8px;
height: 8px;
background: #2e9d76;
box-shadow: 0 0 0 2px rgba(46, 157, 118, 0.09);
}
.node-dot.level-3 {
width: 6px;
height: 6px;
background: #64b5a1;
box-shadow: 0 0 0 2px rgba(100, 181, 161, 0.07);
}
.node-name {
flex: 1;
font-size: 0.82rem;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: color var(--transition);
letter-spacing: 0.02em;
cursor: pointer;
padding: 1px 3px;
border-radius: 3px;
}
.node-name:hover {
color: var(--accent);
}
.level-1-name {
font-size: 0.87rem;
font-weight: 600;
color: #1a3c34;
}
.level-2-name {
font-size: 0.8rem;
font-weight: 500;
color: #2c4a3e;
}
.level-3-name {
font-size: 0.76rem;
font-weight: 400;
color: #4a6358;
}
.node-badge {
font-size: 0.58rem;
padding: 1px 6px;
border-radius: 8px;
font-weight: 500;
letter-spacing: 0.04em;
flex-shrink: 0;
opacity: 0.65;
transition: opacity var(--transition);
pointer-events: none;
}
.tree-details > summary:hover .node-badge,
.tree-leaf:hover .node-badge {
opacity: 1;
}
.badge-area {
background: #e8f5e9;
color: #1a6b52;
}
.badge-main {
background: #e3f2fd;
color: #1565c0;
}
.badge-branch {
background: #fff3e0;
color: #e65100;
}
.sub-tree {
padding-left: 18px;
border-left: 1.5px dashed #e0e6ec;
margin-left: 9px;
transition: border-color var(--transition);
}
.tree-details[open] > .sub-tree {
border-left-color: #c8d6e0;
}
.tree-leaf {
display: flex;
align-items: center;
cursor: pointer;
padding: 5px 7px 5px 3px;
margin: 1px 0;
border-radius: var(--radius-sm);
transition: all var(--transition);
gap: 3px;
min-height: 30px;
}
.tree-leaf:hover {
background: #f0f4f8;
}
.tree-details > summary.row-selected,
.tree-leaf.row-selected {
background: var(--accent-light) !important;
box-shadow: inset 3px 0 0 var(--accent);
}
.tree-details > summary.row-selected .node-name,
.tree-leaf.row-selected .node-name {
color: #0d47a1;
font-weight: 600;
}
.tree-details > summary.search-highlight,
.tree-leaf.search-highlight {
background: #fffde7 !important;
animation: highlightPulse 0.5s ease-out;
}
@keyframes highlightPulse {
0% {
background: #fff9c4 !important;
transform: scale(1.02);
}
100% {
background: #fffde7 !important;
transform: scale(1);
}
}
.tree-details.search-hidden,
.tree-leaf.search-hidden {
display: none !important;
}
/* 右键菜单 */
.context-menu {
position: fixed;
z-index: 9999;
background: #fff;
border-radius: var(--radius);
box-shadow: var(--shadow-xl);
border: 1px solid var(--border);
min-width: 175px;
padding: 5px 0;
display: none;
animation: contextIn 0.15s ease-out;
}
@keyframes contextIn {
from {
opacity: 0;
transform: scale(0.92);
}
to {
opacity: 1;
transform: scale(1);
}
}
.context-menu .ctx-item {
padding: 8px 15px;
cursor: pointer;
font-size: 0.76rem;
color: var(--text);
display: flex;
align-items: center;
gap: 8px;
transition: all var(--transition);
letter-spacing: 0.03em;
white-space: nowrap;
}
.context-menu .ctx-item:hover {
background: #f5f7f9;
color: var(--primary-dark);
}
.context-menu .ctx-item.danger {
color: #c62828;
}
.context-menu .ctx-item.danger:hover {
background: var(--danger-light);
}
.context-menu .ctx-divider {
height: 1px;
background: var(--border-light);
margin: 4px 0;
}
/* 右侧详情 */
.detail-panel {
flex: 1;
display: flex;
flex-direction: column;
background: #fff;
overflow-y: auto;
}
.detail-panel::-webkit-scrollbar {
width: 5px;
}
.detail-panel::-webkit-scrollbar-track {
background: transparent;
}
.detail-panel::-webkit-scrollbar-thumb {
background: #d0d7de;
border-radius: 10px;
}
.detail-header {
padding: 18px 22px 12px;
border-bottom: 1px solid var(--border-light);
background: #fafcfd;
}
.detail-header .breadcrumb {
font-size: 0.7rem;
color: var(--text-light);
margin-bottom: 5px;
display: flex;
flex-wrap: wrap;
gap: 3px;
}
.breadcrumb span.sep {
color: #b0b8c1;
margin: 0 2px;
}
.breadcrumb .crumb-item.current {
color: var(--primary-dark);
font-weight: 600;
}
.detail-header .detail-title {
font-size: 1.35rem;
font-weight: 700;
color: var(--primary-dark);
display: flex;
align-items: center;
gap: 10px;
}
.detail-title .type-indicator {
font-size: 0.66rem;
padding: 3px 10px;
border-radius: 12px;
font-weight: 500;
letter-spacing: 0.05em;
}
.type-indicator.area {
background: #e8f5e9;
color: #1a6b52;
}
.type-indicator.main-canal {
background: #e3f2fd;
color: #1565c0;
}
.type-indicator.branch-canal {
background: #fff3e0;
color: #e65100;
}
.detail-body {
padding: 16px 22px;
}
.detail-card {
background: #f9fafb;
border-radius: var(--radius);
padding: 14px 16px;
margin-bottom: 10px;
border: 1px solid var(--border-light);
transition: all var(--transition);
}
.detail-card:hover {
box-shadow: var(--shadow-sm);
border-color: #d8dee4;
}
.detail-card .card-label {
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-light);
margin-bottom: 3px;
font-weight: 600;
}
.detail-card .card-value {
font-size: 0.87rem;
color: var(--text);
font-weight: 500;
line-height: 1.6;
}
.detail-info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #c0c8d0;
text-align: center;
padding: 40px;
}
.empty-state .empty-icon {
font-size: 3.5rem;
margin-bottom: 10px;
opacity: 0.5;
animation: float 3s infinite;
}
@keyframes float {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-8px);
}
}
.toast {
position: fixed;
bottom: 30px;
left: 50%;
transform: translateX(-50%);
background: #1e293b;
color: #fff;
padding: 9px 20px;
border-radius: 20px;
font-size: 0.78rem;
z-index: 10000;
pointer-events: none;
opacity: 0;
transition: all 0.35s;
box-shadow: var(--shadow-md);
white-space: nowrap;
}
.toast.show {
opacity: 1;
transform: translateX(-50%) translateY(-10px);
}
.toast.success {
background: #2e7d32;
}
.toast.error {
background: #c62828;
}
@media (max-width: 860px) {
.app-container {
flex-direction: column;
height: 98vh;
}
.tree-panel {
width: 100%;
min-width: 100%;
max-height: 50%;
border-right: none;
border-bottom: 1px solid var(--border);
}
.detail-panel {
flex: 1;
min-height: 180px;
}
.root-actions {
flex-wrap: wrap;
gap: 3px;
}
.root-actions .search-box {
width: 90px;
}
.detail-info-grid {
grid-template-columns: 1fr;
}
}
</style>
<div class="app-container">
<!-- ========== 左侧一体化树面板 ========== -->
<aside class="tree-panel">
<div class="panel-header">
<div class="brand"><span class="icon">💧</span>灌区管理系统</div>
<div class="subtitle">灌溉区 · 主渠道 · 支渠</div>
</div>
<!-- 树形菜单区域(包含总节点) -->
<div class="tree-scroll" id="treeScroll">
<nav id="treeContainer">
<!-- ★ 总节点:灌区 ★ -->
<details
class="tree-details root-node"
data-type="root"
data-name="灌区"
data-id="root"
open
>
<summary data-type="root" data-name="灌区" data-id="root">
<span class="tree-arrow">▶</span>
<input
type="checkbox"
class="tree-checkbox root-checkbox"
data-id="root"
title="全选/取消全部"
/>
<span class="node-dot root-dot"></span>
<span class="node-name">灌区</span>
<span class="root-badge">总灌区</span>
<!-- 右侧集成操作区 -->
<div class="root-actions" onclick="event.stopPropagation()">
<div class="search-box">
<span class="search-icon">🔍</span>
<input
type="text"
class="search-input"
id="searchInput"
placeholder="搜索..."
autocomplete="off"
/>
<button class="search-clear" id="searchClear">✕</button>
</div>
<select class="sort-select" id="sortSelect">
<option value="default">📋 默认</option>
<option value="name-asc">🔤 A→Z</option>
<option value="name-desc">🔤 Z→A</option>
<option value="type-group">📂 类型</option>
</select>
<button
class="action-btn"
id="btnExpandAll"
title="展开全部节点"
>
📂
</button>
<button
class="action-btn"
id="btnCollapseAll"
title="折叠全部节点"
>
📁
</button>
<button
class="action-btn"
id="btnSelectAll"
title="全选所有节点"
>
☑
</button>
<button
class="action-btn"
id="btnDeselectAll"
title="取消全选"
>
☐
</button>
<button
class="action-btn danger"
id="btnDeleteSelected"
title="删除选中的节点"
>
🗑
</button>
</div>
</summary>
<!-- 子节点:三个灌区保持不变 -->
<div class="sub-tree">
<details
class="tree-details"
data-type="area"
data-name="东风灌区"
data-id="area-1"
data-sort-key="东风灌区"
>
<summary
data-type="area"
data-name="东风灌区"
data-id="area-1"
data-sort-key="东风灌区"
>
<span class="tree-arrow">▶</span>
<input
type="checkbox"
class="tree-checkbox"
data-id="area-1"
/>
<span class="node-dot level-1"></span>
<span class="node-name level-1-name">东风灌区</span>
<span class="node-badge badge-area">灌区</span>
</summary>
<div class="sub-tree">
<details
class="tree-details"
data-type="main-canal"
data-name="东风主渠"
data-id="main-1-1"
data-sort-key="东风主渠"
>
<summary
data-type="main-canal"
data-name="东风主渠"
data-id="main-1-1"
data-sort-key="东风主渠"
>
<span class="tree-arrow">▶</span>
<input
type="checkbox"
class="tree-checkbox"
data-id="main-1-1"
/>
<span class="node-dot level-2"></span>
<span class="node-name level-2-name">东风主渠</span>
<span class="node-badge badge-main">主渠道</span>
</summary>
<div class="sub-tree">
<div
class="tree-leaf"
data-type="branch-canal"
data-name="东一支渠"
data-id="branch-1-1-1"
data-sort-key="东一支渠"
>
<input
type="checkbox"
class="tree-checkbox"
data-id="branch-1-1-1"
/><span class="node-dot level-3"></span
><span class="node-name level-3-name">东一支渠</span
><span class="node-badge badge-branch">支渠</span>
</div>
<div
class="tree-leaf"
data-type="branch-canal"
data-name="东二支渠"
data-id="branch-1-1-2"
data-sort-key="东二支渠"
>
<input
type="checkbox"
class="tree-checkbox"
data-id="branch-1-1-2"
/><span class="node-dot level-3"></span
><span class="node-name level-3-name">东二支渠</span
><span class="node-badge badge-branch">支渠</span>
</div>
<div
class="tree-leaf"
data-type="branch-canal"
data-name="东三支渠"
data-id="branch-1-1-3"
data-sort-key="东三支渠"
>
<input
type="checkbox"
class="tree-checkbox"
data-id="branch-1-1-3"
/><span class="node-dot level-3"></span
><span class="node-name level-3-name">东三支渠</span
><span class="node-badge badge-branch">支渠</span>
</div>
</div>
</details>
<details
class="tree-details"
data-type="main-canal"
data-name="红旗主渠"
data-id="main-1-2"
data-sort-key="红旗主渠"
>
<summary
data-type="main-canal"
data-name="红旗主渠"
data-id="main-1-2"
data-sort-key="红旗主渠"
>
<span class="tree-arrow">▶</span
><input
type="checkbox"
class="tree-checkbox"
data-id="main-1-2"
/><span class="node-dot level-2"></span
><span class="node-name level-2-name">红旗主渠</span
><span class="node-badge badge-main">主渠道</span>
</summary>
<div class="sub-tree">
<div
class="tree-leaf"
data-type="branch-canal"
data-name="红一支渠"
data-id="branch-1-2-1"
data-sort-key="红一支渠"
>
<input
type="checkbox"
class="tree-checkbox"
data-id="branch-1-2-1"
/><span class="node-dot level-3"></span
><span class="node-name level-3-name">红一支渠</span
><span class="node-badge badge-branch">支渠</span>
</div>
<div
class="tree-leaf"
data-type="branch-canal"
data-name="红二支渠"
data-id="branch-1-2-2"
data-sort-key="红二支渠"
>
<input
type="checkbox"
class="tree-checkbox"
data-id="branch-1-2-2"
/><span class="node-dot level-3"></span
><span class="node-name level-3-name">红二支渠</span
><span class="node-badge badge-branch">支渠</span>
</div>
</div>
</details>
</div>
</details>
<details
class="tree-details"
data-type="area"
data-name="丰收灌区"
data-id="area-2"
data-sort-key="丰收灌区"
>
<summary
data-type="area"
data-name="丰收灌区"
data-id="area-2"
data-sort-key="丰收灌区"
>
<span class="tree-arrow">▶</span
><input
type="checkbox"
class="tree-checkbox"
data-id="area-2"
/><span class="node-dot level-1"></span
><span class="node-name level-1-name">丰收灌区</span
><span class="node-badge badge-area">灌区</span>
</summary>
<div class="sub-tree">
<details
class="tree-details"
data-type="main-canal"
data-name="丰收主渠"
data-id="main-2-1"
data-sort-key="丰收主渠"
>
<summary
data-type="main-canal"
data-name="丰收主渠"
data-id="main-2-1"
data-sort-key="丰收主渠"
>
<span class="tree-arrow">▶</span
><input
type="checkbox"
class="tree-checkbox"
data-id="main-2-1"
/><span class="node-dot level-2"></span
><span class="node-name level-2-name">丰收主渠</span
><span class="node-badge badge-main">主渠道</span>
</summary>
<div class="sub-tree">
<div
class="tree-leaf"
data-type="branch-canal"
data-name="丰一支渠"
data-id="branch-2-1-1"
data-sort-key="丰一支渠"
>
<input
type="checkbox"
class="tree-checkbox"
data-id="branch-2-1-1"
/><span class="node-dot level-3"></span
><span class="node-name level-3-name">丰一支渠</span
><span class="node-badge badge-branch">支渠</span>
</div>
<div
class="tree-leaf"
data-type="branch-canal"
data-name="丰二支渠"
data-id="branch-2-1-2"
data-sort-key="丰二支渠"
>
<input
type="checkbox"
class="tree-checkbox"
data-id="branch-2-1-2"
/><span class="node-dot level-3"></span
><span class="node-name level-3-name">丰二支渠</span
><span class="node-badge badge-branch">支渠</span>
</div>
</div>
</details>
<details
class="tree-details"
data-type="main-canal"
data-name="胜利主渠"
data-id="main-2-2"
data-sort-key="胜利主渠"
>
<summary
data-type="main-canal"
data-name="胜利主渠"
data-id="main-2-2"
data-sort-key="胜利主渠"
>
<span class="tree-arrow">▶</span
><input
type="checkbox"
class="tree-checkbox"
data-id="main-2-2"
/><span class="node-dot level-2"></span
><span class="node-name level-2-name">胜利主渠</span
><span class="node-badge badge-main">主渠道</span>
</summary>
<div class="sub-tree">
<div
class="tree-leaf"
data-type="branch-canal"
data-name="胜一支渠"
data-id="branch-2-2-1"
data-sort-key="胜一支渠"
>
<input
type="checkbox"
class="tree-checkbox"
data-id="branch-2-2-1"
/><span class="node-dot level-3"></span
><span class="node-name level-3-name">胜一支渠</span
><span class="node-badge badge-branch">支渠</span>
</div>
<div
class="tree-leaf"
data-type="branch-canal"
data-name="胜二支渠"
data-id="branch-2-2-2"
data-sort-key="胜二支渠"
>
<input
type="checkbox"
class="tree-checkbox"
data-id="branch-2-2-2"
/><span class="node-dot level-3"></span
><span class="node-name level-3-name">胜二支渠</span
><span class="node-badge badge-branch">支渠</span>
</div>
<div
class="tree-leaf"
data-type="branch-canal"
data-name="胜三支渠"
data-id="branch-2-2-3"
data-sort-key="胜三支渠"
>
<input
type="checkbox"
class="tree-checkbox"
data-id="branch-2-2-3"
/><span class="node-dot level-3"></span
><span class="node-name level-3-name">胜三支渠</span
><span class="node-badge badge-branch">支渠</span>
</div>
</div>
</details>
</div>
</details>
<details
class="tree-details"
data-type="area"
data-name="清泉灌区"
data-id="area-3"
data-sort-key="清泉灌区"
>
<summary
data-type="area"
data-name="清泉灌区"
data-id="area-3"
data-sort-key="清泉灌区"
>
<span class="tree-arrow">▶</span
><input
type="checkbox"
class="tree-checkbox"
data-id="area-3"
/><span class="node-dot level-1"></span
><span class="node-name level-1-name">清泉灌区</span
><span class="node-badge badge-area">灌区</span>
</summary>
<div class="sub-tree">
<details
class="tree-details"
data-type="main-canal"
data-name="清泉主渠"
data-id="main-3-1"
data-sort-key="清泉主渠"
>
<summary
data-type="main-canal"
data-name="清泉主渠"
data-id="main-3-1"
data-sort-key="清泉主渠"
>
<span class="tree-arrow">▶</span
><input
type="checkbox"
class="tree-checkbox"
data-id="main-3-1"
/><span class="node-dot level-2"></span
><span class="node-name level-2-name">清泉主渠</span
><span class="node-badge badge-main">主渠道</span>
</summary>
<div class="sub-tree">
<div
class="tree-leaf"
data-type="branch-canal"
data-name="清一支渠"
data-id="branch-3-1-1"
data-sort-key="清一支渠"
>
<input
type="checkbox"
class="tree-checkbox"
data-id="branch-3-1-1"
/><span class="node-dot level-3"></span
><span class="node-name level-3-name">清一支渠</span
><span class="node-badge badge-branch">支渠</span>
</div>
<div
class="tree-leaf"
data-type="branch-canal"
data-name="清二支渠"
data-id="branch-3-1-2"
data-sort-key="清二支渠"
>
<input
type="checkbox"
class="tree-checkbox"
data-id="branch-3-1-2"
/><span class="node-dot level-3"></span
><span class="node-name level-3-name">清二支渠</span
><span class="node-badge badge-branch">支渠</span>
</div>
</div>
</details>
<details
class="tree-details"
data-type="main-canal"
data-name="碧波主渠"
data-id="main-3-2"
data-sort-key="碧波主渠"
>
<summary
data-type="main-canal"
data-name="碧波主渠"
data-id="main-3-2"
data-sort-key="碧波主渠"
>
<span class="tree-arrow">▶</span
><input
type="checkbox"
class="tree-checkbox"
data-id="main-3-2"
/><span class="node-dot level-2"></span
><span class="node-name level-2-name">碧波主渠</span
><span class="node-badge badge-main">主渠道</span>
</summary>
<div class="sub-tree">
<div
class="tree-leaf"
data-type="branch-canal"
data-name="碧一支渠"
data-id="branch-3-2-1"
data-sort-key="碧一支渠"
>
<input
type="checkbox"
class="tree-checkbox"
data-id="branch-3-2-1"
/><span class="node-dot level-3"></span
><span class="node-name level-3-name">碧一支渠</span
><span class="node-badge badge-branch">支渠</span>
</div>
<div
class="tree-leaf"
data-type="branch-canal"
data-name="碧二支渠"
data-id="branch-3-2-2"
data-sort-key="碧二支渠"
>
<input
type="checkbox"
class="tree-checkbox"
data-id="branch-3-2-2"
/><span class="node-dot level-3"></span
><span class="node-name level-3-name">碧二支渠</span
><span class="node-badge badge-branch">支渠</span>
</div>
</div>
</details>
</div>
</details>
</div>
</details>
</nav>
</div>
<!-- 底部状态栏 -->
<div class="panel-footer">
<span
>已选:
<span class="count-badge zero" id="selectedCount">0</span></span
>
<span
class="footer-link"
id="clearSelectionLink"
style="display: none"
>清除选择</span
>
</div>
</aside>
<!-- ========== 右侧详情面板 ========== -->
<main class="detail-panel" id="detailPanel">
<div class="empty-state" id="emptyState">
<div class="empty-icon">📋</div>
<div class="empty-text">点击任意节点查看详情</div>
<div class="empty-hint">右键节点可进行更多操作 · 双击重命名</div>
</div>
<div id="detailContent" style="display: none">
<div class="detail-header">
<div class="breadcrumb" id="breadcrumb"></div>
<div class="detail-title" id="detailTitle"></div>
</div>
<div class="detail-body" id="detailBody"></div>
</div>
</main>
</div>
<!-- 右键菜单 -->
<div class="context-menu" id="contextMenu">
<div class="ctx-item" data-action="expand">📂 展开</div>
<div class="ctx-item" data-action="collapse">📁 折叠</div>
<div class="ctx-divider"></div>
<div class="ctx-item" data-action="rename">✏️ 重命名</div>
<div class="ctx-item" data-action="addChild">➕ 添加子节点</div>
<div class="ctx-divider"></div>
<div class="ctx-item" data-action="selectChildren">☑ 全选子节点</div>
<div class="ctx-item" data-action="deselectChildren">
☐ 取消全选子节点
</div>
<div class="ctx-item" data-action="sortChildrenAsc">
🔤 子节点排序 A→Z
</div>
<div class="ctx-item" data-action="sortChildrenDesc">
🔤 子节点排序 Z→A
</div>
<div class="ctx-divider"></div>
<div class="ctx-item danger" data-action="delete">🗑 删除节点</div>
</div>
<div class="toast" id="toast"></div>
<script>
;(function () {
// DOM 元素
const treeContainer = document.getElementById('treeContainer')
const searchInput = document.getElementById('searchInput')
const searchClear = document.getElementById('searchClear')
const detailPanel = document.getElementById('detailPanel')
const emptyState = document.getElementById('emptyState')
const detailContent = document.getElementById('detailContent')
const breadcrumbEl = document.getElementById('breadcrumb')
const detailTitleEl = document.getElementById('detailTitle')
const detailBodyEl = document.getElementById('detailBody')
const selectedCountEl = document.getElementById('selectedCount')
const clearSelectionLink = document.getElementById('clearSelectionLink')
const contextMenu = document.getElementById('contextMenu')
const toast = document.getElementById('toast')
const sortSelect = document.getElementById('sortSelect')
const rootNode = document.querySelector('.root-node')
const rootCheckbox = rootNode.querySelector('.root-checkbox')
let currentRowSelected = null
let contextTarget = null
let searchTimeout = null
let idCounter = 300
let toastTimer = null
// 数据映射 (同前)
const nodeDataMap = {
'area-1': {
name: '东风灌区',
type: 'area',
typeLabel: '灌区',
location: '河南省郑州市',
area: '3,200 公顷',
established: '1985年',
waterSource: '黄河水系',
annualSupply: '1.8亿立方米',
description: '东风灌区是河南省重要的农业灌溉区,覆盖12个乡镇。',
},
'area-2': {
name: '丰收灌区',
type: 'area',
typeLabel: '灌区',
location: '山东省济南市',
area: '4,500 公顷',
established: '1992年',
waterSource: '黄河水系',
annualSupply: '2.3亿立方米',
description:
'丰收灌区位于山东省中部,是黄河流域重要的粮食生产基地。',
},
'area-3': {
name: '清泉灌区',
type: 'area',
typeLabel: '灌区',
location: '河北省石家庄市',
area: '2,800 公顷',
established: '1988年',
waterSource: '地下水+地表水',
annualSupply: '1.5亿立方米',
description: '清泉灌区是河北省现代化灌区示范点。',
},
'main-1-1': {
name: '东风主渠',
type: 'main-canal',
typeLabel: '主渠道',
length: '25 公里',
capacity: '15 立方米/秒',
coverage: '8个行政村',
material: '混凝土衬砌',
completed: '1986年',
description: '东风主渠是东风灌区的骨干输水渠道。',
},
'main-1-2': {
name: '红旗主渠',
type: 'main-canal',
typeLabel: '主渠道',
length: '18 公里',
capacity: '12 立方米/秒',
coverage: '6个行政村',
material: '混凝土衬砌',
completed: '1987年',
description: '红旗主渠连接东风灌区东部区域。',
},
'main-2-1': {
name: '丰收主渠',
type: 'main-canal',
typeLabel: '主渠道',
length: '30 公里',
capacity: '18 立方米/秒',
coverage: '10个行政村',
material: '钢筋混凝土',
completed: '1993年',
description: '丰收主渠是丰收灌区最长的主渠道。',
},
'main-2-2': {
name: '胜利主渠',
type: 'main-canal',
typeLabel: '主渠道',
length: '22 公里',
capacity: '14 立方米/秒',
coverage: '7个行政村',
material: '混凝土衬砌',
completed: '1994年',
description: '胜利主渠为丰收灌区西线骨干渠道。',
},
'main-3-1': {
name: '清泉主渠',
type: 'main-canal',
typeLabel: '主渠道',
length: '20 公里',
capacity: '13 立方米/秒',
coverage: '5个行政村',
material: '钢筋混凝土',
completed: '1989年',
description: '清泉主渠配备智能监控系统。',
},
'main-3-2': {
name: '碧波主渠',
type: 'main-canal',
typeLabel: '主渠道',
length: '15 公里',
capacity: '10 立方米/秒',
coverage: '4个行政村',
material: '混凝土衬砌',
completed: '1990年',
description: '碧波主渠以生态护坡为特色。',
},
'branch-1-1-1': {
name: '东一支渠',
type: 'branch-canal',
typeLabel: '支渠',
length: '5 公里',
capacity: '3 立方米/秒',
coverage: '3个自然村',
material: 'U型槽衬砌',
completed: '1987年',
description: '东一支渠主要服务于东风灌区东北部。',
},
'branch-1-1-2': {
name: '东二支渠',
type: 'branch-canal',
typeLabel: '支渠',
length: '3.5 公里',
capacity: '2.5 立方米/秒',
coverage: '2个自然村',
material: 'U型槽衬砌',
completed: '1988年',
description: '东二支渠覆盖东风灌区东南部。',
},
'branch-1-1-3': {
name: '东三支渠',
type: 'branch-canal',
typeLabel: '支渠',
length: '4.2 公里',
capacity: '2.8 立方米/秒',
coverage: '3个自然村',
material: '现浇混凝土',
completed: '1989年',
description: '东三支渠是东风主渠最南端的支渠。',
},
'branch-1-2-1': {
name: '红一支渠',
type: 'branch-canal',
typeLabel: '支渠',
length: '6 公里',
capacity: '3.5 立方米/秒',
coverage: '4个自然村',
material: 'U型槽衬砌',
completed: '1988年',
description: '红一支渠是红旗主渠最大的支渠。',
},
'branch-1-2-2': {
name: '红二支渠',
type: 'branch-canal',
typeLabel: '支渠',
length: '2.8 公里',
capacity: '2 立方米/秒',
coverage: '2个自然村',
material: '现浇混凝土',
completed: '1989年',
description: '红二支渠以果树灌溉为主。',
},
'branch-2-1-1': {
name: '丰一支渠',
type: 'branch-canal',
typeLabel: '支渠',
length: '7 公里',
capacity: '4 立方米/秒',
coverage: '5个自然村',
material: 'U型槽衬砌',
completed: '1994年',
description: '丰一支渠是丰收灌区最大的支渠。',
},
'branch-2-1-2': {
name: '丰二支渠',
type: 'branch-canal',
typeLabel: '支渠',
length: '4.5 公里',
capacity: '3 立方米/秒',
coverage: '3个自然村',
material: '现浇混凝土',
completed: '1995年',
description: '丰二支渠覆盖丰收灌区西南部。',
},
'branch-2-2-1': {
name: '胜一支渠',
type: 'branch-canal',
typeLabel: '支渠',
length: '5.5 公里',
capacity: '3.2 立方米/秒',
coverage: '3个自然村',
material: 'U型槽衬砌',
completed: '1995年',
description: '胜一支渠以高效节水灌溉为特色。',
},
'branch-2-2-2': {
name: '胜二支渠',
type: 'branch-canal',
typeLabel: '支渠',
length: '3.2 公里',
capacity: '2.5 立方米/秒',
coverage: '2个自然村',
material: '现浇混凝土',
completed: '1996年',
description: '胜二支渠服务于胜利主渠中段。',
},
'branch-2-2-3': {
name: '胜三支渠',
type: 'branch-canal',
typeLabel: '支渠',
length: '2.1 公里',
capacity: '1.8 立方米/秒',
coverage: '1个自然村',
material: 'U型槽衬砌',
completed: '1997年',
description: '胜三支渠是胜利主渠末端支渠。',
},
'branch-3-1-1': {
name: '清一支渠',
type: 'branch-canal',
typeLabel: '支渠',
length: '4 公里',
capacity: '2.8 立方米/秒',
coverage: '2个自然村',
material: 'U型槽衬砌',
completed: '1990年',
description: '清一支渠配备智能闸门控制系统。',
},
'branch-3-1-2': {
name: '清二支渠',
type: 'branch-canal',
typeLabel: '支渠',
length: '5.5 公里',
capacity: '3.3 立方米/秒',
coverage: '3个自然村',
material: '现浇混凝土',
completed: '1991年',
description: '清二支渠覆盖清泉灌区北部。',
},
'branch-3-2-1': {
name: '碧一支渠',
type: 'branch-canal',
typeLabel: '支渠',
length: '3.8 公里',
capacity: '2.5 立方米/秒',
coverage: '2个自然村',
material: '生态护坡衬砌',
completed: '1991年',
description: '碧一支渠采用生态护坡设计。',
},
'branch-3-2-2': {
name: '碧二支渠',
type: 'branch-canal',
typeLabel: '支渠',
length: '4.6 公里',
capacity: '2.8 立方米/秒',
coverage: '3个自然村',
material: 'U型槽衬砌',
completed: '1992年',
description: '碧二支渠是碧波主渠最大的支渠。',
},
}
function showToast(msg, type = '') {
if (toastTimer) clearTimeout(toastTimer)
toast.textContent = msg
toast.className = 'toast ' + type + ' show'
toastTimer = setTimeout(() => toast.classList.remove('show'), 1800)
}
function getRowEl(el) {
if (!el || el === treeContainer) return null
if (
el.tagName === 'SUMMARY' &&
el.parentElement?.classList.contains('tree-details')
)
return el
if (el.classList.contains('tree-leaf')) return el
let cur = el
while (cur && cur !== treeContainer) {
if (
cur.tagName === 'SUMMARY' &&
cur.parentElement?.classList.contains('tree-details')
)
return cur
if (cur.classList.contains('tree-leaf')) return cur
cur = cur.parentElement
}
return null
}
function getRowContainer(el) {
const r = getRowEl(el)
return r ? (r.tagName === 'SUMMARY' ? r.parentElement : r) : null
}
function getNodeId(el) {
const r = getRowEl(el)
return r ? r.getAttribute('data-id') : null
}
function getNodeType(el) {
const r = getRowEl(el)
return r ? r.getAttribute('data-type') : null
}
function getNodeName(el) {
const r = getRowEl(el)
return r ? r.getAttribute('data-name') : null
}
function getCheckbox(rowEl) {
return rowEl.querySelector(':scope > .tree-checkbox, .tree-checkbox')
}
function getSubTree(detailsEl) {
return detailsEl.querySelector(':scope > .sub-tree')
}
function getAllRows() {
const rows = []
treeContainer
.querySelectorAll('.tree-details > summary')
.forEach((s) => rows.push(s))
treeContainer
.querySelectorAll('.tree-leaf')
.forEach((l) => rows.push(l))
return rows
}
function getAllCheckboxes() {
return treeContainer.querySelectorAll('.tree-checkbox')
}
function getAllDetails() {
return treeContainer.querySelectorAll('.tree-details')
}
function getChildRows(parentEl) {
const children = []
if (parentEl.classList.contains('tree-details')) {
const sub = getSubTree(parentEl)
if (sub) {
sub
.querySelectorAll(':scope > .tree-details > summary')
.forEach((s) => children.push(s))
sub
.querySelectorAll(':scope > .tree-leaf')
.forEach((l) => children.push(l))
}
}
return children
}
function getChildCheckboxes(parentEl) {
const cbs = []
if (parentEl.classList.contains('tree-details')) {
const sub = getSubTree(parentEl)
if (sub)
sub
.querySelectorAll('.tree-checkbox')
.forEach((cb) => cbs.push(cb))
}
return cbs
}
function setCheckboxState(cb, checked) {
cb.checked = checked
cb.indeterminate = false
}
function updateChildrenCheckboxes(parentEl, checked) {
getChildCheckboxes(parentEl).forEach((cb) => {
setCheckboxState(cb, checked)
const row = getRowEl(cb)
if (row && row.tagName === 'SUMMARY')
updateChildrenCheckboxes(row.parentElement, checked)
})
}
function getAncestorDetails(el) {
const anc = []
let cur = el.parentElement
while (cur && cur !== treeContainer) {
if (
cur.classList.contains('sub-tree') &&
cur.parentElement?.classList.contains('tree-details')
)
anc.push(cur.parentElement)
cur = cur.parentElement
}
return anc
}
function updateAncestorCheckboxes(el) {
getAncestorDetails(el).forEach((details) => {
const summary = details.querySelector(':scope > summary')
const cb = summary ? getCheckbox(summary) : null
if (!cb) return
const childCbs = getChildCheckboxes(details)
const total = childCbs.length
const checked = childCbs.filter((c) => c.checked).length
const indet = childCbs.filter((c) => c.indeterminate).length
if (total === 0) setCheckboxState(cb, cb.checked)
else if (checked === total && indet === 0)
setCheckboxState(cb, true)
else if (checked === 0 && indet === 0) setCheckboxState(cb, false)
else {
cb.checked = false
cb.indeterminate = true
}
})
}
function handleCheckboxChange(cb) {
const row = getRowEl(cb)
if (!row) return
const checked = cb.checked
cb.indeterminate = false
if (row.tagName === 'SUMMARY')
updateChildrenCheckboxes(row.parentElement, checked)
updateAncestorCheckboxes(row)
updateSelectedCount()
}
function getCheckedCheckboxes() {
return Array.from(getAllCheckboxes()).filter(
(cb) => cb.checked && !cb.indeterminate,
)
}
function updateSelectedCount() {
const count = getCheckedCheckboxes().length
selectedCountEl.textContent = count
if (count === 0) {
selectedCountEl.classList.add('zero')
clearSelectionLink.style.display = 'none'
} else {
selectedCountEl.classList.remove('zero')
clearSelectionLink.style.display = 'inline'
}
}
function clearAllSelections() {
getAllCheckboxes().forEach((cb) => {
cb.checked = false
cb.indeterminate = false
})
updateSelectedCount()
showToast('已清除全部选择', 'success')
}
function selectAllNodes() {
getAllCheckboxes().forEach((cb) => {
cb.checked = true
cb.indeterminate = false
})
updateSelectedCount()
showToast('已全选所有节点', 'success')
}
function selectRow(rowEl) {
if (currentRowSelected && currentRowSelected !== rowEl)
currentRowSelected.classList.remove('row-selected')
rowEl.classList.add('row-selected')
currentRowSelected = rowEl
showDetail(rowEl)
}
function clearRowSelection() {
if (currentRowSelected) {
currentRowSelected.classList.remove('row-selected')
currentRowSelected = null
}
emptyState.style.display = 'flex'
detailContent.style.display = 'none'
}
function getNodePath(rowEl) {
const path = []
const name = rowEl.getAttribute('data-name')
if (name) path.unshift({ name, el: rowEl })
let cur = rowEl.parentElement
while (cur && cur !== treeContainer) {
if (
cur.classList.contains('sub-tree') &&
cur.parentElement?.classList.contains('tree-details')
) {
const s = cur.parentElement.querySelector(':scope > summary')
if (s) {
const n = s.getAttribute('data-name')
if (n) path.unshift({ name: n, el: s })
}
}
cur = cur.parentElement
}
return path
}
function showDetail(rowEl) {
const nodeId = rowEl.getAttribute('data-id')
const nodeName = rowEl.getAttribute('data-name')
const nodeType = rowEl.getAttribute('data-type')
if (nodeId === 'root') {
breadcrumbEl.innerHTML =
'<span class="crumb-item current">灌区</span>'
detailTitleEl.innerHTML =
'灌区 <span class="type-indicator area">总灌区</span>'
detailBodyEl.innerHTML =
'<div class="detail-card"><div class="card-label">📝 描述</div><div class="card-value">灌区是全部灌溉区域的总管理机构,下辖东风、丰收、清泉三大灌区,负责统筹水资源调配。</div></div>'
emptyState.style.display = 'none'
detailContent.style.display = 'block'
return
}
const data = nodeDataMap[nodeId]
const path = getNodePath(rowEl)
breadcrumbEl.innerHTML = path
.map(
(p, i) =>
`<span class="crumb-item${i === path.length - 1 ? ' current' : ''}">${p.name}</span>${i < path.length - 1 ? '<span class="sep">›</span>' : ''}`,
)
.join('')
const tc = {
area: 'area',
'main-canal': 'main-canal',
'branch-canal': 'branch-canal',
}
const tl = {
area: '灌区',
'main-canal': '主渠道',
'branch-canal': '支渠',
}
detailTitleEl.innerHTML = `${nodeName} <span class="type-indicator ${tc[nodeType] || 'area'}">${data?.typeLabel || tl[nodeType] || '节点'}</span>`
let cards = ''
if (data) {
if (data.type === 'area')
cards = `<div class="detail-info-grid"><div class="detail-card"><div class="card-label">📍 所在地</div><div class="card-value">${data.location}</div></div><div class="detail-card"><div class="card-label">📐 灌溉面积</div><div class="card-value">${data.area}</div></div><div class="detail-card"><div class="card-label">📅 建成年份</div><div class="card-value">${data.established}</div></div><div class="detail-card"><div class="card-label">💧 水源类型</div><div class="card-value">${data.waterSource}</div></div><div class="detail-card"><div class="card-label">📊 年供水量</div><div class="card-value">${data.annualSupply}</div></div></div><div class="detail-card"><div class="card-label">📝 灌区简介</div><div class="card-value" style="font-weight:400;line-height:1.7;">${data.description}</div></div>`
else if (data.type === 'main-canal')
cards = `<div class="detail-info-grid"><div class="detail-card"><div class="card-label">📏 渠道长度</div><div class="card-value">${data.length}</div></div><div class="detail-card"><div class="card-label">🌊 输水能力</div><div class="card-value">${data.capacity}</div></div><div class="detail-card"><div class="card-label">🏘️ 覆盖范围</div><div class="card-value">${data.coverage}</div></div><div class="detail-card"><div class="card-label">🧱 渠道材质</div><div class="card-value">${data.material}</div></div><div class="detail-card"><div class="card-label">📅 完工年份</div><div class="card-value">${data.completed}</div></div></div><div class="detail-card"><div class="card-label">📝 渠道简介</div><div class="card-value" style="font-weight:400;line-height:1.7;">${data.description}</div></div>`
else
cards = `<div class="detail-info-grid"><div class="detail-card"><div class="card-label">📏 支渠长度</div><div class="card-value">${data.length}</div></div><div class="detail-card"><div class="card-label">🌊 过水能力</div><div class="card-value">${data.capacity}</div></div><div class="detail-card"><div class="card-label">🏘️ 覆盖范围</div><div class="card-value">${data.coverage}</div></div><div class="detail-card"><div class="card-label">🧱 衬砌类型</div><div class="card-value">${data.material}</div></div><div class="detail-card"><div class="card-label">📅 完工年份</div><div class="card-value">${data.completed}</div></div></div><div class="detail-card"><div class="card-label">📝 支渠简介</div><div class="card-value" style="font-weight:400;line-height:1.7;">${data.description}</div></div>`
} else
cards =
'<div class="detail-card"><div class="card-label">📝 信息</div><div class="card-value">暂无详细数据</div></div>'
detailBodyEl.innerHTML = cards
emptyState.style.display = 'none'
detailContent.style.display = 'block'
detailPanel.scrollTop = 0
}
// 排序
function sortSubTree(sub, mode) {
if (!sub) return
const items = Array.from(sub.children)
items.sort((a, b) => {
const ka =
a.getAttribute('data-sort-key') ||
a.getAttribute('data-name') ||
''
const kb =
b.getAttribute('data-sort-key') ||
b.getAttribute('data-name') ||
''
if (mode === 'name-asc') return ka.localeCompare(kb, 'zh')
if (mode === 'name-desc') return kb.localeCompare(ka, 'zh')
if (mode === 'type-group') {
const ta = a.getAttribute('data-type')
const tb = b.getAttribute('data-type')
const order = { area: 1, 'main-canal': 2, 'branch-canal': 3 }
if ((order[ta] || 9) !== (order[tb] || 9))
return (order[ta] || 9) - (order[tb] || 9)
return ka.localeCompare(kb, 'zh')
}
return 0
})
items.forEach((item) => sub.appendChild(item))
}
function sortAllSubTrees(mode) {
treeContainer
.querySelectorAll('.sub-tree')
.forEach((sub) => sortSubTree(sub, mode))
}
sortSelect.addEventListener('change', function () {
const mode = this.value
if (mode === 'default') showToast('排序已重置为默认', 'success')
else {
sortAllSubTrees(mode)
showToast('节点已排序', 'success')
}
})
// 右键菜单
function hideContextMenu() {
contextMenu.style.display = 'none'
contextTarget = null
}
function showContextMenu(x, y, targetRow) {
contextTarget = targetRow
const items = contextMenu.querySelectorAll('.ctx-item')
const nodeType = getNodeType(targetRow)
items.forEach((item) => {
const action = item.getAttribute('data-action')
item.style.display = 'flex'
if (action === 'addChild' && nodeType === 'branch-canal')
item.style.display = 'none'
if (
(action === 'expand' ||
action === 'collapse' ||
action === 'sortChildrenAsc' ||
action === 'sortChildrenDesc') &&
nodeType === 'branch-canal'
)
item.style.display = 'none'
if (
(action === 'selectChildren' || action === 'deselectChildren') &&
nodeType === 'branch-canal'
)
item.style.display = 'none'
})
contextMenu.style.display = 'block'
const mr = contextMenu.getBoundingClientRect()
let l = x,
t = y
if (l + mr.width > window.innerWidth - 10)
l = window.innerWidth - mr.width - 10
if (t + mr.height > window.innerHeight - 10)
t = window.innerHeight - mr.height - 10
contextMenu.style.left = Math.max(5, l) + 'px'
contextMenu.style.top = Math.max(5, t) + 'px'
}
document.addEventListener('click', (e) => {
if (!contextMenu.contains(e.target)) hideContextMenu()
})
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') hideContextMenu()
})
contextMenu.addEventListener('click', function (e) {
const act = e.target.closest('.ctx-item')
if (!act || !contextTarget) return
const action = act.getAttribute('data-action')
const target = contextTarget
hideContextMenu()
switch (action) {
case 'expand':
if (target.tagName === 'SUMMARY') target.parentElement.open = true
break
case 'collapse':
if (target.tagName === 'SUMMARY')
target.parentElement.open = false
break
case 'rename':
renameNode(target)
break
case 'addChild':
addChildNode(target)
break
case 'selectChildren':
if (target.tagName === 'SUMMARY') {
getChildCheckboxes(target.parentElement).forEach((cb) => {
cb.checked = true
cb.indeterminate = false
})
updateAncestorCheckboxes(target)
updateSelectedCount()
showToast('已全选子节点', 'success')
}
break
case 'deselectChildren':
if (target.tagName === 'SUMMARY') {
getChildCheckboxes(target.parentElement).forEach((cb) => {
cb.checked = false
cb.indeterminate = false
})
updateAncestorCheckboxes(target)
updateSelectedCount()
showToast('已取消全选子节点', 'success')
}
break
case 'sortChildrenAsc':
sortChildrenOfNode(target, 'name-asc')
break
case 'sortChildrenDesc':
sortChildrenOfNode(target, 'name-desc')
break
case 'delete':
deleteNode(target)
break
}
})
function sortChildrenOfNode(targetRow, mode) {
if (targetRow.tagName !== 'SUMMARY') return
const sub = getSubTree(targetRow.parentElement)
if (sub) sortSubTree(sub, mode)
showToast('子节点已排序', 'success')
}
function renameNode(targetRow) {
const old = getNodeName(targetRow)
const nw = prompt('请输入新名称:', old)
if (nw && nw.trim() && nw.trim() !== old) {
const t = nw.trim()
targetRow.setAttribute('data-name', t)
targetRow.setAttribute('data-sort-key', t)
const ns = targetRow.querySelector('.node-name')
if (ns) ns.textContent = t
const nid = getNodeId(targetRow)
if (nodeDataMap[nid]) nodeDataMap[nid].name = t
if (currentRowSelected === targetRow) showDetail(targetRow)
showToast('重命名成功:' + t, 'success')
}
}
function addChildNode(targetRow) {
const nt = getNodeType(targetRow)
if (nt === 'branch-canal') {
showToast('支渠不能添加子节点', 'warning')
return
}
const nm = prompt('请输入新节点名称:', '新建节点')
if (!nm || !nm.trim()) return
const tn = nm.trim()
let ct, cl, bc, dc, nc
if (nt === 'area' || nt === 'root') {
ct = 'main-canal'
cl = '主渠道'
bc = 'badge-main'
dc = 'level-2'
nc = 'level-2-name'
} else {
ct = 'branch-canal'
cl = '支渠'
bc = 'badge-branch'
dc = 'level-3'
nc = 'level-3-name'
}
const nid = ct + '-' + idCounter++
nodeDataMap[nid] = {
name: tn,
type: ct,
typeLabel: cl,
location: '待填写',
area: '待填写',
established: '待填写',
waterSource: '待填写',
annualSupply: '待填写',
length: '待填写',
capacity: '待填写',
coverage: '待填写',
material: '待填写',
completed: '待填写',
description: '新增节点,信息待完善。',
}
let container
if (targetRow.tagName === 'SUMMARY') {
const det = targetRow.parentElement
let sub = getSubTree(det)
if (!sub) {
sub = document.createElement('div')
sub.className = 'sub-tree'
det.appendChild(sub)
}
container = sub
} else container = targetRow.parentElement
if (ct === 'main-canal') {
const nd = document.createElement('details')
nd.className = 'tree-details'
nd.setAttribute('data-type', ct)
nd.setAttribute('data-name', tn)
nd.setAttribute('data-id', nid)
nd.setAttribute('data-sort-key', tn)
nd.innerHTML = `<summary data-type="${ct}" data-name="${tn}" data-id="${nid}" data-sort-key="${tn}"><span class="tree-arrow">▶</span><input type="checkbox" class="tree-checkbox" data-id="${nid}"><span class="node-dot ${dc}"></span><span class="node-name ${nc}">${tn}</span><span class="node-badge ${bc}">${cl}</span></summary><div class="sub-tree"></div>`
container.appendChild(nd)
nd.open = true
} else {
const nl = document.createElement('div')
nl.className = 'tree-leaf'
nl.setAttribute('data-type', ct)
nl.setAttribute('data-name', tn)
nl.setAttribute('data-id', nid)
nl.setAttribute('data-sort-key', tn)
nl.innerHTML = `<input type="checkbox" class="tree-checkbox" data-id="${nid}"><span class="node-dot ${dc}"></span><span class="node-name ${nc}">${tn}</span><span class="node-badge ${bc}">${cl}</span>`
container.appendChild(nl)
}
if (targetRow.tagName === 'SUMMARY')
targetRow.parentElement.open = true
updateSelectedCount()
showToast('已添加:' + tn, 'success')
}
function deleteNode(targetRow) {
if (targetRow.getAttribute('data-id') === 'root') {
showToast('不能删除总节点', 'error')
return
}
const nm = getNodeName(targetRow)
const nt = getNodeType(targetRow)
if (!confirm(`确定删除「${nm}」吗?`)) return
const cont = getRowContainer(targetRow)
if (cont) cont.remove()
if (currentRowSelected === targetRow) clearRowSelection()
updateAncestorAfterDelete()
updateSelectedCount()
showToast('已删除:' + nm, 'error')
}
function updateAncestorAfterDelete() {
getAllDetails().forEach((details) => {
const summary = details.querySelector(':scope > summary')
const cb = summary ? getCheckbox(summary) : null
if (!cb) return
const childCbs = getChildCheckboxes(details)
const total = childCbs.length
const checked = childCbs.filter((c) => c.checked).length
const indet = childCbs.filter((c) => c.indeterminate).length
if (total === 0) setCheckboxState(cb, cb.checked)
else if (checked === total && indet === 0)
setCheckboxState(cb, true)
else if (checked === 0 && indet === 0) setCheckboxState(cb, false)
else {
cb.checked = false
cb.indeterminate = true
}
})
}
function deleteSelectedNodes() {
const cbs = getCheckedCheckboxes()
if (cbs.length === 0) {
showToast('没有选中的节点', 'warning')
return
}
if (!confirm(`确定删除 ${cbs.length} 个选中节点吗?`)) return
const rowsToDel = new Set()
cbs.forEach((cb) => {
const r = getRowEl(cb)
if (r && r.getAttribute('data-id') !== 'root') rowsToDel.add(r)
})
const topDel = new Set()
rowsToDel.forEach((row) => {
let isChild = false
let cur = row.parentElement
while (cur && cur !== treeContainer) {
if (
cur.classList.contains('sub-tree') &&
cur.parentElement?.classList.contains('tree-details')
) {
const ps = cur.parentElement.querySelector(':scope>summary')
if (ps && rowsToDel.has(ps)) {
isChild = true
break
}
}
cur = cur.parentElement
}
if (!isChild) topDel.add(row)
})
let cnt = 0
topDel.forEach((row) => {
const c = getRowContainer(row)
if (c) {
c.remove()
cnt++
}
})
if (currentRowSelected && !document.contains(currentRowSelected))
clearRowSelection()
updateAncestorAfterDelete()
updateSelectedCount()
showToast(`已删除 ${cnt} 个节点`, 'error')
}
// 搜索
function performSearch(query) {
const allRows = getAllRows()
const q = query.trim().toLowerCase()
allRows.forEach((r) =>
r.classList.remove('search-highlight', 'search-hidden'),
)
if (!q) {
searchClear.classList.remove('visible')
return
}
searchClear.classList.add('visible')
const matched = [],
mSet = new Set()
allRows.forEach((r) => {
if ((r.getAttribute('data-name') || '').toLowerCase().includes(q)) {
matched.push(r)
mSet.add(r)
}
})
const toShow = new Set(mSet)
matched.forEach((r) => {
let cur = r.parentElement
while (cur && cur !== treeContainer) {
if (
cur.classList.contains('sub-tree') &&
cur.parentElement?.classList.contains('tree-details')
) {
const s = cur.parentElement.querySelector(':scope>summary')
if (s) toShow.add(s)
cur.parentElement.open = true
}
cur = cur.parentElement
}
})
allRows.forEach((r) => {
if (toShow.has(r)) {
r.classList.remove('search-hidden')
if (mSet.has(r)) r.classList.add('search-highlight')
} else r.classList.add('search-hidden')
})
if (matched.length === 1) {
selectRow(matched[0])
matched[0].scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}
searchInput.addEventListener('input', function () {
if (searchTimeout) clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => performSearch(this.value), 200)
})
searchClear.addEventListener('click', function () {
searchInput.value = ''
performSearch('')
searchInput.focus()
})
searchInput.addEventListener('keydown', function (e) {
if (e.key === 'Escape') {
searchInput.value = ''
performSearch('')
searchInput.blur()
}
})
// 总节点复选框联动
rootCheckbox.addEventListener('change', function (e) {
e.stopPropagation()
const checked = this.checked
this.indeterminate = false
const allCbs = getAllCheckboxes()
allCbs.forEach((cb) => {
if (cb !== rootCheckbox) {
cb.checked = checked
cb.indeterminate = false
}
})
updateSelectedCount()
})
// 事件委托
treeContainer.addEventListener('click', function (e) {
const cb = e.target.closest('.tree-checkbox')
if (cb && cb !== rootCheckbox) {
e.stopPropagation()
handleCheckboxChange(cb)
return
}
const row = getRowEl(e.target)
if (row && !e.target.closest('.tree-checkbox')) selectRow(row)
})
treeContainer.addEventListener('contextmenu', function (e) {
const row = getRowEl(e.target)
if (row && row.getAttribute('data-id') !== 'root') {
e.preventDefault()
showContextMenu(e.clientX, e.clientY, row)
}
})
treeContainer.addEventListener('dblclick', function (e) {
const row = getRowEl(e.target)
if (
row &&
row.getAttribute('data-id') !== 'root' &&
!e.target.closest('.tree-checkbox')
)
renameNode(row)
})
// 总节点工具按钮
document
.getElementById('btnExpandAll')
.addEventListener('click', () => {
getAllDetails().forEach((d) => (d.open = true))
showToast('已展开全部', 'success')
})
document
.getElementById('btnCollapseAll')
.addEventListener('click', () => {
getAllDetails().forEach((d) => (d.open = false))
showToast('已折叠全部', 'success')
})
document
.getElementById('btnSelectAll')
.addEventListener('click', selectAllNodes)
document
.getElementById('btnDeselectAll')
.addEventListener('click', clearAllSelections)
document
.getElementById('btnDeleteSelected')
.addEventListener('click', deleteSelectedNodes)
clearSelectionLink.addEventListener('click', clearAllSelections)
// 键盘快捷键
document.addEventListener('keydown', function (e) {
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
e.preventDefault()
searchInput.focus()
searchInput.select()
}
if (
(e.ctrlKey || e.metaKey) &&
e.key === 'a' &&
document.activeElement !== searchInput
) {
e.preventDefault()
selectAllNodes()
}
if (
e.key === 'Delete' &&
document.activeElement !== searchInput &&
!e.target.closest('input')
) {
const cbs = getCheckedCheckboxes()
if (cbs.length > 0) {
e.preventDefault()
deleteSelectedNodes()
}
}
})
// 初始
updateSelectedCount()
console.log('✅ 灌区总节点已就绪,所有核心功能集成于总节点一行。')
})()
</script>