feat: 维度映射/嵌套配置/地址栏导航/界面分区重构
This commit is contained in:
@@ -0,0 +1,695 @@
|
||||
/* Registry 配置管理中心样式 - 参考 Windows 注册表编辑器风格 */
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Segoe UI", "Microsoft YaHei", sans-serif;
|
||||
font-size: 13px;
|
||||
color: #1e1e1e;
|
||||
background: #f0f0f0;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 顶部工具栏 */
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 44px;
|
||||
padding: 0 12px;
|
||||
background: linear-gradient(to bottom, #fdfdfd, #e8e8e8);
|
||||
border-bottom: 1px solid #c0c0c0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toolbar-left,
|
||||
.toolbar-center,
|
||||
.toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.app-title {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: #003a70;
|
||||
}
|
||||
|
||||
.toolbar-select {
|
||||
height: 28px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid #adadad;
|
||||
border-radius: 3px;
|
||||
background: #fff;
|
||||
font-size: 13px;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.toolbar-btn {
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #adadad;
|
||||
border-radius: 3px;
|
||||
background: linear-gradient(to bottom, #fdfdfd, #e8e8e8);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.toolbar-btn:hover {
|
||||
background: linear-gradient(to bottom, #eaf3fb, #d6e9f9);
|
||||
border-color: #5b9bd5;
|
||||
}
|
||||
|
||||
.mode-label {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.mode-switcher {
|
||||
display: flex;
|
||||
border: 1px solid #adadad;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mode-btn {
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
border: none;
|
||||
border-right: 1px solid #adadad;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.mode-btn:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.mode-btn.active {
|
||||
background: #0078d4;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mode-btn:not(.active):hover {
|
||||
background: #e5f1fb;
|
||||
}
|
||||
|
||||
/* 主内容区 */
|
||||
.main-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 左侧树形导航 */
|
||||
.tree-panel {
|
||||
width: 320px;
|
||||
min-width: 180px;
|
||||
background: #fff;
|
||||
border-right: 1px solid #c0c0c0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tree-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 30px;
|
||||
padding: 0 10px;
|
||||
background: #f5f5f5;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.tree-content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.tree-empty {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
/* 树节点 */
|
||||
.tree-node {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tree-node-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
padding: 0 4px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tree-node-label:hover {
|
||||
background: #e5f1fb;
|
||||
}
|
||||
|
||||
.tree-node-label.selected {
|
||||
background: #cce8ff;
|
||||
}
|
||||
|
||||
.tree-toggle {
|
||||
width: 16px;
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.tree-icon {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.tree-children {
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.tree-children.collapsed {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 分隔条 */
|
||||
.resizer {
|
||||
width: 4px;
|
||||
background: #e0e0e0;
|
||||
cursor: col-resize;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.resizer:hover {
|
||||
background: #5b9bd5;
|
||||
}
|
||||
|
||||
/* 右侧详情面板 */
|
||||
.detail-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 34px;
|
||||
padding: 4px 12px;
|
||||
background: #f5f5f5;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
#detail-path {
|
||||
font-family: Consolas, monospace;
|
||||
color: #003a70;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.detail-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.detail-table-wrapper {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* 配置表格 */
|
||||
.config-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.config-table th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: #f0f0f0;
|
||||
border-bottom: 1px solid #c0c0c0;
|
||||
border-right: 1px solid #e0e0e0;
|
||||
padding: 6px 10px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.config-table td {
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
border-right: 1px solid #f5f5f5;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.config-table tr:hover td {
|
||||
background: #f8fbff;
|
||||
}
|
||||
|
||||
.col-key { width: 22%; }
|
||||
.col-value { width: 26%; }
|
||||
.col-type { width: 10%; }
|
||||
.col-desc { width: 30%; }
|
||||
.col-actions { width: 12%; }
|
||||
|
||||
.config-key-cell {
|
||||
font-family: Consolas, monospace;
|
||||
color: #0451a5;
|
||||
}
|
||||
|
||||
.config-value-cell {
|
||||
font-family: Consolas, monospace;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.type-badge {
|
||||
display: inline-block;
|
||||
padding: 1px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
background: #e5f1fb;
|
||||
color: #0078d4;
|
||||
}
|
||||
|
||||
.empty-row td {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
.row-btn {
|
||||
border: 1px solid #adadad;
|
||||
border-radius: 3px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.row-btn:hover {
|
||||
background: #e5f1fb;
|
||||
}
|
||||
|
||||
.row-btn.danger:hover {
|
||||
background: #fde7e9;
|
||||
border-color: #d13438;
|
||||
color: #d13438;
|
||||
}
|
||||
|
||||
/* 状态栏 */
|
||||
.statusbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 24px;
|
||||
padding: 0 12px;
|
||||
background: #f0f0f0;
|
||||
border-top: 1px solid #c0c0c0;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 模态框 */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal {
|
||||
width: 480px;
|
||||
background: #fff;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.25);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-sm {
|
||||
width: 360px;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
background: #f5f5f5;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
font-size: 15px;
|
||||
color: #003a70;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 16px;
|
||||
max-height: 60vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
/* 表单 */
|
||||
.form-group {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.form-half {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-weight: 600;
|
||||
color: #444;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #adadad;
|
||||
border-radius: 3px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.required {
|
||||
color: #d13438;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-weight: normal;
|
||||
color: #999;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.checkbox-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-weight: normal !important;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox-label input {
|
||||
width: auto !important;
|
||||
}
|
||||
|
||||
/* 按钮 */
|
||||
.btn {
|
||||
height: 30px;
|
||||
padding: 0 16px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid #adadad;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #0078d4;
|
||||
color: #fff;
|
||||
border-color: #0078d4;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #106ebe;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #d13438;
|
||||
color: #fff;
|
||||
border-color: #d13438;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #b52e32;
|
||||
}
|
||||
|
||||
/* 只读模式下隐藏操作按钮 */
|
||||
body.mode-readonly .detail-actions #btn-add-config,
|
||||
body.mode-readonly .row-btn:not(.view) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Toast 提示 */
|
||||
.toast {
|
||||
position: fixed;
|
||||
top: 60px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 10px 20px;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
z-index: 2000;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.toast-success { background: #107c10; }
|
||||
.toast-error { background: #d13438; }
|
||||
.toast-info { background: #0078d4; }
|
||||
|
||||
/* ===== 标题栏(与工具栏分离) ===== */
|
||||
.titlebar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
height: 40px;
|
||||
padding: 0 14px;
|
||||
background: linear-gradient(to bottom, #0a4a86, #003a70);
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.titlebar .app-title {
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.titlebar .current-project {
|
||||
font-size: 13px;
|
||||
color: #cfe3f7;
|
||||
padding-left: 14px;
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* ===== 工具栏(分组处理) ===== */
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 4px;
|
||||
min-height: 46px;
|
||||
padding: 4px 8px;
|
||||
background: linear-gradient(to bottom, #fdfdfd, #eaeaea);
|
||||
border-bottom: 1px solid #c0c0c0;
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tool-group {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 10px;
|
||||
border-right: 1px solid #d5d5d5;
|
||||
}
|
||||
|
||||
.tool-group .group-label {
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
/* ===== 维度栏 ===== */
|
||||
.dimension-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-height: 34px;
|
||||
padding: 4px 14px;
|
||||
background: #eef4fb;
|
||||
border-bottom: 1px solid #d0dced;
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.dim-empty {
|
||||
color: #8aa;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dim-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.dim-item .dim-name {
|
||||
font-weight: 600;
|
||||
color: #0a4a86;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dim-select {
|
||||
height: 26px;
|
||||
padding: 0 6px;
|
||||
border: 1px solid #adadad;
|
||||
border-radius: 3px;
|
||||
background: #fff;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dim-mapping {
|
||||
font-size: 11px;
|
||||
color: #7a8ba0;
|
||||
}
|
||||
|
||||
/* ===== 配置导航地址栏 ===== */
|
||||
.address-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: #f5f5f5;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.address-icon {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.address-input {
|
||||
flex: 1;
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #adadad;
|
||||
border-radius: 3px;
|
||||
font-family: Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.addr-btn {
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #adadad;
|
||||
border-radius: 3px;
|
||||
background: linear-gradient(to bottom, #fdfdfd, #e8e8e8);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.addr-btn:hover {
|
||||
background: linear-gradient(to bottom, #eaf3fb, #d6e9f9);
|
||||
border-color: #5b9bd5;
|
||||
}
|
||||
|
||||
/* ===== 新建项目维度映射行 ===== */
|
||||
.dim-config-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.dim-config-row .checkbox-label {
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.dim-config-row .map-name {
|
||||
min-width: 60px;
|
||||
font-weight: 600;
|
||||
color: #444;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.mapping-select {
|
||||
height: 28px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid #adadad;
|
||||
border-radius: 3px;
|
||||
background: #fff;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ===== 树节点:值项与目录项区分 ===== */
|
||||
.tree-node-label.is-leaf .tree-icon {
|
||||
opacity: 0.9;
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Registry - 微服务配置管理中心</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<!-- 标题栏:仅展示应用名与当前项目 -->
|
||||
<header class="titlebar">
|
||||
<span class="app-title">📋 Registry 配置管理中心</span>
|
||||
<span class="current-project" id="current-project-name">未选择项目</span>
|
||||
</header>
|
||||
|
||||
<!-- 工具栏:分组处理(项目 / 模式 / 配置 / 数据) -->
|
||||
<div class="toolbar">
|
||||
<div class="tool-group" title="项目操作">
|
||||
<span class="group-label">项目</span>
|
||||
<select id="project-selector" class="toolbar-select">
|
||||
<option value="">-- 选择项目 --</option>
|
||||
</select>
|
||||
<button id="btn-new-project" class="toolbar-btn" title="新建项目">📁 新建</button>
|
||||
<button id="btn-delete-project" class="toolbar-btn" title="删除项目">🗑 删除</button>
|
||||
</div>
|
||||
|
||||
<div class="tool-group" title="工作模式">
|
||||
<span class="group-label">模式</span>
|
||||
<div class="mode-switcher">
|
||||
<button class="mode-btn" data-mode="plan" title="规划模式:定义配置结构">📐 规划</button>
|
||||
<button class="mode-btn active" data-mode="edit" title="编辑模式:修改配置值">✏️ 编辑</button>
|
||||
<button class="mode-btn" data-mode="readonly" title="只读模式:仅查看">👁️ 只读</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tool-group" title="配置操作">
|
||||
<span class="group-label">配置</span>
|
||||
<button id="btn-add-config" class="toolbar-btn" title="新增配置项">➕ 新增</button>
|
||||
<button id="btn-collapse-all" class="toolbar-btn" title="全部折叠">⊟ 折叠</button>
|
||||
</div>
|
||||
|
||||
<div class="tool-group" title="数据操作">
|
||||
<span class="group-label">数据</span>
|
||||
<button id="btn-export-config" class="toolbar-btn" title="导出配置">📥 导出</button>
|
||||
<button id="btn-import-config" class="toolbar-btn" title="导入配置">📤 导入</button>
|
||||
<input type="file" id="import-file-input" accept=".json" style="display:none">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 维度栏:展示已启用维度的筛选器与映射配置入口 -->
|
||||
<div class="dimension-bar" id="dimension-bar">
|
||||
<span class="dim-empty" id="dim-empty">选择项目后展示维度筛选</span>
|
||||
<div class="dim-item" id="dim-idc" style="display:none">
|
||||
<span class="dim-name">机房</span>
|
||||
<select class="dim-select" id="dim-idc-select"></select>
|
||||
<span class="dim-mapping" id="dim-idc-mapping"></span>
|
||||
</div>
|
||||
<div class="dim-item" id="dim-env" style="display:none">
|
||||
<span class="dim-name">环境</span>
|
||||
<select class="dim-select" id="dim-env-select"></select>
|
||||
<span class="dim-mapping" id="dim-env-mapping"></span>
|
||||
</div>
|
||||
<div class="dim-item" id="dim-group" style="display:none">
|
||||
<span class="dim-name">分组</span>
|
||||
<select class="dim-select" id="dim-group-select"></select>
|
||||
<span class="dim-mapping" id="dim-group-mapping"></span>
|
||||
</div>
|
||||
<button id="btn-dim-mapping" class="toolbar-btn" style="display:none" title="配置维度映射方式">⚙ 映射配置</button>
|
||||
</div>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<div class="main-container">
|
||||
<!-- 左侧树形导航 -->
|
||||
<aside class="tree-panel" id="tree-panel">
|
||||
<div class="tree-header">
|
||||
<span>配置树</span>
|
||||
</div>
|
||||
<div class="tree-content" id="tree-content">
|
||||
<div class="tree-empty">请先选择一个项目</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 分隔条 -->
|
||||
<div class="resizer" id="resizer"></div>
|
||||
|
||||
<!-- 右侧详情面板 -->
|
||||
<main class="detail-panel" id="detail-panel">
|
||||
<!-- 配置导航地址栏:显示当前路径,可拷贝、可粘贴回车跳转 -->
|
||||
<div class="address-bar">
|
||||
<span class="address-icon">📍</span>
|
||||
<input type="text" id="address-input" class="address-input" placeholder="配置路径,回车跳转(类似注册表地址栏)">
|
||||
<button id="btn-address-go" class="addr-btn" title="跳转到路径">转到</button>
|
||||
<button id="btn-address-copy" class="addr-btn" title="拷贝路径">拷贝</button>
|
||||
</div>
|
||||
<div class="detail-table-wrapper">
|
||||
<table class="config-table" id="config-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-key">键名</th>
|
||||
<th class="col-value">值</th>
|
||||
<th class="col-type">类型</th>
|
||||
<th class="col-desc">说明</th>
|
||||
<th class="col-actions">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="config-tbody">
|
||||
<tr class="empty-row">
|
||||
<td colspan="5">请在左侧选择节点查看配置</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 状态栏 -->
|
||||
<footer class="statusbar">
|
||||
<span id="status-text">就绪</span>
|
||||
<span id="status-mode">当前模式:编辑</span>
|
||||
</footer>
|
||||
|
||||
<!-- 模态框:新建项目 -->
|
||||
<div class="modal-overlay" id="modal-project" style="display:none">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3 id="modal-project-title">新建项目</h3>
|
||||
<button class="modal-close" onclick="closeModal('modal-project')">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>项目名称 <span class="required">*</span></label>
|
||||
<input type="text" id="project-name" placeholder="例如:用户服务">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>项目描述</label>
|
||||
<textarea id="project-description" rows="2" placeholder="项目配置说明"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>维度配置 <span class="hint">(是否启用创建后不可修改;映射方式后续可调整)</span></label>
|
||||
<div class="dim-config-row">
|
||||
<label class="checkbox-label"><input type="checkbox" id="project-enable-idc" checked> 机房</label>
|
||||
<select id="project-idc-mapping" class="mapping-select">
|
||||
<option value="path">映射为路径</option>
|
||||
<option value="label">仅作标签</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="dim-config-row">
|
||||
<label class="checkbox-label"><input type="checkbox" id="project-enable-env" checked> 环境</label>
|
||||
<select id="project-env-mapping" class="mapping-select">
|
||||
<option value="path">映射为路径</option>
|
||||
<option value="label">仅作标签</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="dim-config-row">
|
||||
<label class="checkbox-label"><input type="checkbox" id="project-enable-group" checked> 分组</label>
|
||||
<select id="project-group-mapping" class="mapping-select">
|
||||
<option value="path">映射为路径</option>
|
||||
<option value="label">仅作标签</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" onclick="closeModal('modal-project')">取消</button>
|
||||
<button class="btn btn-primary" id="btn-save-project">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 模态框:维度映射配置 -->
|
||||
<div class="modal-overlay" id="modal-mapping" style="display:none">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>维度映射配置</h3>
|
||||
<button class="modal-close" onclick="closeModal('modal-mapping')">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="hint" style="margin-bottom:12px">映射为路径:该维度作为配置存储路径前缀;仅作标签:该维度仅用于筛选,不进入路径。</p>
|
||||
<div class="dim-config-row" id="map-idc-row">
|
||||
<span class="map-name">机房</span>
|
||||
<select id="map-idc-mapping" class="mapping-select">
|
||||
<option value="path">映射为路径</option>
|
||||
<option value="label">仅作标签</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="dim-config-row" id="map-env-row">
|
||||
<span class="map-name">环境</span>
|
||||
<select id="map-env-mapping" class="mapping-select">
|
||||
<option value="path">映射为路径</option>
|
||||
<option value="label">仅作标签</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="dim-config-row" id="map-group-row">
|
||||
<span class="map-name">分组</span>
|
||||
<select id="map-group-mapping" class="mapping-select">
|
||||
<option value="path">映射为路径</option>
|
||||
<option value="label">仅作标签</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" onclick="closeModal('modal-mapping')">取消</button>
|
||||
<button class="btn btn-primary" id="btn-save-mapping">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 模态框:新增/编辑配置项 -->
|
||||
<div class="modal-overlay" id="modal-config" style="display:none">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3 id="modal-config-title">新增配置项</h3>
|
||||
<button class="modal-close" onclick="closeModal('modal-config')">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>键名 <span class="required">*</span> <span class="hint">(可用 / 嵌套,如 db/pool/max)</span></label>
|
||||
<input type="text" id="config-key" placeholder="例如:db/host 或 db.host">
|
||||
</div>
|
||||
<div id="config-dimension-fields">
|
||||
<div class="form-row">
|
||||
<div class="form-group form-half" id="dim-idc-group">
|
||||
<label>机房</label>
|
||||
<input type="text" id="config-idc" placeholder="例如:华北">
|
||||
</div>
|
||||
<div class="form-group form-half" id="dim-env-group">
|
||||
<label>环境</label>
|
||||
<input type="text" id="config-environment" placeholder="例如:dev">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" id="dim-group-group">
|
||||
<label>分组</label>
|
||||
<input type="text" id="config-group" placeholder="例如:用户模块">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>值</label>
|
||||
<input type="text" id="config-value" placeholder="配置值">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group form-half">
|
||||
<label>类型</label>
|
||||
<select id="config-type">
|
||||
<option value="string">string</option>
|
||||
<option value="number">number</option>
|
||||
<option value="boolean">boolean</option>
|
||||
<option value="json">json</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group form-half">
|
||||
<label>值范围 <span class="hint">(规划模式)</span></label>
|
||||
<input type="text" id="config-value-range" placeholder="例如:1-100 或 枚举: A,B">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>说明 <span class="required">*</span></label>
|
||||
<textarea id="config-description" rows="2" placeholder="配置项用途说明"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>用途 <span class="hint">(规划模式)</span></label>
|
||||
<textarea id="config-purpose" rows="2" placeholder="详细用途描述"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" onclick="closeModal('modal-config')">取消</button>
|
||||
<button class="btn btn-primary" id="btn-save-config">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 模态框:确认删除 -->
|
||||
<div class="modal-overlay" id="modal-confirm" style="display:none">
|
||||
<div class="modal modal-sm">
|
||||
<div class="modal-header">
|
||||
<h3>确认操作</h3>
|
||||
<button class="modal-close" onclick="closeModal('modal-confirm')">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p id="confirm-message"></p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" onclick="closeModal('modal-confirm')">取消</button>
|
||||
<button class="btn btn-danger" id="btn-confirm-ok">确认</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="/js/tree.js"></script>
|
||||
<script src="/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* API 调用封装
|
||||
* 统一处理与后端 REST 接口的交互,返回解析后的数据或抛出错误
|
||||
*/
|
||||
const API = {
|
||||
/**
|
||||
* 通用请求方法
|
||||
* @param {string} url - 请求路径
|
||||
* @param {Object} options - fetch 选项
|
||||
* @returns {Promise<any>} 响应数据
|
||||
*/
|
||||
async request(url, options = {}) {
|
||||
const opts = {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
...options,
|
||||
};
|
||||
if (opts.body && typeof opts.body !== "string") {
|
||||
opts.body = JSON.stringify(opts.body);
|
||||
}
|
||||
const res = await fetch(url, opts);
|
||||
const json = await res.json();
|
||||
if (!json.success) {
|
||||
throw new Error(json.message || "请求失败");
|
||||
}
|
||||
return json.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取所有项目
|
||||
* @returns {Promise<Array>} 项目列表
|
||||
*/
|
||||
getProjects() {
|
||||
return this.request("/api/projects");
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取单个项目
|
||||
* @param {string} id - 项目 ID
|
||||
* @returns {Promise<Object>} 项目对象
|
||||
*/
|
||||
getProject(id) {
|
||||
return this.request(`/api/projects/${id}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建项目
|
||||
* @param {Object} data - 项目数据
|
||||
* @returns {Promise<Object>} 创建的项目
|
||||
*/
|
||||
createProject(data) {
|
||||
return this.request("/api/projects", { method: "POST", body: data });
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除项目
|
||||
* @param {string} id - 项目 ID
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
deleteProject(id) {
|
||||
return this.request(`/api/projects/${id}`, { method: "DELETE" });
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新项目维度映射方式
|
||||
* @param {string} id - 项目 ID
|
||||
* @param {Object} mappings - { idcMapping, environmentMapping, groupMapping }
|
||||
* @returns {Promise<Object>} 更新后的项目
|
||||
*/
|
||||
updateMappings(id, mappings) {
|
||||
return this.request(`/api/projects/${id}/mappings`, { method: "PUT", body: mappings });
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取项目下所有配置
|
||||
* @param {string} projectId - 项目 ID
|
||||
* @returns {Promise<Array>} 配置项列表
|
||||
*/
|
||||
getConfigs(projectId) {
|
||||
return this.request(`/api/projects/${projectId}/configs`);
|
||||
},
|
||||
|
||||
/**
|
||||
* 新增配置项
|
||||
* @param {string} projectId - 项目 ID
|
||||
* @param {Object} data - 配置项数据
|
||||
* @returns {Promise<Object>} 创建的配置项
|
||||
*/
|
||||
createConfig(projectId, data) {
|
||||
return this.request(`/api/projects/${projectId}/configs`, { method: "POST", body: data });
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新配置项
|
||||
* @param {string} projectId - 项目 ID
|
||||
* @param {string} configId - 配置项 ID
|
||||
* @param {Object} data - 要更新的字段
|
||||
* @returns {Promise<Object>} 更新后的配置项
|
||||
*/
|
||||
updateConfig(projectId, configId, data) {
|
||||
return this.request(`/api/projects/${projectId}/configs/${configId}`, { method: "PUT", body: data });
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除配置项
|
||||
* @param {string} projectId - 项目 ID
|
||||
* @param {string} configId - 配置项 ID
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
deleteConfig(projectId, configId) {
|
||||
return this.request(`/api/projects/${projectId}/configs/${configId}`, { method: "DELETE" });
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取当前模式
|
||||
* @returns {Promise<Object>} { mode }
|
||||
*/
|
||||
getMode() {
|
||||
return this.request("/api/mode");
|
||||
},
|
||||
|
||||
/**
|
||||
* 切换模式
|
||||
* @param {string} mode - 目标模式(plan/edit/readonly)
|
||||
* @returns {Promise<Object>} { mode }
|
||||
*/
|
||||
setMode(mode) {
|
||||
return this.request("/api/mode", { method: "PUT", body: { mode } });
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,772 @@
|
||||
/**
|
||||
* 主应用逻辑
|
||||
* 负责状态管理、事件绑定、界面渲染,以及维度筛选、地址栏导航与三种模式的差异化控制
|
||||
*/
|
||||
|
||||
// 全局应用状态
|
||||
const state = {
|
||||
projects: [], // 项目列表
|
||||
currentProject: null, // 当前选中项目
|
||||
configs: [], // 当前项目全部配置(含 path)
|
||||
visibleConfigs: [], // 右侧面板当前展示的配置
|
||||
mode: "edit", // 当前模式: plan/edit/readonly
|
||||
editingConfigId: null, // 正在编辑的配置项 ID(null 表示新增)
|
||||
confirmCallback: null, // 确认框回调
|
||||
dimFilter: { idc: "", environment: "", group: "" }, // 维度栏筛选值(空=全部)
|
||||
};
|
||||
|
||||
/**
|
||||
* 显示 Toast 提示
|
||||
* @param {string} message - 提示内容
|
||||
* @param {string} type - 类型: success/error/info
|
||||
* @returns {void}
|
||||
*/
|
||||
function toast(message, type = "info") {
|
||||
const el = document.createElement("div");
|
||||
el.className = `toast toast-${type}`;
|
||||
el.textContent = message;
|
||||
document.body.appendChild(el);
|
||||
setTimeout(() => el.remove(), 2500);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置状态栏文字
|
||||
* @param {string} text - 状态文字
|
||||
* @returns {void}
|
||||
*/
|
||||
function setStatus(text) {
|
||||
document.getElementById("status-text").textContent = text;
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开模态框
|
||||
* @param {string} id - 模态框元素 ID
|
||||
* @returns {void}
|
||||
*/
|
||||
function openModal(id) {
|
||||
document.getElementById(id).style.display = "flex";
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭模态框
|
||||
* @param {string} id - 模态框元素 ID
|
||||
* @returns {void}
|
||||
*/
|
||||
function closeModal(id) {
|
||||
document.getElementById(id).style.display = "none";
|
||||
}
|
||||
|
||||
/**
|
||||
* 模式的中文名称映射
|
||||
*/
|
||||
const MODE_NAMES = { plan: "规划", edit: "编辑", readonly: "只读" };
|
||||
|
||||
/**
|
||||
* 映射方式中文名称
|
||||
*/
|
||||
const MAPPING_NAMES = { path: "路径", label: "标签" };
|
||||
|
||||
/**
|
||||
* 应用当前模式到界面(更新样式类、按钮状态、状态栏)
|
||||
* @returns {void}
|
||||
*/
|
||||
function applyMode() {
|
||||
document.body.classList.remove("mode-plan", "mode-edit", "mode-readonly");
|
||||
document.body.classList.add(`mode-${state.mode}`);
|
||||
document.querySelectorAll(".mode-btn").forEach((btn) => {
|
||||
btn.classList.toggle("active", btn.dataset.mode === state.mode);
|
||||
});
|
||||
document.getElementById("status-mode").textContent = `当前模式:${MODE_NAMES[state.mode]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML 转义,防止 XSS
|
||||
* @param {string} str - 原始字符串
|
||||
* @returns {string} 转义后的字符串
|
||||
*/
|
||||
function escapeHtml(str) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载项目列表并填充选择器
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function loadProjects() {
|
||||
state.projects = await API.getProjects();
|
||||
const selector = document.getElementById("project-selector");
|
||||
selector.innerHTML = '<option value="">-- 选择项目 --</option>';
|
||||
state.projects.forEach((p) => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = p.id;
|
||||
opt.textContent = p.name;
|
||||
selector.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 选中并加载指定项目的配置
|
||||
* @param {string} projectId - 项目 ID
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function selectProject(projectId) {
|
||||
state.dimFilter = { idc: "", environment: "", group: "" };
|
||||
if (!projectId) {
|
||||
state.currentProject = null;
|
||||
state.configs = [];
|
||||
document.getElementById("current-project-name").textContent = "未选择项目";
|
||||
renderDimensionBar();
|
||||
Tree.render(document.getElementById("tree-content"), null, [], onTreeSelect);
|
||||
renderConfigTable([], "/");
|
||||
return;
|
||||
}
|
||||
state.currentProject = state.projects.find((p) => p.id === projectId);
|
||||
state.configs = await API.getConfigs(projectId);
|
||||
document.getElementById("current-project-name").textContent = state.currentProject.name;
|
||||
renderDimensionBar();
|
||||
refreshTree();
|
||||
renderConfigTable(state.configs, "/");
|
||||
setStatus(`已加载项目:${state.currentProject.name},共 ${state.configs.length} 项配置`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 依据 label 维度筛选,得到用于展示的配置列表
|
||||
* 仅映射为 label 的维度参与筛选;映射为 path 的维度已体现在树路径中
|
||||
* @returns {Array} 筛选后的配置列表
|
||||
*/
|
||||
function getFilteredConfigs() {
|
||||
const p = state.currentProject;
|
||||
if (!p) return [];
|
||||
return state.configs.filter((c) => {
|
||||
if (p.enableIdc && p.idcMapping === "label" && state.dimFilter.idc && c.idc !== state.dimFilter.idc) return false;
|
||||
if (p.enableEnvironment && p.environmentMapping === "label" && state.dimFilter.environment && c.environment !== state.dimFilter.environment) return false;
|
||||
if (p.enableGroup && p.groupMapping === "label" && state.dimFilter.group && c.group !== state.dimFilter.group) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新构建并渲染配置树(应用当前维度筛选)
|
||||
* @returns {void}
|
||||
*/
|
||||
function refreshTree() {
|
||||
Tree.render(document.getElementById("tree-content"), state.currentProject, getFilteredConfigs(), onTreeSelect);
|
||||
}
|
||||
|
||||
/**
|
||||
* 收集某维度在当前项目配置中出现过的全部取值
|
||||
* @param {string} field - 维度字段名(idc/environment/group)
|
||||
* @returns {Array<string>} 去重后的取值列表
|
||||
*/
|
||||
function collectDimValues(field) {
|
||||
const set = new Set();
|
||||
state.configs.forEach((c) => {
|
||||
if (c[field]) set.add(c[field]);
|
||||
});
|
||||
return Array.from(set).sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染维度栏:展示已启用维度的筛选下拉与映射方式
|
||||
* @returns {void}
|
||||
*/
|
||||
function renderDimensionBar() {
|
||||
const p = state.currentProject;
|
||||
const empty = document.getElementById("dim-empty");
|
||||
const mappingBtn = document.getElementById("btn-dim-mapping");
|
||||
const items = [
|
||||
{ key: "idc", enable: "enableIdc", mapping: "idcMapping", field: "idc", box: "dim-idc", sel: "dim-idc-select", map: "dim-idc-mapping" },
|
||||
{ key: "environment", enable: "enableEnvironment", mapping: "environmentMapping", field: "environment", box: "dim-env", sel: "dim-env-select", map: "dim-env-mapping" },
|
||||
{ key: "group", enable: "enableGroup", mapping: "groupMapping", field: "group", box: "dim-group", sel: "dim-group-select", map: "dim-group-mapping" },
|
||||
];
|
||||
|
||||
if (!p) {
|
||||
empty.style.display = "";
|
||||
mappingBtn.style.display = "none";
|
||||
items.forEach((it) => (document.getElementById(it.box).style.display = "none"));
|
||||
return;
|
||||
}
|
||||
|
||||
const anyEnabled = p.enableIdc || p.enableEnvironment || p.enableGroup;
|
||||
empty.style.display = anyEnabled ? "none" : "";
|
||||
mappingBtn.style.display = anyEnabled ? "" : "none";
|
||||
|
||||
items.forEach((it) => {
|
||||
const box = document.getElementById(it.box);
|
||||
if (!p[it.enable]) {
|
||||
box.style.display = "none";
|
||||
return;
|
||||
}
|
||||
box.style.display = "";
|
||||
// 填充筛选下拉
|
||||
const sel = document.getElementById(it.sel);
|
||||
const values = collectDimValues(it.field);
|
||||
sel.innerHTML = '<option value="">全部</option>' + values.map((v) => `<option value="${escapeHtml(v)}">${escapeHtml(v)}</option>`).join("");
|
||||
sel.value = state.dimFilter[it.key] || "";
|
||||
// 展示映射方式
|
||||
document.getElementById(it.map).textContent = `[${MAPPING_NAMES[p[it.mapping]] || "路径"}]`;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 树节点选中回调
|
||||
* @param {Array} configs - 该节点下的配置列表
|
||||
* @param {string} path - 节点路径
|
||||
* @returns {void}
|
||||
*/
|
||||
function onTreeSelect(configs, path) {
|
||||
state.visibleConfigs = configs;
|
||||
renderConfigTable(configs, path);
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染右侧配置表格并同步地址栏
|
||||
* @param {Array} configs - 配置项列表
|
||||
* @param {string} path - 当前路径
|
||||
* @returns {void}
|
||||
*/
|
||||
function renderConfigTable(configs, path) {
|
||||
state.visibleConfigs = configs;
|
||||
document.getElementById("address-input").value = path === "/" ? "" : path;
|
||||
const tbody = document.getElementById("config-tbody");
|
||||
tbody.innerHTML = "";
|
||||
|
||||
if (!configs || configs.length === 0) {
|
||||
tbody.innerHTML = '<tr class="empty-row"><td colspan="5">当前节点暂无配置项</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
configs.forEach((cfg) => {
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = `
|
||||
<td class="config-key-cell">${escapeHtml(cfg.key)}</td>
|
||||
<td class="config-value-cell">${escapeHtml(String(cfg.value))}</td>
|
||||
<td><span class="type-badge">${escapeHtml(cfg.type)}</span></td>
|
||||
<td>${escapeHtml(cfg.description)}</td>
|
||||
<td>
|
||||
<button class="row-btn view" data-action="view" data-id="${cfg.id}">查看</button>
|
||||
<button class="row-btn" data-action="edit" data-id="${cfg.id}">编辑</button>
|
||||
<button class="row-btn danger" data-action="delete" data-id="${cfg.id}">删除</button>
|
||||
</td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据当前项目维度开关,控制配置模态框中维度字段的显示
|
||||
* @returns {void}
|
||||
*/
|
||||
function updateDimensionFields() {
|
||||
const p = state.currentProject;
|
||||
document.getElementById("dim-idc-group").style.display = p && p.enableIdc ? "" : "none";
|
||||
document.getElementById("dim-env-group").style.display = p && p.enableEnvironment ? "" : "none";
|
||||
document.getElementById("dim-group-group").style.display = p && p.enableGroup ? "" : "none";
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开配置编辑模态框
|
||||
* @param {Object|null} config - 配置项,null 表示新增
|
||||
* @param {boolean} readonly - 是否只读查看
|
||||
* @returns {void}
|
||||
*/
|
||||
function openConfigModal(config, readonly) {
|
||||
state.editingConfigId = config ? config.id : null;
|
||||
document.getElementById("modal-config-title").textContent = readonly
|
||||
? "查看配置项"
|
||||
: config
|
||||
? "编辑配置项"
|
||||
: "新增配置项";
|
||||
|
||||
// 新增时用维度栏当前筛选值预填维度,方便就地新增
|
||||
const preIdc = config ? config.idc || "" : state.dimFilter.idc || "";
|
||||
const preEnv = config ? config.environment || "" : state.dimFilter.environment || "";
|
||||
const preGroup = config ? config.group || "" : state.dimFilter.group || "";
|
||||
|
||||
document.getElementById("config-key").value = config ? config.key : "";
|
||||
document.getElementById("config-idc").value = preIdc;
|
||||
document.getElementById("config-environment").value = preEnv;
|
||||
document.getElementById("config-group").value = preGroup;
|
||||
document.getElementById("config-value").value = config ? config.value : "";
|
||||
document.getElementById("config-type").value = config ? config.type : "string";
|
||||
document.getElementById("config-value-range").value = config ? config.valueRange || "" : "";
|
||||
document.getElementById("config-description").value = config ? config.description : "";
|
||||
document.getElementById("config-purpose").value = config ? config.purpose || "" : "";
|
||||
|
||||
updateDimensionFields();
|
||||
applyFieldPermissions(readonly);
|
||||
openModal("modal-config");
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据模式和是否只读查看,设置配置模态框各字段的可编辑性
|
||||
* 规划模式:结构字段(类型/说明/用途/范围/键名/维度)可改,值不可改
|
||||
* 编辑模式:值可改,已有项的结构字段锁定
|
||||
* 只读查看:全部禁用
|
||||
* @param {boolean} readonly - 是否只读查看
|
||||
* @returns {void}
|
||||
*/
|
||||
function applyFieldPermissions(readonly) {
|
||||
const structFields = [
|
||||
"config-key", "config-type", "config-value-range",
|
||||
"config-description", "config-purpose",
|
||||
"config-idc", "config-environment", "config-group",
|
||||
];
|
||||
const valueField = "config-value";
|
||||
const saveBtn = document.getElementById("btn-save-config");
|
||||
|
||||
const disableAll = (disabled) => {
|
||||
structFields.forEach((id) => (document.getElementById(id).disabled = disabled));
|
||||
document.getElementById(valueField).disabled = disabled;
|
||||
};
|
||||
|
||||
if (readonly || state.mode === "readonly") {
|
||||
disableAll(true);
|
||||
saveBtn.style.display = "none";
|
||||
return;
|
||||
}
|
||||
|
||||
saveBtn.style.display = "";
|
||||
|
||||
if (state.mode === "plan") {
|
||||
// 规划模式:可改结构,值不可改
|
||||
structFields.forEach((id) => (document.getElementById(id).disabled = false));
|
||||
document.getElementById(valueField).disabled = true;
|
||||
} else if (state.mode === "edit") {
|
||||
// 编辑模式:新增时结构可填;编辑已有项时仅值可改(键名/维度也允许调整路径)
|
||||
const isNew = !state.editingConfigId;
|
||||
document.getElementById("config-type").disabled = !isNew;
|
||||
document.getElementById("config-value-range").disabled = !isNew;
|
||||
document.getElementById("config-description").disabled = !isNew;
|
||||
document.getElementById("config-purpose").disabled = !isNew;
|
||||
document.getElementById("config-key").disabled = false;
|
||||
document.getElementById("config-idc").disabled = false;
|
||||
document.getElementById("config-environment").disabled = false;
|
||||
document.getElementById("config-group").disabled = false;
|
||||
document.getElementById(valueField).disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 收集配置模态框表单数据
|
||||
* @returns {Object} 表单数据
|
||||
*/
|
||||
function collectConfigForm() {
|
||||
return {
|
||||
key: document.getElementById("config-key").value.trim(),
|
||||
idc: document.getElementById("config-idc").value.trim(),
|
||||
environment: document.getElementById("config-environment").value.trim(),
|
||||
group: document.getElementById("config-group").value.trim(),
|
||||
value: document.getElementById("config-value").value,
|
||||
type: document.getElementById("config-type").value,
|
||||
valueRange: document.getElementById("config-value-range").value.trim(),
|
||||
description: document.getElementById("config-description").value.trim(),
|
||||
purpose: document.getElementById("config-purpose").value.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存配置项(新增或更新)
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function saveConfig() {
|
||||
if (!state.currentProject) {
|
||||
toast("请先选择项目", "error");
|
||||
return;
|
||||
}
|
||||
const data = collectConfigForm();
|
||||
try {
|
||||
if (state.editingConfigId) {
|
||||
await API.updateConfig(state.currentProject.id, state.editingConfigId, data);
|
||||
toast("配置已更新", "success");
|
||||
} else {
|
||||
await API.createConfig(state.currentProject.id, data);
|
||||
toast("配置已新增", "success");
|
||||
}
|
||||
closeModal("modal-config");
|
||||
await selectProject(state.currentProject.id);
|
||||
} catch (err) {
|
||||
toast(err.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存新建项目
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function saveProject() {
|
||||
const data = {
|
||||
name: document.getElementById("project-name").value.trim(),
|
||||
description: document.getElementById("project-description").value.trim(),
|
||||
enableIdc: document.getElementById("project-enable-idc").checked,
|
||||
enableEnvironment: document.getElementById("project-enable-env").checked,
|
||||
enableGroup: document.getElementById("project-enable-group").checked,
|
||||
idcMapping: document.getElementById("project-idc-mapping").value,
|
||||
environmentMapping: document.getElementById("project-env-mapping").value,
|
||||
groupMapping: document.getElementById("project-group-mapping").value,
|
||||
};
|
||||
try {
|
||||
const project = await API.createProject(data);
|
||||
toast("项目已创建", "success");
|
||||
closeModal("modal-project");
|
||||
await loadProjects();
|
||||
document.getElementById("project-selector").value = project.id;
|
||||
await selectProject(project.id);
|
||||
} catch (err) {
|
||||
toast(err.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开维度映射配置模态框,回填当前项目映射方式
|
||||
* @returns {void}
|
||||
*/
|
||||
function openMappingModal() {
|
||||
const p = state.currentProject;
|
||||
if (!p) {
|
||||
toast("请先选择项目", "error");
|
||||
return;
|
||||
}
|
||||
document.getElementById("map-idc-row").style.display = p.enableIdc ? "" : "none";
|
||||
document.getElementById("map-env-row").style.display = p.enableEnvironment ? "" : "none";
|
||||
document.getElementById("map-group-row").style.display = p.enableGroup ? "" : "none";
|
||||
document.getElementById("map-idc-mapping").value = p.idcMapping || "path";
|
||||
document.getElementById("map-env-mapping").value = p.environmentMapping || "path";
|
||||
document.getElementById("map-group-mapping").value = p.groupMapping || "path";
|
||||
openModal("modal-mapping");
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存维度映射配置
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function saveMapping() {
|
||||
if (!state.currentProject) return;
|
||||
const mappings = {
|
||||
idcMapping: document.getElementById("map-idc-mapping").value,
|
||||
environmentMapping: document.getElementById("map-env-mapping").value,
|
||||
groupMapping: document.getElementById("map-group-mapping").value,
|
||||
};
|
||||
try {
|
||||
await API.updateMappings(state.currentProject.id, mappings);
|
||||
toast("映射配置已保存", "success");
|
||||
closeModal("modal-mapping");
|
||||
await selectProject(state.currentProject.id);
|
||||
} catch (err) {
|
||||
toast(err.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示确认框
|
||||
* @param {string} message - 提示信息
|
||||
* @param {Function} callback - 确认回调
|
||||
* @returns {void}
|
||||
*/
|
||||
function confirmAction(message, callback) {
|
||||
document.getElementById("confirm-message").textContent = message;
|
||||
state.confirmCallback = callback;
|
||||
openModal("modal-confirm");
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出当前项目配置为 JSON 文件
|
||||
* @returns {void}
|
||||
*/
|
||||
function exportConfig() {
|
||||
if (!state.currentProject) {
|
||||
toast("请先选择项目", "error");
|
||||
return;
|
||||
}
|
||||
const payload = { project: state.currentProject, configs: state.configs };
|
||||
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${state.currentProject.name}-configs.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
toast("配置已导出", "success");
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入配置文件(逐条新增到当前项目)
|
||||
* @param {File} file - 用户选择的 JSON 文件
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function importConfig(file) {
|
||||
if (!state.currentProject) {
|
||||
toast("请先选择项目", "error");
|
||||
return;
|
||||
}
|
||||
if (state.mode === "readonly") {
|
||||
toast("只读模式下不允许导入", "error");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const text = await file.text();
|
||||
const payload = JSON.parse(text);
|
||||
const configs = payload.configs || [];
|
||||
let count = 0;
|
||||
for (const cfg of configs) {
|
||||
try {
|
||||
await API.createConfig(state.currentProject.id, cfg);
|
||||
count++;
|
||||
} catch {
|
||||
// 忽略单条失败,继续导入其余配置
|
||||
}
|
||||
}
|
||||
toast(`成功导入 ${count} 项配置`, "success");
|
||||
await selectProject(state.currentProject.id);
|
||||
} catch (err) {
|
||||
toast("导入失败:" + err.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 地址栏跳转:按输入路径定位并选中树节点
|
||||
* @returns {void}
|
||||
*/
|
||||
function navigateToPath() {
|
||||
if (!state.currentProject) {
|
||||
toast("请先选择项目", "error");
|
||||
return;
|
||||
}
|
||||
const target = document.getElementById("address-input").value.trim();
|
||||
if (target === "" || target === "/") {
|
||||
Tree.selectByPath(document.getElementById("tree-content"), "");
|
||||
renderConfigTable(getFilteredConfigs(), "/");
|
||||
return;
|
||||
}
|
||||
const ok = Tree.selectByPath(document.getElementById("tree-content"), target);
|
||||
if (!ok) {
|
||||
toast("未找到该路径:" + target, "error");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 拷贝地址栏路径到剪贴板
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function copyAddress() {
|
||||
const text = document.getElementById("address-input").value;
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast("路径已拷贝", "success");
|
||||
} catch {
|
||||
// 剪贴板不可用时退化为选中输入框内容
|
||||
document.getElementById("address-input").select();
|
||||
toast("请手动复制(Ctrl+C)", "info");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定所有事件监听
|
||||
* @returns {void}
|
||||
*/
|
||||
function bindEvents() {
|
||||
// 项目选择
|
||||
document.getElementById("project-selector").addEventListener("change", (e) => {
|
||||
selectProject(e.target.value);
|
||||
});
|
||||
|
||||
// 新建项目
|
||||
document.getElementById("btn-new-project").addEventListener("click", () => {
|
||||
document.getElementById("project-name").value = "";
|
||||
document.getElementById("project-description").value = "";
|
||||
document.getElementById("project-enable-idc").checked = true;
|
||||
document.getElementById("project-enable-env").checked = true;
|
||||
document.getElementById("project-enable-group").checked = true;
|
||||
document.getElementById("project-idc-mapping").value = "path";
|
||||
document.getElementById("project-env-mapping").value = "path";
|
||||
document.getElementById("project-group-mapping").value = "path";
|
||||
openModal("modal-project");
|
||||
});
|
||||
document.getElementById("btn-save-project").addEventListener("click", saveProject);
|
||||
|
||||
// 删除项目
|
||||
document.getElementById("btn-delete-project").addEventListener("click", () => {
|
||||
if (!state.currentProject) {
|
||||
toast("请先选择项目", "error");
|
||||
return;
|
||||
}
|
||||
const proj = state.currentProject;
|
||||
confirmAction(`确认删除项目 "${proj.name}" 及其全部配置吗?`, async () => {
|
||||
try {
|
||||
await API.deleteProject(proj.id);
|
||||
toast("项目已删除", "success");
|
||||
closeModal("modal-confirm");
|
||||
await loadProjects();
|
||||
await selectProject("");
|
||||
document.getElementById("project-selector").value = "";
|
||||
} catch (err) {
|
||||
toast(err.message, "error");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 模式切换
|
||||
document.querySelectorAll(".mode-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", async () => {
|
||||
try {
|
||||
await API.setMode(btn.dataset.mode);
|
||||
state.mode = btn.dataset.mode;
|
||||
applyMode();
|
||||
toast(`已切换到${MODE_NAMES[state.mode]}模式`, "info");
|
||||
} catch (err) {
|
||||
toast(err.message, "error");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 新增配置
|
||||
document.getElementById("btn-add-config").addEventListener("click", () => {
|
||||
if (!state.currentProject) {
|
||||
toast("请先选择项目", "error");
|
||||
return;
|
||||
}
|
||||
if (state.mode === "readonly") {
|
||||
toast("只读模式下不允许新增", "error");
|
||||
return;
|
||||
}
|
||||
openConfigModal(null, false);
|
||||
});
|
||||
document.getElementById("btn-save-config").addEventListener("click", saveConfig);
|
||||
|
||||
// 维度筛选下拉
|
||||
document.getElementById("dim-idc-select").addEventListener("change", (e) => {
|
||||
state.dimFilter.idc = e.target.value;
|
||||
refreshTree();
|
||||
renderConfigTable(getFilteredConfigs(), "/");
|
||||
});
|
||||
document.getElementById("dim-env-select").addEventListener("change", (e) => {
|
||||
state.dimFilter.environment = e.target.value;
|
||||
refreshTree();
|
||||
renderConfigTable(getFilteredConfigs(), "/");
|
||||
});
|
||||
document.getElementById("dim-group-select").addEventListener("change", (e) => {
|
||||
state.dimFilter.group = e.target.value;
|
||||
refreshTree();
|
||||
renderConfigTable(getFilteredConfigs(), "/");
|
||||
});
|
||||
|
||||
// 维度映射配置
|
||||
document.getElementById("btn-dim-mapping").addEventListener("click", openMappingModal);
|
||||
document.getElementById("btn-save-mapping").addEventListener("click", saveMapping);
|
||||
|
||||
// 地址栏
|
||||
document.getElementById("btn-address-go").addEventListener("click", navigateToPath);
|
||||
document.getElementById("btn-address-copy").addEventListener("click", copyAddress);
|
||||
document.getElementById("address-input").addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") navigateToPath();
|
||||
});
|
||||
|
||||
// 导出/导入
|
||||
document.getElementById("btn-export-config").addEventListener("click", exportConfig);
|
||||
document.getElementById("btn-import-config").addEventListener("click", () => {
|
||||
document.getElementById("import-file-input").click();
|
||||
});
|
||||
document.getElementById("import-file-input").addEventListener("change", (e) => {
|
||||
if (e.target.files[0]) {
|
||||
importConfig(e.target.files[0]);
|
||||
e.target.value = "";
|
||||
}
|
||||
});
|
||||
|
||||
// 表格操作(事件委托)
|
||||
document.getElementById("config-tbody").addEventListener("click", (e) => {
|
||||
const btn = e.target.closest(".row-btn");
|
||||
if (!btn) return;
|
||||
const id = btn.dataset.id;
|
||||
const action = btn.dataset.action;
|
||||
const config = state.configs.find((c) => c.id === id);
|
||||
if (!config) return;
|
||||
|
||||
if (action === "view") {
|
||||
openConfigModal(config, true);
|
||||
} else if (action === "edit") {
|
||||
if (state.mode === "readonly") {
|
||||
toast("只读模式下不允许编辑", "error");
|
||||
return;
|
||||
}
|
||||
openConfigModal(config, false);
|
||||
} else if (action === "delete") {
|
||||
if (state.mode === "readonly") {
|
||||
toast("只读模式下不允许删除", "error");
|
||||
return;
|
||||
}
|
||||
confirmAction(`确认删除配置项 "${config.key}" 吗?`, async () => {
|
||||
try {
|
||||
await API.deleteConfig(state.currentProject.id, id);
|
||||
toast("配置已删除", "success");
|
||||
closeModal("modal-confirm");
|
||||
await selectProject(state.currentProject.id);
|
||||
} catch (err) {
|
||||
toast(err.message, "error");
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 确认框
|
||||
document.getElementById("btn-confirm-ok").addEventListener("click", () => {
|
||||
if (state.confirmCallback) state.confirmCallback();
|
||||
});
|
||||
|
||||
// 全部折叠
|
||||
document.getElementById("btn-collapse-all").addEventListener("click", () => {
|
||||
document.querySelectorAll(".tree-children").forEach((el) => el.classList.add("collapsed"));
|
||||
document.querySelectorAll(".tree-toggle").forEach((el) => {
|
||||
if (el.textContent === "▼") el.textContent = "▶";
|
||||
});
|
||||
});
|
||||
|
||||
// 分隔条拖拽
|
||||
bindResizer();
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定左右面板分隔条的拖拽调整宽度功能
|
||||
* @returns {void}
|
||||
*/
|
||||
function bindResizer() {
|
||||
const resizer = document.getElementById("resizer");
|
||||
const treePanel = document.getElementById("tree-panel");
|
||||
let dragging = false;
|
||||
|
||||
resizer.addEventListener("mousedown", () => {
|
||||
dragging = true;
|
||||
document.body.style.cursor = "col-resize";
|
||||
});
|
||||
document.addEventListener("mousemove", (e) => {
|
||||
if (!dragging) return;
|
||||
const width = Math.max(180, Math.min(600, e.clientX));
|
||||
treePanel.style.width = width + "px";
|
||||
});
|
||||
document.addEventListener("mouseup", () => {
|
||||
dragging = false;
|
||||
document.body.style.cursor = "";
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用初始化入口
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function init() {
|
||||
bindEvents();
|
||||
try {
|
||||
const modeData = await API.getMode();
|
||||
state.mode = modeData.mode;
|
||||
} catch {
|
||||
state.mode = "edit";
|
||||
}
|
||||
applyMode();
|
||||
await loadProjects();
|
||||
setStatus("就绪");
|
||||
}
|
||||
|
||||
// 页面加载完成后启动
|
||||
window.addEventListener("DOMContentLoaded", init);
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* 树形组件
|
||||
* 将配置项按“路径映射维度前缀 + 嵌套键”构建成注册表风格的树并渲染
|
||||
* 说明:仅映射为 path 的维度进入路径;label 维度由维度栏筛选,不进入树层级;
|
||||
* 配置键 key 自身按 / 拆分为多级目录,叶子节点为真正的配置项。
|
||||
*/
|
||||
const Tree = {
|
||||
/**
|
||||
* 将配置项列表构建为树形数据结构
|
||||
* 每个节点结构: { name, path, type:"dir"|"leaf", children:{}, config }
|
||||
* @param {Object} project - 项目对象
|
||||
* @param {Array} configs - 已按 label 维度筛选后的配置项列表(含 path 字段)
|
||||
* @returns {Object} 树根节点
|
||||
*/
|
||||
buildTree(project, configs) {
|
||||
const root = { name: project.name, path: "", type: "dir", children: {}, config: null };
|
||||
|
||||
configs.forEach((config) => {
|
||||
// path 已由后端按映射规则生成,直接按 / 拆分为层级
|
||||
const segments = String(config.path || config.key).split("/").filter((s) => s !== "");
|
||||
let node = root;
|
||||
let acc = "";
|
||||
segments.forEach((seg, idx) => {
|
||||
acc = acc ? acc + "/" + seg : seg;
|
||||
const isLast = idx === segments.length - 1;
|
||||
if (!node.children[seg]) {
|
||||
node.children[seg] = { name: seg, path: acc, type: "dir", children: {}, config: null };
|
||||
}
|
||||
node = node.children[seg];
|
||||
if (isLast) {
|
||||
// 叶子节点绑定真实配置项
|
||||
node.type = "leaf";
|
||||
node.config = config;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return root;
|
||||
},
|
||||
|
||||
/**
|
||||
* 渲染树到指定容器
|
||||
* @param {HTMLElement} container - 容器元素
|
||||
* @param {Object} project - 项目对象
|
||||
* @param {Array} configs - 配置项列表
|
||||
* @param {Function} onSelect - 节点选中回调,参数为(配置列表, 路径, 节点)
|
||||
*/
|
||||
render(container, project, configs, onSelect) {
|
||||
container.innerHTML = "";
|
||||
if (!project) {
|
||||
container.innerHTML = '<div class="tree-empty">请先选择一个项目</div>';
|
||||
return;
|
||||
}
|
||||
const tree = this.buildTree(project, configs);
|
||||
const rootEl = this._renderNode(tree, onSelect, true);
|
||||
container.appendChild(rootEl);
|
||||
},
|
||||
|
||||
/**
|
||||
* 收集某节点及其子孙下的全部叶子配置项
|
||||
* @param {Object} node - 树节点
|
||||
* @returns {Array} 配置项列表
|
||||
*/
|
||||
collectConfigs(node) {
|
||||
let list = [];
|
||||
if (node.type === "leaf" && node.config) {
|
||||
list.push(node.config);
|
||||
}
|
||||
Object.values(node.children).forEach((child) => {
|
||||
list = list.concat(this.collectConfigs(child));
|
||||
});
|
||||
return list;
|
||||
},
|
||||
|
||||
/**
|
||||
* 递归渲染单个树节点
|
||||
* @param {Object} node - 树节点
|
||||
* @param {Function} onSelect - 选中回调
|
||||
* @param {boolean} isRoot - 是否为根节点
|
||||
* @returns {HTMLElement} 节点元素
|
||||
*/
|
||||
_renderNode(node, onSelect, isRoot) {
|
||||
const nodeEl = document.createElement("div");
|
||||
nodeEl.className = "tree-node";
|
||||
|
||||
const childKeys = Object.keys(node.children);
|
||||
const hasChildren = childKeys.length > 0;
|
||||
const isLeaf = node.type === "leaf" && !hasChildren;
|
||||
|
||||
const label = document.createElement("div");
|
||||
label.className = "tree-node-label" + (isLeaf ? " is-leaf" : "");
|
||||
label.dataset.path = node.path;
|
||||
|
||||
const toggle = document.createElement("span");
|
||||
toggle.className = "tree-toggle";
|
||||
toggle.textContent = hasChildren ? "▼" : "";
|
||||
|
||||
const icon = document.createElement("span");
|
||||
icon.className = "tree-icon";
|
||||
icon.textContent = isRoot ? "📦" : isLeaf ? "🔑" : "📁";
|
||||
|
||||
const text = document.createElement("span");
|
||||
text.textContent = node.name;
|
||||
|
||||
label.appendChild(toggle);
|
||||
label.appendChild(icon);
|
||||
label.appendChild(text);
|
||||
nodeEl.appendChild(label);
|
||||
|
||||
label.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
document.querySelectorAll(".tree-node-label.selected").forEach((el) => el.classList.remove("selected"));
|
||||
label.classList.add("selected");
|
||||
onSelect(this.collectConfigs(node), node.path || "/", node);
|
||||
});
|
||||
|
||||
if (hasChildren) {
|
||||
const childrenEl = document.createElement("div");
|
||||
childrenEl.className = "tree-children";
|
||||
childKeys.sort().forEach((key) => {
|
||||
childrenEl.appendChild(this._renderNode(node.children[key], onSelect, false));
|
||||
});
|
||||
nodeEl.appendChild(childrenEl);
|
||||
|
||||
toggle.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
childrenEl.classList.toggle("collapsed");
|
||||
toggle.textContent = childrenEl.classList.contains("collapsed") ? "▶" : "▼";
|
||||
});
|
||||
}
|
||||
|
||||
return nodeEl;
|
||||
},
|
||||
|
||||
/**
|
||||
* 根据路径展开并选中对应节点(供地址栏跳转使用)
|
||||
* @param {HTMLElement} container - 树容器
|
||||
* @param {string} targetPath - 目标路径
|
||||
* @returns {boolean} 是否命中并选中节点
|
||||
*/
|
||||
selectByPath(container, targetPath) {
|
||||
const normalized = String(targetPath || "").split("/").map((s) => s.trim()).filter((s) => s !== "").join("/");
|
||||
const labels = container.querySelectorAll(".tree-node-label");
|
||||
for (const label of labels) {
|
||||
if (label.dataset.path === normalized) {
|
||||
// 展开所有祖先节点
|
||||
let parent = label.parentElement;
|
||||
while (parent && parent !== container) {
|
||||
if (parent.classList.contains("tree-children")) {
|
||||
parent.classList.remove("collapsed");
|
||||
const pToggle = parent.previousElementSibling?.querySelector(".tree-toggle");
|
||||
if (pToggle && pToggle.textContent === "▶") pToggle.textContent = "▼";
|
||||
}
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
label.click();
|
||||
label.scrollIntoView({ block: "center" });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user