This commit is contained in:
parent
e3e5d5bd8f
commit
f9f3184f98
3
.gitignore
vendored
3
.gitignore
vendored
@ -2,4 +2,5 @@ node_modules/
|
||||
data/projects.json
|
||||
data/system.json
|
||||
data/configs/*.json
|
||||
*.log
|
||||
*.log
|
||||
.reasonix/*
|
||||
10
docs/需求.md
10
docs/需求.md
@ -31,11 +31,14 @@
|
||||
- **FR-3-3**:每个配置项必须有说明(description),用于文档化配置用途
|
||||
- **FR-3-4**:支持配置项的新增、修改、删除
|
||||
- **FR-3-5**:配置值根据类型进行格式校验
|
||||
- **FR-3-8**:配置项支持「是否必须」(required,规划模式设置)与「是否存在」(exists,编辑模式设置)两个标志;required 且 exists 时编辑模式值必填,exists 关闭视为该配置项当前不存在、允许值为空
|
||||
- **FR-3-9**:值范围支持正则约束(`/pattern/flags` 格式,string 类型常用),前端提供手机号、邮箱、身份证、IPv4、URL、端口、字母数字等常见格式预设
|
||||
- **FR-3-10**:配置项支持默认值(defaultValue,规划模式设置,需符合值类型);编辑模式下值为空时表格回退展示默认值
|
||||
|
||||
### 2.4 三种工作模式
|
||||
|
||||
- **FR-4-1**:规划模式——定义配置结构,包括字段名、类型、用途、值范围约束,类似"配置 Schema 设计"
|
||||
- **FR-4-2**:编辑模式——在规划好的结构基础上填写实际配置值
|
||||
- **FR-4-1**:规划模式——定义配置结构,包括字段名、类型、用途、值范围约束、是否必须(required),类似"配置 Schema 设计"
|
||||
- **FR-4-2**:编辑模式——在规划好的结构基础上填写实际配置值,可设置配置项是否存在(exists)
|
||||
- **FR-4-3**:只读模式——仅可查看配置,不可修改,用于日常查阅和审计
|
||||
|
||||
### 2.5 持久化方案
|
||||
@ -49,9 +52,10 @@
|
||||
|
||||
- **FR-6-1**:左侧树形导航,按“path 维度前缀 + 嵌套键”逐级展开,视觉风格参考 Windows 注册表编辑器
|
||||
- **FR-6-2**:右侧详情面板,顶部为配置导航地址栏,下方展示选中节点的配置项列表
|
||||
- **FR-6-3**:每个配置项展示:键名、值、类型、说明
|
||||
- **FR-6-3**:每个配置项展示:键名、值、类型、状态(必填/存在)、说明
|
||||
- **FR-6-4**:标题栏(应用名+当前项目)与工具栏分离;工具栏按项目/模式/配置/数据分组
|
||||
- **FR-6-5**:维度栏独立展示已启用维度的筛选器与映射方式,并提供映射配置入口
|
||||
- **FR-6-6**:配置项编辑/查看使用右侧抽屉式弹出,占屏幕至少 60% 面积
|
||||
|
||||
## 3. 非功能需求
|
||||
|
||||
|
||||
@ -269,10 +269,11 @@ body {
|
||||
background: #f8fbff;
|
||||
}
|
||||
|
||||
.col-key { width: 22%; }
|
||||
.col-value { width: 26%; }
|
||||
.col-type { width: 10%; }
|
||||
.col-desc { width: 30%; }
|
||||
.col-key { width: 18%; }
|
||||
.col-value { width: 24%; }
|
||||
.col-type { width: 8%; }
|
||||
.col-status { width: 12%; }
|
||||
.col-desc { width: 26%; }
|
||||
.col-actions { width: 12%; }
|
||||
|
||||
.config-key-cell {
|
||||
@ -294,6 +295,59 @@ body {
|
||||
color: #0078d4;
|
||||
}
|
||||
|
||||
/* 状态徽标:必填 / 存在 / 不存在 */
|
||||
.config-status-cell {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 1px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
margin-right: 4px;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.badge-required {
|
||||
background: #fff3e0;
|
||||
color: #b45309;
|
||||
border-color: #f0c98f;
|
||||
}
|
||||
|
||||
.badge-exists {
|
||||
background: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
border-color: #b5dfb8;
|
||||
}
|
||||
|
||||
.badge-missing {
|
||||
background: #fdecea;
|
||||
color: #c62828;
|
||||
border-color: #f2b8b5;
|
||||
}
|
||||
|
||||
/* 不存在(未生效)的配置行 */
|
||||
.row-not-exists td {
|
||||
color: #999;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.row-not-exists .config-key-cell {
|
||||
color: #999;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.value-missing {
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
/* 默认值回退展示(配置值为空时) */
|
||||
.value-default {
|
||||
color: #8a8a8a;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.empty-row td {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
@ -353,6 +407,42 @@ body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 右侧抽屉:配置项编辑/查看使用,占屏幕至少 60% */
|
||||
.drawer-overlay {
|
||||
justify-content: flex-end;
|
||||
align-items: stretch;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.drawer-overlay .modal {
|
||||
width: 68vw;
|
||||
max-width: none;
|
||||
height: 100vh;
|
||||
max-height: 100vh;
|
||||
border-radius: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.25);
|
||||
animation: drawer-in 0.22s ease-out;
|
||||
}
|
||||
|
||||
.drawer-overlay .modal-body {
|
||||
flex: 1;
|
||||
max-height: none;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 抽屉内表单内容限宽,避免超宽屏下字段过度拉伸 */
|
||||
.drawer-overlay .modal-body .form-group,
|
||||
.drawer-overlay .modal-body .form-row {
|
||||
max-width: 780px;
|
||||
}
|
||||
|
||||
@keyframes drawer-in {
|
||||
from { transform: translateX(100%); }
|
||||
to { transform: translateX(0); }
|
||||
}
|
||||
|
||||
.modal-sm {
|
||||
width: 360px;
|
||||
}
|
||||
|
||||
@ -98,13 +98,14 @@
|
||||
<th class="col-key">键名</th>
|
||||
<th class="col-value">值</th>
|
||||
<th class="col-type">类型</th>
|
||||
<th class="col-status">状态</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>
|
||||
<td colspan="6">请在左侧选择节点查看配置</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@ -204,8 +205,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 模态框:新增/编辑配置项 -->
|
||||
<div class="modal-overlay" id="modal-config" style="display:none">
|
||||
<!-- 抽屉:新增/编辑/查看配置项(右侧滑出,占屏幕 68%) -->
|
||||
<div class="modal-overlay drawer-overlay" id="modal-config" style="display:none">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3 id="modal-config-title">新增配置项</h3>
|
||||
@ -236,6 +237,10 @@
|
||||
<label>值</label>
|
||||
<input type="text" id="config-value" placeholder="配置值">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>默认值 <span class="hint">(规划模式,编辑模式下值为空时展示回退)</span></label>
|
||||
<input type="text" id="config-default-value" placeholder="配置值为空时展示/回退的默认值">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group form-half">
|
||||
<label>类型</label>
|
||||
@ -248,7 +253,30 @@
|
||||
</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">
|
||||
<input type="text" id="config-value-range" placeholder="例如:1-100、枚举: A,B 或 /正则/">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" id="config-range-preset-group" style="display:none">
|
||||
<label>常见格式 <span class="hint">(string 类型:值范围按正则校验,选择后自动填入)</span></label>
|
||||
<select id="config-range-preset">
|
||||
<option value="">— 选择常见格式 —</option>
|
||||
<option value="phone">📱 手机号(中国大陆 11 位)</option>
|
||||
<option value="email">✉️ 邮箱</option>
|
||||
<option value="idcard">🪪 身份证(18 位)</option>
|
||||
<option value="ipv4">🌐 IPv4 地址</option>
|
||||
<option value="url">🔗 URL 链接</option>
|
||||
<option value="port">🔌 端口号(1-65535)</option>
|
||||
<option value="alphanumeric">🔤 字母数字</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group form-half">
|
||||
<label>是否必须 <span class="hint">(规划模式)</span></label>
|
||||
<label class="checkbox-label"><input type="checkbox" id="config-required"> 编辑模式下必须填写值</label>
|
||||
</div>
|
||||
<div class="form-group form-half">
|
||||
<label>是否存在 <span class="hint">(编辑模式)</span></label>
|
||||
<label class="checkbox-label"><input type="checkbox" id="config-exists" checked> 该配置项当前存在/生效</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
|
||||
111
public/js/app.js
111
public/js/app.js
@ -66,6 +66,20 @@ 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}
|
||||
@ -197,10 +211,16 @@ function renderDimensionBar() {
|
||||
return;
|
||||
}
|
||||
box.style.display = "";
|
||||
// 填充筛选下拉
|
||||
// 填充筛选下拉(用 DOM 构建避免属性上下文注入)
|
||||
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.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]] || "路径"}]`;
|
||||
@ -231,16 +251,28 @@ function renderConfigTable(configs, path) {
|
||||
tbody.innerHTML = "";
|
||||
|
||||
if (!configs || configs.length === 0) {
|
||||
tbody.innerHTML = '<tr class="empty-row"><td colspan="5">当前节点暂无配置项</td></tr>';
|
||||
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">${escapeHtml(String(cfg.value))}</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>
|
||||
@ -252,6 +284,24 @@ function renderConfigTable(configs, path) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建表格值列 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}
|
||||
@ -287,16 +337,36 @@ function openConfigModal(config, readonly) {
|
||||
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";
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据模式和是否只读查看,设置配置模态框各字段的可编辑性
|
||||
* 规划模式:结构字段(类型/说明/用途/范围/键名/维度)可改,值不可改
|
||||
@ -307,16 +377,21 @@ function openConfigModal(config, readonly) {
|
||||
*/
|
||||
function applyFieldPermissions(readonly) {
|
||||
const structFields = [
|
||||
"config-key", "config-type", "config-value-range",
|
||||
"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") {
|
||||
@ -328,14 +403,18 @@ function applyFieldPermissions(readonly) {
|
||||
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;
|
||||
@ -343,6 +422,8 @@ function applyFieldPermissions(readonly) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -359,6 +440,9 @@ function collectConfigForm() {
|
||||
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(),
|
||||
};
|
||||
@ -632,6 +716,19 @@ function bindEvents() {
|
||||
});
|
||||
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;
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
*/
|
||||
const { loadConfigs, saveConfigs } = require("../persistence/jsonAdapter");
|
||||
const { getProjectById } = require("./projectService");
|
||||
const { validateByType, validateRange } = require("../utils/validator");
|
||||
const { validateByType, validateRange, VALID_TYPES } = require("../utils/validator");
|
||||
|
||||
/**
|
||||
* 生成唯一 ID
|
||||
@ -106,6 +106,9 @@ function getConfigById(projectId, configId) {
|
||||
* @param {string} data.group - 分组(维度启用时必填)
|
||||
* @param {string} data.purpose - 用途说明(规划模式)
|
||||
* @param {string} data.valueRange - 值范围(规划模式)
|
||||
* @param {boolean} data.required - 是否必须(规划模式,编辑模式下必填校验)
|
||||
* @param {boolean} data.exists - 是否存在(编辑模式,false 视为该配置项当前不存在)
|
||||
* @param {any} data.defaultValue - 默认值(规划模式,编辑模式下值为空时展示回退)
|
||||
* @param {string} mode - 当前模式(plan/edit/readonly)
|
||||
* @returns {Object} 创建的配置项
|
||||
*/
|
||||
@ -120,29 +123,6 @@ function createConfig(projectId, data, mode) {
|
||||
}
|
||||
|
||||
const key = normalizeKey(data.key);
|
||||
if (key === "") {
|
||||
throw new Error("配置键名不能为空");
|
||||
}
|
||||
|
||||
if (!data.description || data.description.trim() === "") {
|
||||
throw new Error("配置说明不能为空");
|
||||
}
|
||||
|
||||
// 校验维度必填
|
||||
if (project.enableIdc && (!data.idc || data.idc.trim() === "")) {
|
||||
throw new Error("项目启用了机房维度,机房不能为空");
|
||||
}
|
||||
if (project.enableEnvironment && (!data.environment || data.environment.trim() === "")) {
|
||||
throw new Error("项目启用了环境维度,环境不能为空");
|
||||
}
|
||||
if (project.enableGroup && (!data.group || data.group.trim() === "")) {
|
||||
throw new Error("项目启用了分组维度,分组不能为空");
|
||||
}
|
||||
|
||||
// 校验类型
|
||||
if (data.type && !["string", "number", "boolean", "json"].includes(data.type)) {
|
||||
throw new Error(`不支持的类型: ${data.type}`);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const config = {
|
||||
@ -151,9 +131,12 @@ function createConfig(projectId, data, mode) {
|
||||
key,
|
||||
value: data.value !== undefined ? data.value : "",
|
||||
type: data.type || "string",
|
||||
description: data.description.trim(),
|
||||
description: String(data.description ?? "").trim(),
|
||||
purpose: data.purpose || "",
|
||||
valueRange: data.valueRange || "",
|
||||
required: data.required === true,
|
||||
exists: data.exists !== false,
|
||||
defaultValue: data.defaultValue !== undefined ? data.defaultValue : "",
|
||||
idc: data.idc || "",
|
||||
environment: data.environment || "",
|
||||
group: data.group || "",
|
||||
@ -161,16 +144,12 @@ function createConfig(projectId, data, mode) {
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
// 编辑模式下校验值和范围
|
||||
// 结构校验(键名/类型/说明/维度必填)
|
||||
validateStructure(config, project);
|
||||
|
||||
// 编辑模式下校验必填、类型和范围
|
||||
if (mode === "edit") {
|
||||
const typeResult = validateByType(config.value, config.type);
|
||||
if (!typeResult.valid) {
|
||||
throw new Error(typeResult.message);
|
||||
}
|
||||
const rangeResult = validateRange(config.value, config.valueRange);
|
||||
if (!rangeResult.valid) {
|
||||
throw new Error(rangeResult.message);
|
||||
}
|
||||
validateEditValue(config);
|
||||
}
|
||||
|
||||
const configs = loadConfigs(projectId);
|
||||
@ -183,10 +162,84 @@ function createConfig(projectId, data, mode) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑模式下的值校验:必填(required + exists)、类型、范围
|
||||
* 规则:
|
||||
* - exists 为 false 时该配置项视为当前不存在,跳过全部值校验(允许值为空)
|
||||
* - required 为 true 且 exists 为 true 时,值不能为空
|
||||
* - 其余情况按类型与范围约束校验
|
||||
* @param {Object} config - 配置项(含 value/type/valueRange/required/exists)
|
||||
* @returns {void} 校验不通过时抛错
|
||||
*/
|
||||
function validateEditValue(config) {
|
||||
const exists = config.exists !== false;
|
||||
if (!exists) {
|
||||
return;
|
||||
}
|
||||
|
||||
const value = config.value;
|
||||
// 必填判断:字符串按去空白后是否为空;非字符串(json 数组/对象、number 0、boolean false)不算空
|
||||
const isEmpty =
|
||||
typeof value === "string" ? value.trim() === "" : value === undefined || value === null;
|
||||
if (config.required === true && isEmpty) {
|
||||
throw new Error(`配置 "${config.key}" 为必填项,值不能为空`);
|
||||
}
|
||||
|
||||
const typeResult = validateByType(value, config.type);
|
||||
if (!typeResult.valid) {
|
||||
throw new Error(typeResult.message);
|
||||
}
|
||||
const rangeResult = validateRange(value, config.valueRange);
|
||||
if (!rangeResult.valid) {
|
||||
throw new Error(rangeResult.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验配置项结构定义(键名/类型/说明/维度必填),创建与更新共用
|
||||
* @param {Object} config - 配置项对象
|
||||
* @param {Object} project - 项目对象
|
||||
* @returns {void} 校验不通过时抛错
|
||||
*/
|
||||
function validateStructure(config, project) {
|
||||
if (!config.key || config.key.trim() === "") {
|
||||
throw new Error("配置键名不能为空");
|
||||
}
|
||||
if (!VALID_TYPES.includes(config.type)) {
|
||||
throw new Error(`不支持的类型: ${config.type}`);
|
||||
}
|
||||
if (!config.description || config.description.trim() === "") {
|
||||
throw new Error("配置说明不能为空");
|
||||
}
|
||||
if (project.enableIdc && (!config.idc || config.idc.trim() === "")) {
|
||||
throw new Error("项目启用了机房维度,机房不能为空");
|
||||
}
|
||||
if (project.enableEnvironment && (!config.environment || config.environment.trim() === "")) {
|
||||
throw new Error("项目启用了环境维度,环境不能为空");
|
||||
}
|
||||
if (project.enableGroup && (!config.group || config.group.trim() === "")) {
|
||||
throw new Error("项目启用了分组维度,分组不能为空");
|
||||
}
|
||||
// 默认值(若填写)需符合值类型与值范围,避免编辑模式下回退展示非法数据
|
||||
const hasDefault =
|
||||
config.defaultValue !== undefined &&
|
||||
config.defaultValue !== null &&
|
||||
String(config.defaultValue).trim() !== "";
|
||||
if (hasDefault) {
|
||||
if (!validateByType(config.defaultValue, config.type).valid) {
|
||||
throw new Error(`默认值不符合类型 ${config.type} 的要求`);
|
||||
}
|
||||
const rangeResult = validateRange(config.defaultValue, config.valueRange);
|
||||
if (!rangeResult.valid) {
|
||||
throw new Error(`默认值校验失败:${rangeResult.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新配置项
|
||||
* 规划模式:可修改 type、description、purpose、valueRange
|
||||
* 编辑模式:可修改 value
|
||||
* 规划模式:可修改 type、description、purpose、valueRange、required
|
||||
* 编辑模式:可修改 value、exists
|
||||
* 只读模式:不可修改
|
||||
* @param {string} projectId - 项目 ID
|
||||
* @param {string} configId - 配置项 ID
|
||||
@ -215,31 +268,27 @@ function updateConfig(projectId, configId, data, mode) {
|
||||
if (mode === "plan") {
|
||||
// 规划模式:修改结构定义
|
||||
if (data.type !== undefined) config.type = data.type;
|
||||
if (data.description !== undefined) config.description = data.description;
|
||||
if (data.description !== undefined) config.description = String(data.description ?? "").trim();
|
||||
if (data.purpose !== undefined) config.purpose = data.purpose;
|
||||
if (data.valueRange !== undefined) config.valueRange = data.valueRange;
|
||||
if (data.required !== undefined) config.required = data.required === true;
|
||||
if (data.defaultValue !== undefined) config.defaultValue = data.defaultValue;
|
||||
if (data.key !== undefined) config.key = normalizeKey(data.key);
|
||||
if (data.idc !== undefined) config.idc = data.idc;
|
||||
if (data.environment !== undefined) config.environment = data.environment;
|
||||
if (data.group !== undefined) config.group = data.group;
|
||||
validateStructure(config, project);
|
||||
} else if (mode === "edit") {
|
||||
// 编辑模式:修改值
|
||||
if (data.value !== undefined) {
|
||||
const typeResult = validateByType(data.value, config.type);
|
||||
if (!typeResult.valid) {
|
||||
throw new Error(typeResult.message);
|
||||
}
|
||||
const rangeResult = validateRange(data.value, config.valueRange);
|
||||
if (!rangeResult.valid) {
|
||||
throw new Error(rangeResult.message);
|
||||
}
|
||||
config.value = data.value;
|
||||
}
|
||||
// 编辑模式:修改值与存在性
|
||||
if (data.value !== undefined) config.value = data.value;
|
||||
if (data.exists !== undefined) config.exists = data.exists !== false;
|
||||
validateEditValue(config);
|
||||
// 编辑模式下也可以修改 key 和维度(路径调整)
|
||||
if (data.key !== undefined) config.key = normalizeKey(data.key);
|
||||
if (data.idc !== undefined) config.idc = data.idc;
|
||||
if (data.environment !== undefined) config.environment = data.environment;
|
||||
if (data.group !== undefined) config.group = data.group;
|
||||
validateStructure(config, project);
|
||||
}
|
||||
|
||||
config.updatedAt = new Date().toISOString();
|
||||
@ -282,4 +331,6 @@ module.exports = {
|
||||
deleteConfig,
|
||||
buildConfigPath,
|
||||
normalizeKey,
|
||||
validateEditValue,
|
||||
validateStructure,
|
||||
};
|
||||
|
||||
@ -26,6 +26,9 @@ function validateByType(value, type) {
|
||||
}
|
||||
break;
|
||||
case "number": {
|
||||
if (value === null || value === undefined || String(value).trim() === "") {
|
||||
return { valid: false, message: "值必须是有效的数字" };
|
||||
}
|
||||
const num = Number(value);
|
||||
if (isNaN(num)) {
|
||||
return { valid: false, message: "值必须是有效的数字" };
|
||||
@ -50,7 +53,11 @@ function validateByType(value, type) {
|
||||
|
||||
/**
|
||||
* 校验值范围约束(仅在编辑模式下生效)
|
||||
* 支持简单的范围描述,如 "合法 IPv4 地址"、"1-100"、"枚举: A,B,C"
|
||||
* 支持:
|
||||
* - 正则约束(string 类型常用):"/正则/flags",如 /^1[3-9]\d{9}$/(手机号)
|
||||
* - 区间范围:"1-100" 或 "0.0-1.0"
|
||||
* - 枚举范围:"枚举: A,B,C"
|
||||
* - 描述性范围(如 "合法 IPv4 地址")仅作提示,不做强制校验
|
||||
* @param {any} value - 配置值
|
||||
* @param {string} valueRange - 值范围描述
|
||||
* @returns {{ valid: boolean, message: string }} 校验结果
|
||||
@ -62,11 +69,37 @@ function validateRange(value, valueRange) {
|
||||
|
||||
const range = valueRange.trim();
|
||||
|
||||
// 正则约束: "/pattern/flags",如 /^1[3-9]\d{9}$/(手机号)
|
||||
if (range.startsWith("/")) {
|
||||
// pattern 中未转义的 / 不允许(与 JS 正则字面量一致);flags 仅允许字母
|
||||
const regexMatch = range.match(/^\/((?:\\.|[^\\/])*)\/([a-z]*)$/);
|
||||
if (!regexMatch) {
|
||||
return { valid: false, message: `正则格式无效: ${range},正确格式为 /pattern/flags` };
|
||||
}
|
||||
const pattern = regexMatch[1];
|
||||
const flags = regexMatch[2] || "";
|
||||
let re;
|
||||
try {
|
||||
re = new RegExp(pattern, flags);
|
||||
} catch (e) {
|
||||
return { valid: false, message: `正则表达式无效: /${pattern}/` };
|
||||
}
|
||||
// 保留 g/y 标志语义:test 前重置 lastIndex,避免状态残留
|
||||
re.lastIndex = 0;
|
||||
if (!re.test(String(value))) {
|
||||
return { valid: false, message: `值不符合格式要求: /${pattern}/` };
|
||||
}
|
||||
return { valid: true, message: "" };
|
||||
}
|
||||
|
||||
// 区间范围: "1-100" 或 "0.0-1.0"
|
||||
const rangeMatch = range.match(/^(-?\d+(\.\d+)?)\s*-\s*(-?\d+(\.\d+)?)$/);
|
||||
if (rangeMatch) {
|
||||
const min = Number(rangeMatch[1]);
|
||||
const max = Number(rangeMatch[3]);
|
||||
if (min > max) {
|
||||
return { valid: false, message: `范围下限 ${min} 大于上限 ${max}` };
|
||||
}
|
||||
const num = Number(value);
|
||||
if (isNaN(num) || num < min || num > max) {
|
||||
return { valid: false, message: `值必须在 ${min} 到 ${max} 之间` };
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
*/
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { buildConfigPath, normalizeKey } = require("../src/services/configService");
|
||||
const { buildConfigPath, normalizeKey, validateEditValue, validateStructure } = require("../src/services/configService");
|
||||
|
||||
// 构造测试用项目对象的辅助函数
|
||||
function makeProject(overrides) {
|
||||
@ -55,3 +55,91 @@ test("2-2 规整键:压缩连续斜杠并去除空段", () => {
|
||||
test("2-3 规整键:空输入返回空字符串", () => {
|
||||
assert.strictEqual(normalizeKey(" "), "");
|
||||
});
|
||||
|
||||
test("3-1 必填且存在:空值校验失败", () => {
|
||||
const cfg = { key: "a", value: " ", type: "string", valueRange: "", required: true, exists: true };
|
||||
assert.throws(() => validateEditValue(cfg), /必填项/);
|
||||
});
|
||||
|
||||
test("3-2 必填且存在:非空值通过", () => {
|
||||
const cfg = { key: "a", value: "x", type: "string", valueRange: "", required: true, exists: true };
|
||||
assert.doesNotThrow(() => validateEditValue(cfg));
|
||||
});
|
||||
|
||||
test("3-3 必填但不存在:允许空值", () => {
|
||||
const cfg = { key: "a", value: "", type: "string", valueRange: "", required: true, exists: false };
|
||||
assert.doesNotThrow(() => validateEditValue(cfg));
|
||||
});
|
||||
|
||||
test("3-4 不存在:跳过类型与范围校验", () => {
|
||||
const cfg = { key: "a", value: "not-number", type: "number", valueRange: "", required: false, exists: false };
|
||||
assert.doesNotThrow(() => validateEditValue(cfg));
|
||||
});
|
||||
|
||||
test("3-5 存在且值类型错误:校验失败", () => {
|
||||
const cfg = { key: "a", value: "abc", type: "number", valueRange: "", required: false, exists: true };
|
||||
assert.throws(() => validateEditValue(cfg), /有效的数字/);
|
||||
});
|
||||
|
||||
test("3-6 存在且不符合正则范围:校验失败", () => {
|
||||
const cfg = { key: "phone", value: "123", type: "string", valueRange: "/^1[3-9]\\d{9}$/", required: false, exists: true };
|
||||
assert.throws(() => validateEditValue(cfg), /格式要求/);
|
||||
});
|
||||
|
||||
test("3-7 必填且 json 值为空数组:不算空值", () => {
|
||||
const cfg = { key: "a", value: [], type: "json", valueRange: "", required: true, exists: true };
|
||||
assert.doesNotThrow(() => validateEditValue(cfg));
|
||||
});
|
||||
|
||||
test("3-8 必填且值为 0 / false:不算空值", () => {
|
||||
assert.doesNotThrow(() => validateEditValue({ key: "a", value: 0, type: "number", valueRange: "", required: true, exists: true }));
|
||||
assert.doesNotThrow(() => validateEditValue({ key: "a", value: false, type: "boolean", valueRange: "", required: true, exists: true }));
|
||||
});
|
||||
|
||||
test("4-1 默认值类型校验:number 类型默认值必须为数字", () => {
|
||||
const project = makeProject({});
|
||||
const base = { key: "a", type: "number", description: "d", idc: "x", environment: "y", group: "z" };
|
||||
assert.throws(() => validateStructure({ ...base, defaultValue: "abc" }, project), /默认值不符合类型/);
|
||||
assert.doesNotThrow(() => validateStructure({ ...base, defaultValue: "42" }, project));
|
||||
assert.doesNotThrow(() => validateStructure({ ...base, defaultValue: 0 }, project));
|
||||
});
|
||||
|
||||
test("4-2 默认值类型校验:空默认值不做校验", () => {
|
||||
const project = makeProject({});
|
||||
const base = { key: "a", type: "number", description: "d", idc: "x", environment: "y", group: "z" };
|
||||
assert.doesNotThrow(() => validateStructure({ ...base, defaultValue: "" }, project));
|
||||
assert.doesNotThrow(() => validateStructure({ ...base }, project));
|
||||
});
|
||||
|
||||
test("4-3 默认值类型校验:json 类型默认值需为合法 JSON", () => {
|
||||
const project = makeProject({});
|
||||
const base = { key: "a", type: "json", description: "d", idc: "x", environment: "y", group: "z" };
|
||||
assert.doesNotThrow(() => validateStructure({ ...base, defaultValue: "{\"a\":1}" }, project));
|
||||
assert.throws(() => validateStructure({ ...base, defaultValue: "{bad json}" }, project), /默认值不符合类型/);
|
||||
});
|
||||
|
||||
test("4-4 默认值范围校验:默认值需符合值范围约束", () => {
|
||||
const project = makeProject({});
|
||||
const base = { key: "a", type: "string", description: "d", idc: "x", environment: "y", group: "z", valueRange: "/^1[3-9]\\d{9}$/" };
|
||||
assert.doesNotThrow(() => validateStructure({ ...base, defaultValue: "13800138000" }, project));
|
||||
assert.throws(() => validateStructure({ ...base, defaultValue: "12345" }, project), /默认值校验失败/);
|
||||
});
|
||||
|
||||
test("4-5 默认值范围校验:number 区间默认值", () => {
|
||||
const project = makeProject({});
|
||||
const base = { key: "a", type: "number", description: "d", idc: "x", environment: "y", group: "z", valueRange: "1-100" };
|
||||
assert.doesNotThrow(() => validateStructure({ ...base, defaultValue: "50" }, project));
|
||||
assert.throws(() => validateStructure({ ...base, defaultValue: "500" }, project), /默认值校验失败/);
|
||||
});
|
||||
|
||||
test("4-6 默认值类型校验:boolean false 为合法默认值", () => {
|
||||
const project = makeProject({});
|
||||
const base = { key: "a", type: "boolean", description: "d", idc: "x", environment: "y", group: "z" };
|
||||
assert.doesNotThrow(() => validateStructure({ ...base, defaultValue: false }, project));
|
||||
});
|
||||
|
||||
test("4-7 默认值判断:纯空白默认值视为未填写", () => {
|
||||
const project = makeProject({});
|
||||
const base = { key: "a", type: "number", description: "d", idc: "x", environment: "y", group: "z" };
|
||||
assert.doesNotThrow(() => validateStructure({ ...base, defaultValue: " " }, project));
|
||||
});
|
||||
|
||||
110
test/validator.test.js
Normal file
110
test/validator.test.js
Normal file
@ -0,0 +1,110 @@
|
||||
/**
|
||||
* 配置值校验器单元测试
|
||||
* 覆盖正则范围约束(/正则/ 格式)、既有区间/枚举约束
|
||||
* 运行:npm test
|
||||
*/
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { validateByType, validateRange } = require("../src/utils/validator");
|
||||
|
||||
test("1-1 正则范围:手机号格式通过", () => {
|
||||
const range = "/^1[3-9]\\d{9}$/";
|
||||
assert.strictEqual(validateRange("13800138000", range).valid, true);
|
||||
});
|
||||
|
||||
test("1-2 正则范围:手机号格式不通过", () => {
|
||||
const range = "/^1[3-9]\\d{9}$/";
|
||||
assert.strictEqual(validateRange("2380013800", range).valid, false);
|
||||
});
|
||||
|
||||
test("1-3 正则范围:邮箱格式", () => {
|
||||
const range = "/^[\\w.%+-]+@[\\w.-]+\\.[A-Za-z]{2,}$/";
|
||||
assert.strictEqual(validateRange("user@example.com", range).valid, true);
|
||||
assert.strictEqual(validateRange("not-an-email", range).valid, false);
|
||||
});
|
||||
|
||||
test("1-4 正则范围:身份证(18 位)", () => {
|
||||
const range = "/^\\d{17}[\\dXx]$/";
|
||||
assert.strictEqual(validateRange("110101199003071234", range).valid, true);
|
||||
assert.strictEqual(validateRange("11010119900307", range).valid, false);
|
||||
});
|
||||
|
||||
test("1-5 正则范围:支持修饰符(i 忽略大小写)", () => {
|
||||
const range = "/^[A-Z]+$/i";
|
||||
assert.strictEqual(validateRange("abc", range).valid, true);
|
||||
assert.strictEqual(validateRange("abc1", range).valid, false);
|
||||
});
|
||||
|
||||
test("1-6 正则范围:无效正则返回错误提示", () => {
|
||||
const res = validateRange("abc", "/[/");
|
||||
assert.strictEqual(res.valid, false);
|
||||
assert.ok(res.message.includes("正则表达式无效"));
|
||||
});
|
||||
|
||||
test("1-7 正则格式:缺少结尾斜杠明确报格式错误(不静默放行)", () => {
|
||||
const res = validateRange("abc", "/abc");
|
||||
assert.strictEqual(res.valid, false);
|
||||
assert.ok(res.message.includes("正则格式无效"));
|
||||
});
|
||||
|
||||
test("1-8 正则格式:pattern 中未转义的斜杠报格式错误", () => {
|
||||
const res = validateRange("a/b", "/a/b/i");
|
||||
assert.strictEqual(res.valid, false);
|
||||
assert.ok(res.message.includes("正则格式无效"));
|
||||
});
|
||||
|
||||
test("1-9 正则格式:转义斜杠可用", () => {
|
||||
const res = validateRange("a/b", "/a\\/b/");
|
||||
assert.strictEqual(res.valid, true);
|
||||
});
|
||||
|
||||
test("1-10 正则范围:g 标志 test 多次结果稳定", () => {
|
||||
const range = "/^abc$/g";
|
||||
assert.strictEqual(validateRange("abc", range).valid, true);
|
||||
assert.strictEqual(validateRange("abc", range).valid, true);
|
||||
});
|
||||
|
||||
test("1-11 空值范围不做校验", () => {
|
||||
assert.strictEqual(validateRange("anything", "").valid, true);
|
||||
assert.strictEqual(validateRange("anything", " ").valid, true);
|
||||
});
|
||||
|
||||
test("1-12 正则格式:URL 预设(含转义斜杠)可正常校验", () => {
|
||||
const range = "/^https?:\\/\\/[\\w.-]+(?:\\.[a-zA-Z]{2,})?(?:\\/[^\\s]*)?$/";
|
||||
assert.strictEqual(validateRange("https://example.com/path", range).valid, true);
|
||||
assert.strictEqual(validateRange("not a url", range).valid, false);
|
||||
});
|
||||
|
||||
test("1-13 正则格式:非法 flags 报错", () => {
|
||||
const res = validateRange("abc", "/^abc$/q");
|
||||
assert.strictEqual(res.valid, false);
|
||||
assert.ok(res.message.includes("正则表达式无效") || res.message.includes("正则格式无效"));
|
||||
});
|
||||
|
||||
test("3-3 类型校验:number 拒绝 null", () => {
|
||||
assert.strictEqual(validateByType(null, "number").valid, false);
|
||||
});
|
||||
|
||||
test("2-1 区间范围仍生效", () => {
|
||||
assert.strictEqual(validateRange("50", "1-100").valid, true);
|
||||
assert.strictEqual(validateRange("150", "1-100").valid, false);
|
||||
});
|
||||
|
||||
test("2-2 区间下限大于上限时报错", () => {
|
||||
const res = validateRange("5", "10-1");
|
||||
assert.strictEqual(res.valid, false);
|
||||
assert.ok(res.message.includes("大于上限"));
|
||||
});
|
||||
|
||||
test("2-3 枚举范围仍生效", () => {
|
||||
assert.strictEqual(validateRange("A", "枚举: A,B,C").valid, true);
|
||||
assert.strictEqual(validateRange("D", "枚举: A,B,C").valid, false);
|
||||
});
|
||||
|
||||
test("3-1 类型校验:number 拒绝非数字", () => {
|
||||
assert.strictEqual(validateByType("abc", "number").valid, false);
|
||||
});
|
||||
|
||||
test("3-2 类型校验:number 拒绝空串(不再当作 0 放行)", () => {
|
||||
assert.strictEqual(validateByType("", "number").valid, false);
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user