fix
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 31s

This commit is contained in:
Cheney 2026-08-07 14:46:52 +08:00
parent e3e5d5bd8f
commit f9f3184f98
9 changed files with 572 additions and 70 deletions

1
.gitignore vendored
View File

@ -3,3 +3,4 @@ data/projects.json
data/system.json
data/configs/*.json
*.log
.reasonix/*

View File

@ -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. 非功能需求

View File

@ -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;
}

View File

@ -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">

View File

@ -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;

View File

@ -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}`);
}
}
}
/**
* 更新配置项
* 规划模式可修改 typedescriptionpurposevalueRange
* 编辑模式可修改 value
* 规划模式可修改 typedescriptionpurposevalueRangerequired
* 编辑模式可修改 valueexists
* 只读模式不可修改
* @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,
};

View File

@ -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} 之间` };

View File

@ -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
View 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);
});