867 lines
30 KiB
JavaScript
867 lines
30 KiB
JavaScript
/**
|
||
* 主应用逻辑
|
||
* 负责状态管理、事件绑定、界面渲染,以及维度筛选、地址栏导航与三种模式的差异化控制
|
||
*/
|
||
|
||
// 全局应用状态
|
||
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: "标签" };
|
||
|
||
/**
|
||
* string 类型值范围的常见格式正则预设(前端选择后填入值范围输入框,后端按 /正则/ 校验)
|
||
* regex 为去掉首尾斜杠与修饰符的正则体
|
||
*/
|
||
const RANGE_PRESETS = {
|
||
phone: { label: "手机号", regex: "^1[3-9]\\d{9}$" },
|
||
email: { label: "邮箱", regex: "^[\\w.%+-]+@[\\w.-]+\\.[A-Za-z]{2,}$" },
|
||
idcard: { label: "身份证", regex: "^\\d{17}[\\dXx]$" },
|
||
ipv4: { label: "IPv4", regex: "^(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}$" },
|
||
url: { label: "URL", regex: "^https?:\\/\\/[\\w.-]+(?:\\.[a-zA-Z]{2,})?(?:\\/[^\\s]*)?$" },
|
||
port: { label: "端口号", regex: "^([1-9]\\d{0,3}|[1-5]\\d{4}|6[0-4]\\d{3}|65[0-4]\\d{2}|655[0-2]\\d|6553[0-5])$" },
|
||
alphanumeric: { label: "字母数字", regex: "^[A-Za-z0-9]+$" },
|
||
};
|
||
|
||
/**
|
||
* 应用当前模式到界面(更新样式类、按钮状态、状态栏)
|
||
* @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 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) {
|
||
mappingBtn.style.display = "none";
|
||
items.forEach((it) => (document.getElementById(it.box).style.display = "none"));
|
||
return;
|
||
}
|
||
|
||
const anyEnabled = p.enableIdc || p.enableEnvironment || p.enableGroup;
|
||
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 = "";
|
||
// 填充筛选下拉(用 DOM 构建避免属性上下文注入)
|
||
const sel = document.getElementById(it.sel);
|
||
const values = collectDimValues(it.field);
|
||
sel.innerHTML = '<option value="">全部</option>';
|
||
values.forEach((v) => {
|
||
const opt = document.createElement("option");
|
||
opt.value = v;
|
||
opt.textContent = v;
|
||
sel.appendChild(opt);
|
||
});
|
||
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="6">当前节点暂无配置项</td></tr>';
|
||
return;
|
||
}
|
||
|
||
configs.forEach((cfg) => {
|
||
const exists = cfg.exists !== false;
|
||
const tr = document.createElement("tr");
|
||
if (!exists) tr.classList.add("row-not-exists");
|
||
|
||
const statusHtml = [];
|
||
if (cfg.required === true) statusHtml.push('<span class="status-badge badge-required">必填</span>');
|
||
statusHtml.push(
|
||
exists
|
||
? '<span class="status-badge badge-exists">存在</span>'
|
||
: '<span class="status-badge badge-missing">不存在</span>'
|
||
);
|
||
|
||
tr.innerHTML = `
|
||
<td class="config-key-cell">${escapeHtml(cfg.key)}</td>
|
||
<td class="config-value-cell">${buildValueCell(cfg, exists)}</td>
|
||
<td><span class="type-badge">${escapeHtml(cfg.type)}</span></td>
|
||
<td class="config-status-cell">${statusHtml.join("")}</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);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 构建表格值列 HTML:存在且值非空显示实际值;值为空但配置了默认值时回退展示默认值;不存在显示 —
|
||
* @param {Object} cfg - 配置项
|
||
* @param {boolean} exists - 是否存在
|
||
* @returns {string} 值列 HTML
|
||
*/
|
||
function buildValueCell(cfg, exists) {
|
||
if (!exists) return '<span class="value-missing">—</span>';
|
||
const value = String(cfg.value);
|
||
if (value.trim() !== "") return escapeHtml(value);
|
||
const hasDefault =
|
||
cfg.defaultValue !== undefined && cfg.defaultValue !== null && String(cfg.defaultValue).trim() !== "";
|
||
if (hasDefault) {
|
||
return `<span class="value-default" title="配置值为空,回退展示默认值">默认 ${escapeHtml(String(cfg.defaultValue))}</span>`;
|
||
}
|
||
return "";
|
||
}
|
||
|
||
/**
|
||
* 根据当前项目维度开关,控制配置模态框中维度字段的显示
|
||
* @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-default-value").value =
|
||
config && config.defaultValue !== undefined && config.defaultValue !== null ? config.defaultValue : "";
|
||
// 编辑模式下提示默认值(留空则回退展示默认值)
|
||
document.getElementById("config-value").placeholder =
|
||
config && config.defaultValue !== undefined && config.defaultValue !== null && String(config.defaultValue).trim() !== ""
|
||
? `留空则使用默认值:${config.defaultValue}`
|
||
: "配置值";
|
||
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 || "" : "";
|
||
document.getElementById("config-required").checked = config ? config.required === true : false;
|
||
document.getElementById("config-exists").checked = config ? config.exists !== false : true;
|
||
document.getElementById("config-range-preset").value = "";
|
||
|
||
updateDimensionFields();
|
||
updateRangePresetVisibility();
|
||
applyFieldPermissions(readonly);
|
||
openModal("modal-config");
|
||
}
|
||
|
||
/**
|
||
* 根据配置类型显示/隐藏“常见格式”正则预设下拉(仅 string 类型提供)
|
||
* @returns {void}
|
||
*/
|
||
function updateRangePresetVisibility() {
|
||
const isString = document.getElementById("config-type").value === "string";
|
||
document.getElementById("config-range-preset-group").style.display = isString ? "" : "none";
|
||
}
|
||
|
||
/**
|
||
* 根据模式和是否只读查看,设置配置模态框各字段的可编辑性
|
||
* 规划模式:结构字段(类型/说明/用途/范围/键名/维度)可改,值不可改
|
||
* 编辑模式:值可改,已有项的结构字段锁定
|
||
* 只读查看:全部禁用
|
||
* @param {boolean} readonly - 是否只读查看
|
||
* @returns {void}
|
||
*/
|
||
function applyFieldPermissions(readonly) {
|
||
const structFields = [
|
||
"config-key", "config-type", "config-value-range", "config-required",
|
||
"config-default-value",
|
||
"config-description", "config-purpose",
|
||
"config-idc", "config-environment", "config-group",
|
||
];
|
||
const valueField = "config-value";
|
||
const existsField = "config-exists";
|
||
const presetField = "config-range-preset";
|
||
const saveBtn = document.getElementById("btn-save-config");
|
||
|
||
const disableAll = (disabled) => {
|
||
structFields.forEach((id) => (document.getElementById(id).disabled = disabled));
|
||
document.getElementById(valueField).disabled = disabled;
|
||
document.getElementById(existsField).disabled = disabled;
|
||
document.getElementById(presetField).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;
|
||
document.getElementById(existsField).disabled = true;
|
||
document.getElementById(presetField).disabled = false;
|
||
} 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-required").disabled = !isNew;
|
||
document.getElementById("config-default-value").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;
|
||
document.getElementById(existsField).disabled = false;
|
||
document.getElementById(presetField).disabled = !isNew;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 收集配置模态框表单数据
|
||
* @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(),
|
||
defaultValue: document.getElementById("config-default-value").value,
|
||
required: document.getElementById("config-required").checked,
|
||
exists: document.getElementById("config-exists").checked,
|
||
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);
|
||
|
||
// 类型切换:仅 string 类型展示“常见格式”预设
|
||
document.getElementById("config-type").addEventListener("change", updateRangePresetVisibility);
|
||
|
||
// 常见格式预设:选择后自动填入值范围(/正则/ 格式)
|
||
document.getElementById("config-range-preset").addEventListener("change", (e) => {
|
||
const key = e.target.value;
|
||
if (!key) return;
|
||
const preset = RANGE_PRESETS[key];
|
||
if (!preset) return;
|
||
document.getElementById("config-value-range").value = `/${preset.regex}/`;
|
||
toast(`已填入${preset.label}格式正则,可继续编辑`, "info");
|
||
});
|
||
|
||
// 维度筛选下拉
|
||
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);
|