























<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>
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。