commit 5bff1a186fb8b560b0aacaeb3285829045630497 Author: cheney Date: Thu Jul 16 09:01:47 2026 +0800 feat: 维度映射/嵌套配置/地址栏导航/界面分区重构 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0cf0453 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +data/projects.json +data/system.json +data/configs/*.json +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..90fc2a5 --- /dev/null +++ b/README.md @@ -0,0 +1,49 @@ +# Registry - 微服务配置管理中心 + +基于 Web 的注册表风格微服务配置管理系统。以项目为单位管理配置,支持机房、环境、分组三个维度划分,提供规划、编辑、只读三种工作模式。 + +## 快速开始 + +```bash +npm install +npm start +``` + +启动后访问 http://localhost:3000 + +## 功能概览 + +- **项目管理**:以项目为单位隔离配置;维度开关(机房/环境/分组)创建时确定,后续不可修改。 +- **注册表风格界面**:左侧树形导航(项目 → 机房 → 环境 → 分组 → 配置),右侧键值详情表格。 +- **配置项**:路径形式的 key,value 支持 string/number/boolean/json 类型,每项必须有说明。 +- **三种模式**: + - 规划模式:定义配置结构(类型、说明、用途、值范围)。 + - 编辑模式:填写/修改配置值,含类型与范围校验。 + - 只读模式:仅查看,禁止修改。 +- **持久化**:当前实现 JSON 文件方案,支持导出下载与导入合并。持久化层已抽象,后续可扩展 XML / YAML / SQLite / MySQL / Nacos。 + +## 文档 + +- 需求文档:`docs/requirements.md` +- 概要设计文档:`docs/design.md` + +## 目录结构 + +``` +registry/ +├── docs/ 需求与设计文档 +├── data/ JSON 持久化数据 +├── src/ 后端源码(Express) +│ ├── server.js +│ ├── routes/ 项目与配置路由 +│ ├── services/ 业务逻辑 +│ ├── persistence/ JSON 持久化适配器 +│ └── utils/ 配置值校验 +└── public/ 前端静态资源 +``` + +## 后续规划 + +- 对接 SSO 单点登录(当前版本无认证)。 +- 扩展更多持久化方案(数据库、Nacos、SSH 远程配置文件)。 +- 项目维度转换机制。 diff --git a/docs/设计.md b/docs/设计.md new file mode 100644 index 0000000..6a6803c --- /dev/null +++ b/docs/设计.md @@ -0,0 +1,215 @@ +# 概要设计文档:微服务配置管理中心 (Registry) + +## 1. 系统架构 + +```mermaid +flowchart TD + A[浏览器 Browser] --> B[Express Web Server] + B --> C[路由层 Router] + C --> D[项目模块 Project Service] + C --> E[配置模块 Config Service] + D --> F[JSON 持久化层 Persistence] + E --> F + F --> G[(JSON 文件存储)] +``` + +## 2. 技术选型 + +| 层级 | 技术 | 说明 | +|------|------|------| +| 后端框架 | Express 4.x | 轻量级 HTTP 服务 | +| 前端 | 原生 HTML/CSS/JS | 无框架依赖,降低复杂度 | +| 持久化 | JSON 文件 | 当前实现,简单可靠 | +| 模板引擎 | EJS | 服务端渲染基础页面 | +| 图标 | 内联 SVG | 不依赖第三方图标库 | + +## 3. 数据模型 + +### 3.1 项目 (Project) + +```json +{ + "id": "proj-001", + "name": "用户服务", + "description": "用户微服务配置", + "enableIdc": true, + "enableEnvironment": true, + "enableGroup": true, + "idcMapping": "path", + "environmentMapping": "path", + "groupMapping": "label", + "createdAt": "2026-07-15T10:00:00Z", + "updatedAt": "2026-07-15T10:00:00Z" +} +``` + +字段说明: +- `enableXxx`:维度是否启用,创建后不可修改。 +- `xxxMapping`:维度映射方式,`path` 表示作为配置存储路径前缀,`label` 表示仅作筛选标签、不进入路径。创建后可通过映射配置接口调整。 + +### 3.2 配置项 (ConfigItem) + +```json +{ + "id": "cfg-001", + "projectId": "proj-001", + "path": "华北/dev/用户模块/db.host", + "key": "db.host", + "value": "192.168.1.100", + "type": "string", + "description": "数据库主机地址", + "purpose": "指定 MySQL 数据库连接地址", + "valueRange": "合法 IPv4 地址", + "createdAt": "2026-07-15T10:00:00Z", + "updatedAt": "2026-07-15T10:00:00Z" +} +``` + +### 3.3 配置路径构建规则 + +配置路径由“维度映射方式”与“嵌套键”共同决定: + +``` +路径模板 = [映射为 path 的维度前缀...]/嵌套键 + +- 维度启用且映射为 path → 该维度值作为路径前缀 +- 维度启用且映射为 label → 该维度不进入路径,仅作维度栏筛选 +- 维度未启用 → 跳过 +- 配置键 key 自身可用 / 嵌套多级(json 类型在展示时按 . 分割) +``` + +示例(机房=path、环境=path、分组=label): +- 配置键 `db/host`,机房 `华北`,环境 `dev`,分组 `用户模块` +- 生成路径:`华北/dev/db/host`(分组作为标签筛选,不进入路径) + +示例(全部映射为 path): +- 配置键 `db.host` → 路径 `华北/dev/用户模块/db.host` + +## 4. API 设计 + +### 4.1 项目接口 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/projects` | 获取项目列表 | +| GET | `/api/projects/:id` | 获取项目详情 | +| POST | `/api/projects` | 创建项目(含维度开关与映射方式) | +| PUT | `/api/projects/:id/mappings` | 更新维度映射方式(path/label) | +| DELETE | `/api/projects/:id` | 删除项目 | + +### 4.2 配置接口 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/projects/:id/configs` | 获取项目下所有配置 | +| GET | `/api/projects/:id/configs/:configId` | 获取单个配置详情 | +| POST | `/api/projects/:id/configs` | 新增配置项 | +| PUT | `/api/projects/:id/configs/:configId` | 修改配置项 | +| DELETE | `/api/projects/:id/configs/:configId` | 删除配置项 | + +### 4.3 模式接口 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/mode` | 获取当前模式 | +| PUT | `/api/mode` | 切换模式(plan/edit/readonly) | + +## 5. 前端界面设计 + +### 5.1 布局结构 + +``` ++--------------------------------------------------+ +| 标题栏:应用名 | 当前项目名 | ++--------------------------------------------------+ +| 工具栏(分组):项目 | 模式 | 配置 | 数据 | ++--------------------------------------------------+ +| 维度栏:机房▼ | 环境▼ | 分组▼ | ⚙映射配置 | ++------------+-------------------------------------+ +| 树形导航 | 地址栏:📍 路径 [转到][拷贝] | +| (左侧) | +-------------------------------+ | +| 📦 项目A | | 键名 | 值 | 类型 | 说明 | | +| 📁 华北 | |------+----+------+----------| | +| 📁 dev | | db/host|192.|string|数据库.. | | +| 📁 db | | db/port|3306|number|数据库.. | | +| 🔑host| +-------------------------------+ | ++------------+-------------------------------------+ +| 状态栏 | ++--------------------------------------------------+ +``` + +界面分区说明: +- 标题栏:仅展示应用名与当前项目名,与操作区分离。 +- 工具栏:按“项目 / 模式 / 配置 / 数据”分组排列操作按钮。 +- 维度栏:展示已启用维度的筛选下拉与映射方式标记,提供映射配置入口。 +- 地址栏:注册表风格路径框,支持拷贝、粘贴后回车跳转到对应节点。 +- 树形导航:按“path 维度前缀 + 嵌套键”逐级展开,叶子节点(🔑)为真实配置项。 + +### 5.2 三种模式差异化 + +| 功能 | 规划模式 | 编辑模式 | 只读模式 | +|------|---------|---------|---------| +| 查看配置 | ✓ | ✓ | ✓ | +| 新增/删除配置 | ✓ | ✓ | ✗ | +| 修改值 | ✗ | ✓ | ✗ | +| 修改类型/说明/用途/范围 | ✓ | ✗ | ✗ | +| 配置值校验 | 仅格式 | 格式+范围 | 不校验 | + +## 6. 持久化层设计 + +### 6.1 存储结构 + +``` +data/ +├── projects.json # 项目元数据 +├── configs/ +│ └── {projectId}.json # 每个项目独立配置文件 +└── system.json # 系统级配置(当前模式等) +``` + +### 6.2 持久化接口(预留扩展) + +```javascript +class PersistenceAdapter { + async loadProjects() {} + async saveProjects(projects) {} + async loadConfigs(projectId) {} + async saveConfigs(projectId, configs) {} + async loadSystem() {} + async saveSystem(system) {} +} +``` + +当前实现 `JsonFileAdapter`,后续可扩展 `SqliteAdapter`、`NacosAdapter` 等。 + +## 7. 目录结构 + +``` +registry/ +├── docs/ +│ ├── requirements.md # 需求文档 +│ └── design.md # 概要设计文档 +├── data/ # 持久化数据目录 +├── src/ +│ ├── server.js # 服务入口 +│ ├── routes/ +│ │ ├── projects.js # 项目路由 +│ │ └── configs.js # 配置路由 +│ ├── services/ +│ │ ├── projectService.js +│ │ └── configService.js +│ ├── persistence/ +│ │ └── jsonAdapter.js # JSON 持久化适配器 +│ └── utils/ +│ └── validator.js # 配置值校验 +├── public/ +│ ├── index.html # 主页面 +│ ├── css/ +│ │ └── style.css # 样式 +│ └── js/ +│ ├── app.js # 主逻辑 +│ ├── tree.js # 树形组件 +│ └── api.js # API 调用封装 +├── package.json +└── README.md +``` diff --git a/docs/需求.md b/docs/需求.md new file mode 100644 index 0000000..54c3ddc --- /dev/null +++ b/docs/需求.md @@ -0,0 +1,74 @@ +# 需求文档:微服务配置管理中心 (Registry) + +## 1. 项目背景 + +在微服务架构中,配置管理是核心基础设施之一。不同项目、不同环境、不同机房的配置散落在各处,缺乏统一的管理界面和规范。本系统旨在提供一个类 Windows 注册表风格的 Web 管理界面,实现配置的集中化、可视化、规范化管理。 + +## 2. 功能需求 + +### 2.1 项目管理 + +- **FR-1-1**:支持创建项目,项目创建时需指定项目名称、描述、以及是否启用机房/环境/分组维度 +- **FR-1-2**:项目创建后,维度开关(机房/环境/分组)不可修改,只能通过项目转换机制处理 +- **FR-1-3**:支持查看项目列表、项目详情 +- **FR-1-4**:支持删除项目(级联删除所有关联配置) + +### 2.2 配置维度 + +- **FR-2-1**:机房维度——用于隔离不同物理/云区域(如华北机房、华南机房) +- **FR-2-2**:环境维度——用于隔离不同运行阶段(如开发、测试、生产) +- **FR-2-3**:分组维度——用于项目内业务分组(如用户模块、订单模块) +- **FR-2-4**:维度启用后可在维度栏直接展示与筛选,不直接硬编码进配置路径 +- **FR-2-5**:每个维度可配置映射方式——`path`(作为配置存储路径前缀)或 `label`(仅作筛选标签,不进入路径),默认 `path` +- **FR-2-6**:映射方式创建后可通过映射配置入口调整(维度启用开关仍不可改) + +### 2.3 配置项管理 + +- **FR-3-1**:配置以路径形式组织,类似注册表键值结构;路径 = 映射为 path 的维度前缀 + 嵌套键 +- **FR-3-6**:配置键支持嵌套,以 `/` 分割;json 类型在展示时自动按 `.` 分割 +- **FR-3-7**:提供配置导航地址栏,显示当前路径,可拷贝、可粘贴后回车跳转到对应目录(类似 Windows 注册表地址栏) +- **FR-3-2**:每个配置项支持类型指定:string、number、boolean、json +- **FR-3-3**:每个配置项必须有说明(description),用于文档化配置用途 +- **FR-3-4**:支持配置项的新增、修改、删除 +- **FR-3-5**:配置值根据类型进行格式校验 + +### 2.4 三种工作模式 + +- **FR-4-1**:规划模式——定义配置结构,包括字段名、类型、用途、值范围约束,类似"配置 Schema 设计" +- **FR-4-2**:编辑模式——在规划好的结构基础上填写实际配置值 +- **FR-4-3**:只读模式——仅可查看配置,不可修改,用于日常查阅和审计 + +### 2.5 持久化方案 + +- **FR-5-1**:支持 JSON 文件持久化(当前版本实现) +- **FR-5-2**:预留扩展接口,后续支持 XML、YAML、SQLite、MySQL、Nacos +- **FR-5-3**:配置文件支持导出下载 +- **FR-5-4**:配置文件支持导入合并 + +### 2.6 界面要求 + +- **FR-6-1**:左侧树形导航,按“path 维度前缀 + 嵌套键”逐级展开,视觉风格参考 Windows 注册表编辑器 +- **FR-6-2**:右侧详情面板,顶部为配置导航地址栏,下方展示选中节点的配置项列表 +- **FR-6-3**:每个配置项展示:键名、值、类型、说明 +- **FR-6-4**:标题栏(应用名+当前项目)与工具栏分离;工具栏按项目/模式/配置/数据分组 +- **FR-6-5**:维度栏独立展示已启用维度的筛选器与映射方式,并提供映射配置入口 + +## 3. 非功能需求 + +- **NFR-1**:技术栈:Node.js + Express 后端,原生 HTML/CSS/JS 前端 +- **NFR-2**:后续对接 SSO 单点登录,当前版本暂不实现认证 +- **NFR-3**:界面响应式,支持主流浏览器 +- **NFR-4**:配置数据实时写入持久化存储,保证数据一致性 + +## 4. 术语定义 + +| 术语 | 说明 | +|------|------| +| 项目 (Project) | 配置管理的顶层隔离单元,对应一个微服务或应用 | +| 机房 (IDC) | 物理或云区域标识,如华北、华南、华东 | +| 环境 (Environment) | 运行阶段标识,如 dev、test、prod | +| 分组 (Group) | 业务模块分组,如 user、order、payment | +| 配置路径 (Path) | 注册表风格的层级路径,如 `project/idc/env/group/key` | +| 规划模式 | Schema 设计模式,定义配置结构 | +| 编辑模式 | 配置值填写模式 | +| 只读模式 | 查看模式,禁止修改 | diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..7e13aa3 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,829 @@ +{ + "name": "microservice-registry", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "microservice-registry", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "express": "^4.18.2" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmmirror.com/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.6", + "resolved": "https://registry.npmmirror.com/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmmirror.com/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmmirror.com/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmmirror.com/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmmirror.com/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmmirror.com/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmmirror.com/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmmirror.com/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmmirror.com/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmmirror.com/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmmirror.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmmirror.com/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmmirror.com/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmmirror.com/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmmirror.com/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmmirror.com/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmmirror.com/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmmirror.com/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmmirror.com/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmmirror.com/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmmirror.com/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..cec18ae --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "microservice-registry", + "version": "1.0.0", + "description": "微服务配置管理中心 - 基于 Web 的注册表风格配置管理", + "main": "src/server.js", + "scripts": { + "start": "node src/server.js", + "dev": "node src/server.js", + "test": "node --test" + }, + "keywords": ["config", "registry", "microservice"], + "license": "MIT", + "dependencies": { + "express": "^4.18.2" + } +} diff --git a/public/css/style.css b/public/css/style.css new file mode 100644 index 0000000..eeb6bf2 --- /dev/null +++ b/public/css/style.css @@ -0,0 +1,695 @@ +/* Registry 配置管理中心样式 - 参考 Windows 注册表编辑器风格 */ + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: "Segoe UI", "Microsoft YaHei", sans-serif; + font-size: 13px; + color: #1e1e1e; + background: #f0f0f0; + height: 100vh; + display: flex; + flex-direction: column; + overflow: hidden; +} + +/* 顶部工具栏 */ +.toolbar { + display: flex; + align-items: center; + justify-content: space-between; + height: 44px; + padding: 0 12px; + background: linear-gradient(to bottom, #fdfdfd, #e8e8e8); + border-bottom: 1px solid #c0c0c0; + flex-shrink: 0; +} + +.toolbar-left, +.toolbar-center, +.toolbar-right { + display: flex; + align-items: center; + gap: 8px; +} + +.app-title { + font-weight: 600; + font-size: 14px; + color: #003a70; +} + +.toolbar-select { + height: 28px; + padding: 0 8px; + border: 1px solid #adadad; + border-radius: 3px; + background: #fff; + font-size: 13px; + min-width: 180px; +} + +.toolbar-btn { + height: 28px; + padding: 0 10px; + border: 1px solid #adadad; + border-radius: 3px; + background: linear-gradient(to bottom, #fdfdfd, #e8e8e8); + cursor: pointer; + font-size: 12px; +} + +.toolbar-btn:hover { + background: linear-gradient(to bottom, #eaf3fb, #d6e9f9); + border-color: #5b9bd5; +} + +.mode-label { + color: #555; +} + +.mode-switcher { + display: flex; + border: 1px solid #adadad; + border-radius: 3px; + overflow: hidden; +} + +.mode-btn { + height: 28px; + padding: 0 12px; + border: none; + border-right: 1px solid #adadad; + background: #fff; + cursor: pointer; + font-size: 12px; +} + +.mode-btn:last-child { + border-right: none; +} + +.mode-btn.active { + background: #0078d4; + color: #fff; + font-weight: 600; +} + +.mode-btn:not(.active):hover { + background: #e5f1fb; +} + +/* 主内容区 */ +.main-container { + flex: 1; + display: flex; + overflow: hidden; +} + +/* 左侧树形导航 */ +.tree-panel { + width: 320px; + min-width: 180px; + background: #fff; + border-right: 1px solid #c0c0c0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.tree-header { + display: flex; + align-items: center; + justify-content: space-between; + height: 30px; + padding: 0 10px; + background: #f5f5f5; + border-bottom: 1px solid #e0e0e0; + font-weight: 600; + color: #555; +} + +.icon-btn { + border: none; + background: transparent; + cursor: pointer; + font-size: 14px; + color: #666; +} + +.tree-content { + flex: 1; + overflow: auto; + padding: 4px 0; +} + +.tree-empty { + padding: 20px; + text-align: center; + color: #999; +} + +/* 树节点 */ +.tree-node { + user-select: none; +} + +.tree-node-label { + display: flex; + align-items: center; + height: 24px; + padding: 0 4px; + cursor: pointer; + white-space: nowrap; +} + +.tree-node-label:hover { + background: #e5f1fb; +} + +.tree-node-label.selected { + background: #cce8ff; +} + +.tree-toggle { + width: 16px; + display: inline-block; + text-align: center; + color: #666; + font-size: 10px; +} + +.tree-icon { + margin-right: 4px; +} + +.tree-children { + padding-left: 16px; +} + +.tree-children.collapsed { + display: none; +} + +/* 分隔条 */ +.resizer { + width: 4px; + background: #e0e0e0; + cursor: col-resize; + flex-shrink: 0; +} + +.resizer:hover { + background: #5b9bd5; +} + +/* 右侧详情面板 */ +.detail-panel { + flex: 1; + display: flex; + flex-direction: column; + background: #fff; + overflow: hidden; +} + +.detail-header { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 34px; + padding: 4px 12px; + background: #f5f5f5; + border-bottom: 1px solid #e0e0e0; +} + +#detail-path { + font-family: Consolas, monospace; + color: #003a70; + font-size: 12px; +} + +.detail-actions { + display: flex; + gap: 6px; +} + +.detail-table-wrapper { + flex: 1; + overflow: auto; +} + +/* 配置表格 */ +.config-table { + width: 100%; + border-collapse: collapse; +} + +.config-table th { + position: sticky; + top: 0; + background: #f0f0f0; + border-bottom: 1px solid #c0c0c0; + border-right: 1px solid #e0e0e0; + padding: 6px 10px; + text-align: left; + font-weight: 600; + color: #555; +} + +.config-table td { + padding: 6px 10px; + border-bottom: 1px solid #f0f0f0; + border-right: 1px solid #f5f5f5; + vertical-align: top; +} + +.config-table tr:hover td { + background: #f8fbff; +} + +.col-key { width: 22%; } +.col-value { width: 26%; } +.col-type { width: 10%; } +.col-desc { width: 30%; } +.col-actions { width: 12%; } + +.config-key-cell { + font-family: Consolas, monospace; + color: #0451a5; +} + +.config-value-cell { + font-family: Consolas, monospace; + word-break: break-all; +} + +.type-badge { + display: inline-block; + padding: 1px 8px; + border-radius: 10px; + font-size: 11px; + background: #e5f1fb; + color: #0078d4; +} + +.empty-row td { + text-align: center; + color: #999; + padding: 30px; +} + +.row-btn { + border: 1px solid #adadad; + border-radius: 3px; + background: #fff; + cursor: pointer; + padding: 2px 8px; + font-size: 11px; + margin-right: 4px; +} + +.row-btn:hover { + background: #e5f1fb; +} + +.row-btn.danger:hover { + background: #fde7e9; + border-color: #d13438; + color: #d13438; +} + +/* 状态栏 */ +.statusbar { + display: flex; + align-items: center; + justify-content: space-between; + height: 24px; + padding: 0 12px; + background: #f0f0f0; + border-top: 1px solid #c0c0c0; + font-size: 12px; + color: #666; + flex-shrink: 0; +} + +/* 模态框 */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.35); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.modal { + width: 480px; + background: #fff; + border-radius: 6px; + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.25); + overflow: hidden; +} + +.modal-sm { + width: 360px; +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + background: #f5f5f5; + border-bottom: 1px solid #e0e0e0; +} + +.modal-header h3 { + font-size: 15px; + color: #003a70; +} + +.modal-close { + border: none; + background: transparent; + cursor: pointer; + font-size: 16px; + color: #999; +} + +.modal-close:hover { + color: #333; +} + +.modal-body { + padding: 16px; + max-height: 60vh; + overflow: auto; +} + +.modal-footer { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 12px 16px; + border-top: 1px solid #e0e0e0; +} + +/* 表单 */ +.form-group { + margin-bottom: 12px; +} + +.form-row { + display: flex; + gap: 12px; +} + +.form-half { + flex: 1; +} + +.form-group label { + display: block; + margin-bottom: 4px; + font-weight: 600; + color: #444; + font-size: 12px; +} + +.form-group input, +.form-group select, +.form-group textarea { + width: 100%; + padding: 6px 8px; + border: 1px solid #adadad; + border-radius: 3px; + font-size: 13px; + font-family: inherit; +} + +.form-group textarea { + resize: vertical; +} + +.required { + color: #d13438; +} + +.hint { + font-weight: normal; + color: #999; + font-size: 11px; +} + +.checkbox-group { + display: flex; + flex-direction: column; + gap: 6px; +} + +.checkbox-label { + display: flex; + align-items: center; + gap: 6px; + font-weight: normal !important; + cursor: pointer; +} + +.checkbox-label input { + width: auto !important; +} + +/* 按钮 */ +.btn { + height: 30px; + padding: 0 16px; + border-radius: 3px; + border: 1px solid #adadad; + cursor: pointer; + font-size: 13px; +} + +.btn-primary { + background: #0078d4; + color: #fff; + border-color: #0078d4; +} + +.btn-primary:hover { + background: #106ebe; +} + +.btn-secondary { + background: #fff; +} + +.btn-secondary:hover { + background: #f0f0f0; +} + +.btn-danger { + background: #d13438; + color: #fff; + border-color: #d13438; +} + +.btn-danger:hover { + background: #b52e32; +} + +/* 只读模式下隐藏操作按钮 */ +body.mode-readonly .detail-actions #btn-add-config, +body.mode-readonly .row-btn:not(.view) { + display: none; +} + +/* Toast 提示 */ +.toast { + position: fixed; + top: 60px; + left: 50%; + transform: translateX(-50%); + padding: 10px 20px; + border-radius: 4px; + color: #fff; + font-size: 13px; + z-index: 2000; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); +} + +.toast-success { background: #107c10; } +.toast-error { background: #d13438; } +.toast-info { background: #0078d4; } + +/* ===== 标题栏(与工具栏分离) ===== */ +.titlebar { + display: flex; + align-items: center; + gap: 14px; + height: 40px; + padding: 0 14px; + background: linear-gradient(to bottom, #0a4a86, #003a70); + color: #fff; + flex-shrink: 0; +} + +.titlebar .app-title { + font-weight: 600; + font-size: 15px; + color: #fff; +} + +.titlebar .current-project { + font-size: 13px; + color: #cfe3f7; + padding-left: 14px; + border-left: 1px solid rgba(255, 255, 255, 0.3); +} + +/* ===== 工具栏(分组处理) ===== */ +.toolbar { + display: flex; + align-items: stretch; + gap: 4px; + min-height: 46px; + padding: 4px 8px; + background: linear-gradient(to bottom, #fdfdfd, #eaeaea); + border-bottom: 1px solid #c0c0c0; + flex-shrink: 0; + flex-wrap: wrap; +} + +.tool-group { + display: flex; + flex-direction: row; + align-items: center; + gap: 6px; + padding: 2px 10px; + border-right: 1px solid #d5d5d5; +} + +.tool-group .group-label { + font-size: 11px; + color: #888; +} + +/* ===== 维度栏 ===== */ +.dimension-bar { + display: flex; + align-items: center; + gap: 14px; + min-height: 34px; + padding: 4px 14px; + background: #eef4fb; + border-bottom: 1px solid #d0dced; + flex-shrink: 0; + flex-wrap: wrap; +} + +.dim-empty { + color: #8aa; + font-size: 12px; +} + +.dim-item { + display: flex; + align-items: center; + gap: 6px; +} + +.dim-item .dim-name { + font-weight: 600; + color: #0a4a86; + font-size: 12px; +} + +.dim-select { + height: 26px; + padding: 0 6px; + border: 1px solid #adadad; + border-radius: 3px; + background: #fff; + font-size: 12px; +} + +.dim-mapping { + font-size: 11px; + color: #7a8ba0; +} + +/* ===== 配置导航地址栏 ===== */ +.address-bar { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + background: #f5f5f5; + border-bottom: 1px solid #e0e0e0; +} + +.address-icon { + font-size: 13px; +} + +.address-input { + flex: 1; + height: 28px; + padding: 0 10px; + border: 1px solid #adadad; + border-radius: 3px; + font-family: Consolas, monospace; + font-size: 12px; +} + +.addr-btn { + height: 28px; + padding: 0 12px; + border: 1px solid #adadad; + border-radius: 3px; + background: linear-gradient(to bottom, #fdfdfd, #e8e8e8); + cursor: pointer; + font-size: 12px; +} + +.addr-btn:hover { + background: linear-gradient(to bottom, #eaf3fb, #d6e9f9); + border-color: #5b9bd5; +} + +/* ===== 新建项目维度映射行 ===== */ +.dim-config-row { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 8px; +} + +.dim-config-row .checkbox-label { + min-width: 60px; +} + +.dim-config-row .map-name { + min-width: 60px; + font-weight: 600; + color: #444; + font-size: 12px; +} + +.mapping-select { + height: 28px; + padding: 0 8px; + border: 1px solid #adadad; + border-radius: 3px; + background: #fff; + font-size: 12px; +} + +/* ===== 树节点:值项与目录项区分 ===== */ +.tree-node-label.is-leaf .tree-icon { + opacity: 0.9; +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..a3d0e92 --- /dev/null +++ b/public/index.html @@ -0,0 +1,292 @@ + + + + + + Registry - 微服务配置管理中心 + + + + +
+ 📋 Registry 配置管理中心 + 未选择项目 +
+ + +
+
+ 项目 + + + +
+ +
+ 模式 +
+ + + +
+
+ +
+ 配置 + + +
+ +
+ 数据 + + + +
+
+ + +
+ 选择项目后展示维度筛选 + + + + +
+ + +
+ + + + +
+ + +
+ +
+ 📍 + + + +
+
+ + + + + + + + + + + + + + + +
键名值类型说明操作
请在左侧选择节点查看配置
+
+
+
+ + + + + + + + + + + + + + + + + + + + + diff --git a/public/js/api.js b/public/js/api.js new file mode 100644 index 0000000..7ef2ba6 --- /dev/null +++ b/public/js/api.js @@ -0,0 +1,129 @@ +/** + * API 调用封装 + * 统一处理与后端 REST 接口的交互,返回解析后的数据或抛出错误 + */ +const API = { + /** + * 通用请求方法 + * @param {string} url - 请求路径 + * @param {Object} options - fetch 选项 + * @returns {Promise} 响应数据 + */ + async request(url, options = {}) { + const opts = { + headers: { "Content-Type": "application/json" }, + ...options, + }; + if (opts.body && typeof opts.body !== "string") { + opts.body = JSON.stringify(opts.body); + } + const res = await fetch(url, opts); + const json = await res.json(); + if (!json.success) { + throw new Error(json.message || "请求失败"); + } + return json.data; + }, + + /** + * 获取所有项目 + * @returns {Promise} 项目列表 + */ + getProjects() { + return this.request("/api/projects"); + }, + + /** + * 获取单个项目 + * @param {string} id - 项目 ID + * @returns {Promise} 项目对象 + */ + getProject(id) { + return this.request(`/api/projects/${id}`); + }, + + /** + * 创建项目 + * @param {Object} data - 项目数据 + * @returns {Promise} 创建的项目 + */ + createProject(data) { + return this.request("/api/projects", { method: "POST", body: data }); + }, + + /** + * 删除项目 + * @param {string} id - 项目 ID + * @returns {Promise} + */ + deleteProject(id) { + return this.request(`/api/projects/${id}`, { method: "DELETE" }); + }, + + /** + * 更新项目维度映射方式 + * @param {string} id - 项目 ID + * @param {Object} mappings - { idcMapping, environmentMapping, groupMapping } + * @returns {Promise} 更新后的项目 + */ + updateMappings(id, mappings) { + return this.request(`/api/projects/${id}/mappings`, { method: "PUT", body: mappings }); + }, + + /** + * 获取项目下所有配置 + * @param {string} projectId - 项目 ID + * @returns {Promise} 配置项列表 + */ + getConfigs(projectId) { + return this.request(`/api/projects/${projectId}/configs`); + }, + + /** + * 新增配置项 + * @param {string} projectId - 项目 ID + * @param {Object} data - 配置项数据 + * @returns {Promise} 创建的配置项 + */ + createConfig(projectId, data) { + return this.request(`/api/projects/${projectId}/configs`, { method: "POST", body: data }); + }, + + /** + * 更新配置项 + * @param {string} projectId - 项目 ID + * @param {string} configId - 配置项 ID + * @param {Object} data - 要更新的字段 + * @returns {Promise} 更新后的配置项 + */ + updateConfig(projectId, configId, data) { + return this.request(`/api/projects/${projectId}/configs/${configId}`, { method: "PUT", body: data }); + }, + + /** + * 删除配置项 + * @param {string} projectId - 项目 ID + * @param {string} configId - 配置项 ID + * @returns {Promise} + */ + deleteConfig(projectId, configId) { + return this.request(`/api/projects/${projectId}/configs/${configId}`, { method: "DELETE" }); + }, + + /** + * 获取当前模式 + * @returns {Promise} { mode } + */ + getMode() { + return this.request("/api/mode"); + }, + + /** + * 切换模式 + * @param {string} mode - 目标模式(plan/edit/readonly) + * @returns {Promise} { mode } + */ + setMode(mode) { + return this.request("/api/mode", { method: "PUT", body: { mode } }); + }, +}; diff --git a/public/js/app.js b/public/js/app.js new file mode 100644 index 0000000..60c8018 --- /dev/null +++ b/public/js/app.js @@ -0,0 +1,772 @@ +/** + * 主应用逻辑 + * 负责状态管理、事件绑定、界面渲染,以及维度筛选、地址栏导航与三种模式的差异化控制 + */ + +// 全局应用状态 +const state = { + projects: [], // 项目列表 + currentProject: null, // 当前选中项目 + configs: [], // 当前项目全部配置(含 path) + visibleConfigs: [], // 右侧面板当前展示的配置 + mode: "edit", // 当前模式: plan/edit/readonly + editingConfigId: null, // 正在编辑的配置项 ID(null 表示新增) + confirmCallback: null, // 确认框回调 + dimFilter: { idc: "", environment: "", group: "" }, // 维度栏筛选值(空=全部) +}; + +/** + * 显示 Toast 提示 + * @param {string} message - 提示内容 + * @param {string} type - 类型: success/error/info + * @returns {void} + */ +function toast(message, type = "info") { + const el = document.createElement("div"); + el.className = `toast toast-${type}`; + el.textContent = message; + document.body.appendChild(el); + setTimeout(() => el.remove(), 2500); +} + +/** + * 设置状态栏文字 + * @param {string} text - 状态文字 + * @returns {void} + */ +function setStatus(text) { + document.getElementById("status-text").textContent = text; +} + +/** + * 打开模态框 + * @param {string} id - 模态框元素 ID + * @returns {void} + */ +function openModal(id) { + document.getElementById(id).style.display = "flex"; +} + +/** + * 关闭模态框 + * @param {string} id - 模态框元素 ID + * @returns {void} + */ +function closeModal(id) { + document.getElementById(id).style.display = "none"; +} + +/** + * 模式的中文名称映射 + */ +const MODE_NAMES = { plan: "规划", edit: "编辑", readonly: "只读" }; + +/** + * 映射方式中文名称 + */ +const MAPPING_NAMES = { path: "路径", label: "标签" }; + +/** + * 应用当前模式到界面(更新样式类、按钮状态、状态栏) + * @returns {void} + */ +function applyMode() { + document.body.classList.remove("mode-plan", "mode-edit", "mode-readonly"); + document.body.classList.add(`mode-${state.mode}`); + document.querySelectorAll(".mode-btn").forEach((btn) => { + btn.classList.toggle("active", btn.dataset.mode === state.mode); + }); + document.getElementById("status-mode").textContent = `当前模式:${MODE_NAMES[state.mode]}`; +} + +/** + * HTML 转义,防止 XSS + * @param {string} str - 原始字符串 + * @returns {string} 转义后的字符串 + */ +function escapeHtml(str) { + const div = document.createElement("div"); + div.textContent = str; + return div.innerHTML; +} + +/** + * 加载项目列表并填充选择器 + * @returns {Promise} + */ +async function loadProjects() { + state.projects = await API.getProjects(); + const selector = document.getElementById("project-selector"); + selector.innerHTML = ''; + 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} + */ +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} 去重后的取值列表 + */ +function collectDimValues(field) { + const set = new Set(); + state.configs.forEach((c) => { + if (c[field]) set.add(c[field]); + }); + return Array.from(set).sort(); +} + +/** + * 渲染维度栏:展示已启用维度的筛选下拉与映射方式 + * @returns {void} + */ +function renderDimensionBar() { + const p = state.currentProject; + const empty = document.getElementById("dim-empty"); + const mappingBtn = document.getElementById("btn-dim-mapping"); + const items = [ + { key: "idc", enable: "enableIdc", mapping: "idcMapping", field: "idc", box: "dim-idc", sel: "dim-idc-select", map: "dim-idc-mapping" }, + { key: "environment", enable: "enableEnvironment", mapping: "environmentMapping", field: "environment", box: "dim-env", sel: "dim-env-select", map: "dim-env-mapping" }, + { key: "group", enable: "enableGroup", mapping: "groupMapping", field: "group", box: "dim-group", sel: "dim-group-select", map: "dim-group-mapping" }, + ]; + + if (!p) { + empty.style.display = ""; + mappingBtn.style.display = "none"; + items.forEach((it) => (document.getElementById(it.box).style.display = "none")); + return; + } + + const anyEnabled = p.enableIdc || p.enableEnvironment || p.enableGroup; + empty.style.display = anyEnabled ? "none" : ""; + mappingBtn.style.display = anyEnabled ? "" : "none"; + + items.forEach((it) => { + const box = document.getElementById(it.box); + if (!p[it.enable]) { + box.style.display = "none"; + return; + } + box.style.display = ""; + // 填充筛选下拉 + const sel = document.getElementById(it.sel); + const values = collectDimValues(it.field); + sel.innerHTML = '' + values.map((v) => ``).join(""); + sel.value = state.dimFilter[it.key] || ""; + // 展示映射方式 + document.getElementById(it.map).textContent = `[${MAPPING_NAMES[p[it.mapping]] || "路径"}]`; + }); +} + +/** + * 树节点选中回调 + * @param {Array} configs - 该节点下的配置列表 + * @param {string} path - 节点路径 + * @returns {void} + */ +function onTreeSelect(configs, path) { + state.visibleConfigs = configs; + renderConfigTable(configs, path); +} + +/** + * 渲染右侧配置表格并同步地址栏 + * @param {Array} configs - 配置项列表 + * @param {string} path - 当前路径 + * @returns {void} + */ +function renderConfigTable(configs, path) { + state.visibleConfigs = configs; + document.getElementById("address-input").value = path === "/" ? "" : path; + const tbody = document.getElementById("config-tbody"); + tbody.innerHTML = ""; + + if (!configs || configs.length === 0) { + tbody.innerHTML = '当前节点暂无配置项'; + return; + } + + configs.forEach((cfg) => { + const tr = document.createElement("tr"); + tr.innerHTML = ` + ${escapeHtml(cfg.key)} + ${escapeHtml(String(cfg.value))} + ${escapeHtml(cfg.type)} + ${escapeHtml(cfg.description)} + + + + + + `; + tbody.appendChild(tr); + }); +} + +/** + * 根据当前项目维度开关,控制配置模态框中维度字段的显示 + * @returns {void} + */ +function updateDimensionFields() { + const p = state.currentProject; + document.getElementById("dim-idc-group").style.display = p && p.enableIdc ? "" : "none"; + document.getElementById("dim-env-group").style.display = p && p.enableEnvironment ? "" : "none"; + document.getElementById("dim-group-group").style.display = p && p.enableGroup ? "" : "none"; +} + +/** + * 打开配置编辑模态框 + * @param {Object|null} config - 配置项,null 表示新增 + * @param {boolean} readonly - 是否只读查看 + * @returns {void} + */ +function openConfigModal(config, readonly) { + state.editingConfigId = config ? config.id : null; + document.getElementById("modal-config-title").textContent = readonly + ? "查看配置项" + : config + ? "编辑配置项" + : "新增配置项"; + + // 新增时用维度栏当前筛选值预填维度,方便就地新增 + const preIdc = config ? config.idc || "" : state.dimFilter.idc || ""; + const preEnv = config ? config.environment || "" : state.dimFilter.environment || ""; + const preGroup = config ? config.group || "" : state.dimFilter.group || ""; + + document.getElementById("config-key").value = config ? config.key : ""; + document.getElementById("config-idc").value = preIdc; + document.getElementById("config-environment").value = preEnv; + document.getElementById("config-group").value = preGroup; + document.getElementById("config-value").value = config ? config.value : ""; + document.getElementById("config-type").value = config ? config.type : "string"; + document.getElementById("config-value-range").value = config ? config.valueRange || "" : ""; + document.getElementById("config-description").value = config ? config.description : ""; + document.getElementById("config-purpose").value = config ? config.purpose || "" : ""; + + updateDimensionFields(); + applyFieldPermissions(readonly); + openModal("modal-config"); +} + +/** + * 根据模式和是否只读查看,设置配置模态框各字段的可编辑性 + * 规划模式:结构字段(类型/说明/用途/范围/键名/维度)可改,值不可改 + * 编辑模式:值可改,已有项的结构字段锁定 + * 只读查看:全部禁用 + * @param {boolean} readonly - 是否只读查看 + * @returns {void} + */ +function applyFieldPermissions(readonly) { + const structFields = [ + "config-key", "config-type", "config-value-range", + "config-description", "config-purpose", + "config-idc", "config-environment", "config-group", + ]; + const valueField = "config-value"; + const saveBtn = document.getElementById("btn-save-config"); + + const disableAll = (disabled) => { + structFields.forEach((id) => (document.getElementById(id).disabled = disabled)); + document.getElementById(valueField).disabled = disabled; + }; + + if (readonly || state.mode === "readonly") { + disableAll(true); + saveBtn.style.display = "none"; + return; + } + + saveBtn.style.display = ""; + + if (state.mode === "plan") { + // 规划模式:可改结构,值不可改 + structFields.forEach((id) => (document.getElementById(id).disabled = false)); + document.getElementById(valueField).disabled = true; + } else if (state.mode === "edit") { + // 编辑模式:新增时结构可填;编辑已有项时仅值可改(键名/维度也允许调整路径) + const isNew = !state.editingConfigId; + document.getElementById("config-type").disabled = !isNew; + document.getElementById("config-value-range").disabled = !isNew; + document.getElementById("config-description").disabled = !isNew; + document.getElementById("config-purpose").disabled = !isNew; + document.getElementById("config-key").disabled = false; + document.getElementById("config-idc").disabled = false; + document.getElementById("config-environment").disabled = false; + document.getElementById("config-group").disabled = false; + document.getElementById(valueField).disabled = false; + } +} + +/** + * 收集配置模态框表单数据 + * @returns {Object} 表单数据 + */ +function collectConfigForm() { + return { + key: document.getElementById("config-key").value.trim(), + idc: document.getElementById("config-idc").value.trim(), + environment: document.getElementById("config-environment").value.trim(), + group: document.getElementById("config-group").value.trim(), + value: document.getElementById("config-value").value, + type: document.getElementById("config-type").value, + valueRange: document.getElementById("config-value-range").value.trim(), + description: document.getElementById("config-description").value.trim(), + purpose: document.getElementById("config-purpose").value.trim(), + }; +} + +/** + * 保存配置项(新增或更新) + * @returns {Promise} + */ +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} + */ +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} + */ +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} + */ +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} + */ +async function copyAddress() { + const text = document.getElementById("address-input").value; + try { + await navigator.clipboard.writeText(text); + toast("路径已拷贝", "success"); + } catch { + // 剪贴板不可用时退化为选中输入框内容 + document.getElementById("address-input").select(); + toast("请手动复制(Ctrl+C)", "info"); + } +} + +/** + * 绑定所有事件监听 + * @returns {void} + */ +function bindEvents() { + // 项目选择 + document.getElementById("project-selector").addEventListener("change", (e) => { + selectProject(e.target.value); + }); + + // 新建项目 + document.getElementById("btn-new-project").addEventListener("click", () => { + document.getElementById("project-name").value = ""; + document.getElementById("project-description").value = ""; + document.getElementById("project-enable-idc").checked = true; + document.getElementById("project-enable-env").checked = true; + document.getElementById("project-enable-group").checked = true; + document.getElementById("project-idc-mapping").value = "path"; + document.getElementById("project-env-mapping").value = "path"; + document.getElementById("project-group-mapping").value = "path"; + openModal("modal-project"); + }); + document.getElementById("btn-save-project").addEventListener("click", saveProject); + + // 删除项目 + document.getElementById("btn-delete-project").addEventListener("click", () => { + if (!state.currentProject) { + toast("请先选择项目", "error"); + return; + } + const proj = state.currentProject; + confirmAction(`确认删除项目 "${proj.name}" 及其全部配置吗?`, async () => { + try { + await API.deleteProject(proj.id); + toast("项目已删除", "success"); + closeModal("modal-confirm"); + await loadProjects(); + await selectProject(""); + document.getElementById("project-selector").value = ""; + } catch (err) { + toast(err.message, "error"); + } + }); + }); + + // 模式切换 + document.querySelectorAll(".mode-btn").forEach((btn) => { + btn.addEventListener("click", async () => { + try { + await API.setMode(btn.dataset.mode); + state.mode = btn.dataset.mode; + applyMode(); + toast(`已切换到${MODE_NAMES[state.mode]}模式`, "info"); + } catch (err) { + toast(err.message, "error"); + } + }); + }); + + // 新增配置 + document.getElementById("btn-add-config").addEventListener("click", () => { + if (!state.currentProject) { + toast("请先选择项目", "error"); + return; + } + if (state.mode === "readonly") { + toast("只读模式下不允许新增", "error"); + return; + } + openConfigModal(null, false); + }); + document.getElementById("btn-save-config").addEventListener("click", saveConfig); + + // 维度筛选下拉 + document.getElementById("dim-idc-select").addEventListener("change", (e) => { + state.dimFilter.idc = e.target.value; + refreshTree(); + renderConfigTable(getFilteredConfigs(), "/"); + }); + document.getElementById("dim-env-select").addEventListener("change", (e) => { + state.dimFilter.environment = e.target.value; + refreshTree(); + renderConfigTable(getFilteredConfigs(), "/"); + }); + document.getElementById("dim-group-select").addEventListener("change", (e) => { + state.dimFilter.group = e.target.value; + refreshTree(); + renderConfigTable(getFilteredConfigs(), "/"); + }); + + // 维度映射配置 + document.getElementById("btn-dim-mapping").addEventListener("click", openMappingModal); + document.getElementById("btn-save-mapping").addEventListener("click", saveMapping); + + // 地址栏 + document.getElementById("btn-address-go").addEventListener("click", navigateToPath); + document.getElementById("btn-address-copy").addEventListener("click", copyAddress); + document.getElementById("address-input").addEventListener("keydown", (e) => { + if (e.key === "Enter") navigateToPath(); + }); + + // 导出/导入 + document.getElementById("btn-export-config").addEventListener("click", exportConfig); + document.getElementById("btn-import-config").addEventListener("click", () => { + document.getElementById("import-file-input").click(); + }); + document.getElementById("import-file-input").addEventListener("change", (e) => { + if (e.target.files[0]) { + importConfig(e.target.files[0]); + e.target.value = ""; + } + }); + + // 表格操作(事件委托) + document.getElementById("config-tbody").addEventListener("click", (e) => { + const btn = e.target.closest(".row-btn"); + if (!btn) return; + const id = btn.dataset.id; + const action = btn.dataset.action; + const config = state.configs.find((c) => c.id === id); + if (!config) return; + + if (action === "view") { + openConfigModal(config, true); + } else if (action === "edit") { + if (state.mode === "readonly") { + toast("只读模式下不允许编辑", "error"); + return; + } + openConfigModal(config, false); + } else if (action === "delete") { + if (state.mode === "readonly") { + toast("只读模式下不允许删除", "error"); + return; + } + confirmAction(`确认删除配置项 "${config.key}" 吗?`, async () => { + try { + await API.deleteConfig(state.currentProject.id, id); + toast("配置已删除", "success"); + closeModal("modal-confirm"); + await selectProject(state.currentProject.id); + } catch (err) { + toast(err.message, "error"); + } + }); + } + }); + + // 确认框 + document.getElementById("btn-confirm-ok").addEventListener("click", () => { + if (state.confirmCallback) state.confirmCallback(); + }); + + // 全部折叠 + document.getElementById("btn-collapse-all").addEventListener("click", () => { + document.querySelectorAll(".tree-children").forEach((el) => el.classList.add("collapsed")); + document.querySelectorAll(".tree-toggle").forEach((el) => { + if (el.textContent === "▼") el.textContent = "▶"; + }); + }); + + // 分隔条拖拽 + bindResizer(); +} + +/** + * 绑定左右面板分隔条的拖拽调整宽度功能 + * @returns {void} + */ +function bindResizer() { + const resizer = document.getElementById("resizer"); + const treePanel = document.getElementById("tree-panel"); + let dragging = false; + + resizer.addEventListener("mousedown", () => { + dragging = true; + document.body.style.cursor = "col-resize"; + }); + document.addEventListener("mousemove", (e) => { + if (!dragging) return; + const width = Math.max(180, Math.min(600, e.clientX)); + treePanel.style.width = width + "px"; + }); + document.addEventListener("mouseup", () => { + dragging = false; + document.body.style.cursor = ""; + }); +} + +/** + * 应用初始化入口 + * @returns {Promise} + */ +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); diff --git a/public/js/tree.js b/public/js/tree.js new file mode 100644 index 0000000..02a49ce --- /dev/null +++ b/public/js/tree.js @@ -0,0 +1,163 @@ +/** + * 树形组件 + * 将配置项按“路径映射维度前缀 + 嵌套键”构建成注册表风格的树并渲染 + * 说明:仅映射为 path 的维度进入路径;label 维度由维度栏筛选,不进入树层级; + * 配置键 key 自身按 / 拆分为多级目录,叶子节点为真正的配置项。 + */ +const Tree = { + /** + * 将配置项列表构建为树形数据结构 + * 每个节点结构: { name, path, type:"dir"|"leaf", children:{}, config } + * @param {Object} project - 项目对象 + * @param {Array} configs - 已按 label 维度筛选后的配置项列表(含 path 字段) + * @returns {Object} 树根节点 + */ + buildTree(project, configs) { + const root = { name: project.name, path: "", type: "dir", children: {}, config: null }; + + configs.forEach((config) => { + // path 已由后端按映射规则生成,直接按 / 拆分为层级 + const segments = String(config.path || config.key).split("/").filter((s) => s !== ""); + let node = root; + let acc = ""; + segments.forEach((seg, idx) => { + acc = acc ? acc + "/" + seg : seg; + const isLast = idx === segments.length - 1; + if (!node.children[seg]) { + node.children[seg] = { name: seg, path: acc, type: "dir", children: {}, config: null }; + } + node = node.children[seg]; + if (isLast) { + // 叶子节点绑定真实配置项 + node.type = "leaf"; + node.config = config; + } + }); + }); + + return root; + }, + + /** + * 渲染树到指定容器 + * @param {HTMLElement} container - 容器元素 + * @param {Object} project - 项目对象 + * @param {Array} configs - 配置项列表 + * @param {Function} onSelect - 节点选中回调,参数为(配置列表, 路径, 节点) + */ + render(container, project, configs, onSelect) { + container.innerHTML = ""; + if (!project) { + container.innerHTML = '
请先选择一个项目
'; + return; + } + const tree = this.buildTree(project, configs); + const rootEl = this._renderNode(tree, onSelect, true); + container.appendChild(rootEl); + }, + + /** + * 收集某节点及其子孙下的全部叶子配置项 + * @param {Object} node - 树节点 + * @returns {Array} 配置项列表 + */ + collectConfigs(node) { + let list = []; + if (node.type === "leaf" && node.config) { + list.push(node.config); + } + Object.values(node.children).forEach((child) => { + list = list.concat(this.collectConfigs(child)); + }); + return list; + }, + + /** + * 递归渲染单个树节点 + * @param {Object} node - 树节点 + * @param {Function} onSelect - 选中回调 + * @param {boolean} isRoot - 是否为根节点 + * @returns {HTMLElement} 节点元素 + */ + _renderNode(node, onSelect, isRoot) { + const nodeEl = document.createElement("div"); + nodeEl.className = "tree-node"; + + const childKeys = Object.keys(node.children); + const hasChildren = childKeys.length > 0; + const isLeaf = node.type === "leaf" && !hasChildren; + + const label = document.createElement("div"); + label.className = "tree-node-label" + (isLeaf ? " is-leaf" : ""); + label.dataset.path = node.path; + + const toggle = document.createElement("span"); + toggle.className = "tree-toggle"; + toggle.textContent = hasChildren ? "▼" : ""; + + const icon = document.createElement("span"); + icon.className = "tree-icon"; + icon.textContent = isRoot ? "📦" : isLeaf ? "🔑" : "📁"; + + const text = document.createElement("span"); + text.textContent = node.name; + + label.appendChild(toggle); + label.appendChild(icon); + label.appendChild(text); + nodeEl.appendChild(label); + + label.addEventListener("click", (e) => { + e.stopPropagation(); + document.querySelectorAll(".tree-node-label.selected").forEach((el) => el.classList.remove("selected")); + label.classList.add("selected"); + onSelect(this.collectConfigs(node), node.path || "/", node); + }); + + if (hasChildren) { + const childrenEl = document.createElement("div"); + childrenEl.className = "tree-children"; + childKeys.sort().forEach((key) => { + childrenEl.appendChild(this._renderNode(node.children[key], onSelect, false)); + }); + nodeEl.appendChild(childrenEl); + + toggle.addEventListener("click", (e) => { + e.stopPropagation(); + childrenEl.classList.toggle("collapsed"); + toggle.textContent = childrenEl.classList.contains("collapsed") ? "▶" : "▼"; + }); + } + + return nodeEl; + }, + + /** + * 根据路径展开并选中对应节点(供地址栏跳转使用) + * @param {HTMLElement} container - 树容器 + * @param {string} targetPath - 目标路径 + * @returns {boolean} 是否命中并选中节点 + */ + selectByPath(container, targetPath) { + const normalized = String(targetPath || "").split("/").map((s) => s.trim()).filter((s) => s !== "").join("/"); + const labels = container.querySelectorAll(".tree-node-label"); + for (const label of labels) { + if (label.dataset.path === normalized) { + // 展开所有祖先节点 + let parent = label.parentElement; + while (parent && parent !== container) { + if (parent.classList.contains("tree-children")) { + parent.classList.remove("collapsed"); + const pToggle = parent.previousElementSibling?.querySelector(".tree-toggle"); + if (pToggle && pToggle.textContent === "▶") pToggle.textContent = "▼"; + } + parent = parent.parentElement; + } + label.click(); + label.scrollIntoView({ block: "center" }); + return true; + } + } + return false; + }, +}; diff --git a/src/persistence/jsonAdapter.js b/src/persistence/jsonAdapter.js new file mode 100644 index 0000000..2864d30 --- /dev/null +++ b/src/persistence/jsonAdapter.js @@ -0,0 +1,132 @@ +/** + * JSON 文件持久化适配器 + * 提供项目、配置、系统数据的读写能力,数据存储在 data/ 目录下 + * 预留扩展接口,后续可替换为 SqliteAdapter、NacosAdapter 等 + */ +const fs = require("fs"); +const path = require("path"); + +// 数据存储根目录 +const DATA_DIR = path.join(__dirname, "..", "..", "data"); +const PROJECTS_FILE = path.join(DATA_DIR, "projects.json"); +const SYSTEM_FILE = path.join(DATA_DIR, "system.json"); +const CONFIGS_DIR = path.join(DATA_DIR, "configs"); + +/** + * 确保数据目录和基础文件存在 + */ +function ensureDataDir() { + if (!fs.existsSync(DATA_DIR)) { + fs.mkdirSync(DATA_DIR, { recursive: true }); + } + if (!fs.existsSync(CONFIGS_DIR)) { + fs.mkdirSync(CONFIGS_DIR, { recursive: true }); + } + if (!fs.existsSync(PROJECTS_FILE)) { + fs.writeFileSync(PROJECTS_FILE, "[]", "utf-8"); + } + if (!fs.existsSync(SYSTEM_FILE)) { + fs.writeFileSync(SYSTEM_FILE, JSON.stringify({ mode: "edit" }, null, 2), "utf-8"); + } +} + +/** + * 读取 JSON 文件,返回解析后的对象 + * @param {string} filePath - 文件路径 + * @returns {any} 解析后的 JSON 数据 + */ +function readJsonFile(filePath) { + if (!fs.existsSync(filePath)) { + return null; + } + const content = fs.readFileSync(filePath, "utf-8"); + return JSON.parse(content); +} + +/** + * 写入 JSON 文件 + * @param {string} filePath - 文件路径 + * @param {any} data - 要写入的数据 + */ +function writeJsonFile(filePath, data) { + fs.writeFileSync(filePath, JSON.stringify(data, null, 2), "utf-8"); +} + +/** + * 加载所有项目 + * @returns {Array} 项目列表 + */ +function loadProjects() { + ensureDataDir(); + return readJsonFile(PROJECTS_FILE) || []; +} + +/** + * 保存所有项目 + * @param {Array} projects - 项目列表 + */ +function saveProjects(projects) { + ensureDataDir(); + writeJsonFile(PROJECTS_FILE, projects); +} + +/** + * 加载指定项目的所有配置 + * @param {string} projectId - 项目 ID + * @returns {Array} 配置项列表 + */ +function loadConfigs(projectId) { + ensureDataDir(); + const configFile = path.join(CONFIGS_DIR, `${projectId}.json`); + return readJsonFile(configFile) || []; +} + +/** + * 保存指定项目的所有配置 + * @param {string} projectId - 项目 ID + * @param {Array} configs - 配置项列表 + */ +function saveConfigs(projectId, configs) { + ensureDataDir(); + const configFile = path.join(CONFIGS_DIR, `${projectId}.json`); + writeJsonFile(configFile, configs); +} + +/** + * 删除指定项目的配置文件 + * @param {string} projectId - 项目 ID + */ +function deleteConfigs(projectId) { + const configFile = path.join(CONFIGS_DIR, `${projectId}.json`); + if (fs.existsSync(configFile)) { + fs.unlinkSync(configFile); + } +} + +/** + * 加载系统配置 + * @returns {Object} 系统配置对象 + */ +function loadSystem() { + ensureDataDir(); + return readJsonFile(SYSTEM_FILE) || { mode: "edit" }; +} + +/** + * 保存系统配置 + * @param {Object} system - 系统配置对象 + */ +function saveSystem(system) { + ensureDataDir(); + writeJsonFile(SYSTEM_FILE, system); +} + +module.exports = { + loadProjects, + saveProjects, + loadConfigs, + saveConfigs, + deleteConfigs, + loadSystem, + saveSystem, +}; diff --git a/src/routes/configs.js b/src/routes/configs.js new file mode 100644 index 0000000..018ab67 --- /dev/null +++ b/src/routes/configs.js @@ -0,0 +1,97 @@ +/** + * 配置路由 + * 处理配置项相关 API 请求 + */ +const express = require("express"); +const router = express.Router({ mergeParams: true }); +const configService = require("../services/configService"); +const { loadSystem, saveSystem } = require("../persistence/jsonAdapter"); + +/** + * 获取当前模式 + * @returns {string} 当前模式 + */ +function getCurrentMode() { + try { + const system = loadSystem(); + return system.mode || "edit"; + } catch { + return "edit"; + } +} + +/** + * GET /api/projects/:projectId/configs + * 获取项目下所有配置项 + */ +router.get("/", (req, res) => { + try { + const configs = configService.getConfigsByProject(req.params.projectId); + res.json({ success: true, data: configs }); + } catch (err) { + res.status(400).json({ success: false, message: err.message }); + } +}); + +/** + * GET /api/projects/:projectId/configs/:configId + * 获取单个配置项 + */ +router.get("/:configId", (req, res) => { + try { + const config = configService.getConfigById(req.params.projectId, req.params.configId); + if (!config) { + return res.status(404).json({ success: false, message: "配置项不存在" }); + } + res.json({ success: true, data: config }); + } catch (err) { + res.status(400).json({ success: false, message: err.message }); + } +}); + +/** + * POST /api/projects/:projectId/configs + * 新增配置项 + */ +router.post("/", (req, res) => { + try { + const mode = getCurrentMode(); + const config = configService.createConfig(req.params.projectId, req.body, mode); + res.status(201).json({ success: true, data: config }); + } catch (err) { + res.status(400).json({ success: false, message: err.message }); + } +}); + +/** + * PUT /api/projects/:projectId/configs/:configId + * 修改配置项 + */ +router.put("/:configId", (req, res) => { + try { + const mode = getCurrentMode(); + const config = configService.updateConfig(req.params.projectId, req.params.configId, req.body, mode); + res.json({ success: true, data: config }); + } catch (err) { + res.status(400).json({ success: false, message: err.message }); + } +}); + +/** + * DELETE /api/projects/:projectId/configs/:configId + * 删除配置项 + */ +router.delete("/:configId", (req, res) => { + try { + const mode = getCurrentMode(); + const result = configService.deleteConfig(req.params.projectId, req.params.configId, mode); + if (!result) { + return res.status(404).json({ success: false, message: "配置项不存在" }); + } + res.json({ success: true, message: "配置项已删除" }); + } catch (err) { + res.status(400).json({ success: false, message: err.message }); + } +}); + +module.exports = router; diff --git a/src/routes/projects.js b/src/routes/projects.js new file mode 100644 index 0000000..a20491e --- /dev/null +++ b/src/routes/projects.js @@ -0,0 +1,82 @@ +/** + * 项目路由 + * 处理项目相关 API 请求 + */ +const express = require("express"); +const router = express.Router(); +const projectService = require("../services/projectService"); + +/** + * GET /api/projects + * 获取所有项目列表 + */ +router.get("/", (req, res) => { + try { + const projects = projectService.getAllProjects(); + res.json({ success: true, data: projects }); + } catch (err) { + res.status(500).json({ success: false, message: err.message }); + } +}); + +/** + * GET /api/projects/:id + * 获取单个项目详情 + */ +router.get("/:id", (req, res) => { + try { + const project = projectService.getProjectById(req.params.id); + if (!project) { + return res.status(404).json({ success: false, message: "项目不存在" }); + } + res.json({ success: true, data: project }); + } catch (err) { + res.status(500).json({ success: false, message: err.message }); + } +}); + +/** + * POST /api/projects + * 创建新项目 + * Body: { name, description, enableIdc, enableEnvironment, enableGroup, idcMapping, environmentMapping, groupMapping } + */ +router.post("/", (req, res) => { + try { + const project = projectService.createProject(req.body); + res.status(201).json({ success: true, data: project }); + } catch (err) { + res.status(400).json({ success: false, message: err.message }); + } +}); + +/** + * PUT /api/projects/:id/mappings + * 更新项目的维度映射方式(path/label) + * Body: { idcMapping, environmentMapping, groupMapping } + */ +router.put("/:id/mappings", (req, res) => { + try { + const project = projectService.updateMappings(req.params.id, req.body); + res.json({ success: true, data: project }); + } catch (err) { + res.status(400).json({ success: false, message: err.message }); + } +}); + +/** + * DELETE /api/projects/:id + * 删除项目及其所有配置 + */ +router.delete("/:id", (req, res) => { + try { + const result = projectService.deleteProject(req.params.id); + if (!result) { + return res.status(404).json({ success: false, message: "项目不存在" }); + } + res.json({ success: true, message: "项目已删除" }); + } catch (err) { + res.status(500).json({ success: false, message: err.message }); + } +}); + +module.exports = router; diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..370cc36 --- /dev/null +++ b/src/server.js @@ -0,0 +1,61 @@ +/** + * 服务入口 + * 启动 Express 服务器,挂载路由和静态资源 + */ +const express = require("express"); +const path = require("path"); +const projectRoutes = require("./routes/projects"); +const configRoutes = require("./routes/configs"); +const { loadSystem, saveSystem } = require("./persistence/jsonAdapter"); + +const app = express(); +const PORT = process.env.PORT || 3000; + +// 解析 JSON 请求体 +app.use(express.json()); + +// 静态资源服务 +app.use(express.static(path.join(__dirname, "..", "public"))); + +// API 路由 +app.use("/api/projects", projectRoutes); +app.use("/api/projects/:projectId/configs", configRoutes); + +/** + * GET /api/mode + * 获取当前系统模式 + */ +app.get("/api/mode", (req, res) => { + try { + const system = loadSystem(); + res.json({ success: true, data: { mode: system.mode || "edit" } }); + } catch (err) { + res.status(500).json({ success: false, message: err.message }); + } +}); + +/** + * PUT /api/mode + * 切换系统模式 + * Body: { mode: "plan" | "edit" | "readonly" } + */ +app.put("/api/mode", (req, res) => { + try { + const { mode } = req.body; + const validModes = ["plan", "edit", "readonly"]; + if (!validModes.includes(mode)) { + return res.status(400).json({ success: false, message: `无效的模式: ${mode},支持: ${validModes.join(", ")}` }); + } + const system = loadSystem(); + system.mode = mode; + saveSystem(system); + res.json({ success: true, data: { mode } }); + } catch (err) { + res.status(500).json({ success: false, message: err.message }); + } +}); + +// 启动服务 +app.listen(PORT, () => { + console.log(`Registry 服务已启动: http://localhost:${PORT}`); +}); diff --git a/src/services/configService.js b/src/services/configService.js new file mode 100644 index 0000000..de5c395 --- /dev/null +++ b/src/services/configService.js @@ -0,0 +1,285 @@ +/** + * 配置服务层 + * 处理配置项的业务逻辑,包括 CRUD 操作、路径构建、值校验 + * 路径规则:仅映射方式为 path 的已启用维度作为路径前缀,label 维度仅作筛选标签不进入路径; + * 配置键 key 自身可用 / 嵌套,json 类型在前端按 . 分割展示 + */ +const { loadConfigs, saveConfigs } = require("../persistence/jsonAdapter"); +const { getProjectById } = require("./projectService"); +const { validateByType, validateRange } = require("../utils/validator"); + +/** + * 生成唯一 ID + * @returns {string} 基于时间戳的唯一 ID + */ +function generateId() { + return "cfg-" + Date.now().toString(36) + "-" + Math.random().toString(36).substring(2, 8); +} + +/** + * 规整配置键:去除首尾空白与多余的斜杠,压缩连续斜杠 + * @param {string} key - 原始键(可含 / 嵌套) + * @returns {string} 规整后的键 + */ +function normalizeKey(key) { + return String(key || "") + .trim() + .split("/") + .map((s) => s.trim()) + .filter((s) => s !== "") + .join("/"); +} + +/** + * 根据项目维度映射配置构建配置项的存储显示路径 + * 仅映射方式为 path 的已启用维度进入路径前缀,label 维度不进入 + * @param {Object} project - 项目对象 + * @param {Object} config - 配置项对象 + * @returns {string} 配置路径 + */ +function buildConfigPath(project, config) { + const parts = []; + if (project.enableIdc && project.idcMapping === "path" && config.idc) { + parts.push(config.idc); + } + if (project.enableEnvironment && project.environmentMapping === "path" && config.environment) { + parts.push(config.environment); + } + if (project.enableGroup && project.groupMapping === "path" && config.group) { + parts.push(config.group); + } + if (config.key) { + parts.push(config.key); + } + return parts.join("/"); +} + +/** + * 获取项目下所有配置项,自动填充路径 + * @param {string} projectId - 项目 ID + * @returns {Array} 配置项列表(含 path 字段) + */ +function getConfigsByProject(projectId) { + const project = getProjectById(projectId); + if (!project) { + throw new Error("项目不存在"); + } + const configs = loadConfigs(projectId); + return configs.map((c) => ({ + ...c, + path: buildConfigPath(project, c), + })); +} + +/** + * 根据 ID 获取单个配置项 + * @param {string} projectId - 项目 ID + * @param {string} configId - 配置项 ID + * @returns {Object|null} 配置项对象,不存在时返回 null + */ +function getConfigById(projectId, configId) { + const project = getProjectById(projectId); + if (!project) { + throw new Error("项目不存在"); + } + const configs = loadConfigs(projectId); + const config = configs.find((c) => c.id === configId); + if (!config) { + return null; + } + return { + ...config, + path: buildConfigPath(project, config), + }; +} + +/** + * 新增配置项 + * @param {string} projectId - 项目 ID + * @param {Object} data - 配置项数据 + * @param {string} data.key - 配置键名,可用 / 嵌套(必填) + * @param {any} data.value - 配置值 + * @param {string} data.type - 值类型(string/number/boolean/json) + * @param {string} data.description - 配置说明(必填) + * @param {string} data.idc - 机房(维度启用时必填) + * @param {string} data.environment - 环境(维度启用时必填) + * @param {string} data.group - 分组(维度启用时必填) + * @param {string} data.purpose - 用途说明(规划模式) + * @param {string} data.valueRange - 值范围(规划模式) + * @param {string} mode - 当前模式(plan/edit/readonly) + * @returns {Object} 创建的配置项 + */ +function createConfig(projectId, data, mode) { + if (mode === "readonly") { + throw new Error("只读模式下不允许新增配置"); + } + + const project = getProjectById(projectId); + if (!project) { + throw new Error("项目不存在"); + } + + 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 = { + id: generateId(), + projectId, + key, + value: data.value !== undefined ? data.value : "", + type: data.type || "string", + description: data.description.trim(), + purpose: data.purpose || "", + valueRange: data.valueRange || "", + idc: data.idc || "", + environment: data.environment || "", + group: data.group || "", + createdAt: now, + updatedAt: now, + }; + + // 编辑模式下校验值和范围 + 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); + } + } + + const configs = loadConfigs(projectId); + configs.push(config); + saveConfigs(projectId, configs); + + return { + ...config, + path: buildConfigPath(project, config), + }; +} + +/** + * 更新配置项 + * 规划模式:可修改 type、description、purpose、valueRange + * 编辑模式:可修改 value + * 只读模式:不可修改 + * @param {string} projectId - 项目 ID + * @param {string} configId - 配置项 ID + * @param {Object} data - 要更新的字段 + * @param {string} mode - 当前模式 + * @returns {Object} 更新后的配置项 + */ +function updateConfig(projectId, configId, data, mode) { + if (mode === "readonly") { + throw new Error("只读模式下不允许修改配置"); + } + + const project = getProjectById(projectId); + if (!project) { + throw new Error("项目不存在"); + } + + const configs = loadConfigs(projectId); + const index = configs.findIndex((c) => c.id === configId); + if (index === -1) { + throw new Error("配置项不存在"); + } + + const config = configs[index]; + + if (mode === "plan") { + // 规划模式:修改结构定义 + if (data.type !== undefined) config.type = data.type; + if (data.description !== undefined) config.description = data.description; + if (data.purpose !== undefined) config.purpose = data.purpose; + if (data.valueRange !== undefined) config.valueRange = data.valueRange; + 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; + } 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; + } + // 编辑模式下也可以修改 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; + } + + config.updatedAt = new Date().toISOString(); + configs[index] = config; + saveConfigs(projectId, configs); + + return { + ...config, + path: buildConfigPath(project, config), + }; +} + +/** + * 删除配置项 + * @param {string} projectId - 项目 ID + * @param {string} configId - 配置项 ID + * @param {string} mode - 当前模式 + * @returns {boolean} 是否删除成功 + */ +function deleteConfig(projectId, configId, mode) { + if (mode === "readonly") { + throw new Error("只读模式下不允许删除配置"); + } + + const configs = loadConfigs(projectId); + const index = configs.findIndex((c) => c.id === configId); + if (index === -1) { + return false; + } + configs.splice(index, 1); + saveConfigs(projectId, configs); + return true; +} + +module.exports = { + getConfigsByProject, + getConfigById, + createConfig, + updateConfig, + deleteConfig, + buildConfigPath, + normalizeKey, +}; diff --git a/src/services/projectService.js b/src/services/projectService.js new file mode 100644 index 0000000..c6bbeb3 --- /dev/null +++ b/src/services/projectService.js @@ -0,0 +1,142 @@ +/** + * 项目服务层 + * 处理项目的业务逻辑,包括 CRUD 操作、维度校验与维度映射配置 + * 注意:项目创建后维度开关不可修改,只能通过项目转换机制处理;维度映射方式可后续调整 + */ +const { loadProjects, saveProjects, deleteConfigs } = require("../persistence/jsonAdapter"); + +// 维度映射方式白名单:path=作为存储路径前缀;label=仅作为筛选标签不进入路径 +const VALID_MAPPINGS = ["path", "label"]; + +/** + * 生成唯一 ID + * @returns {string} 基于时间戳的唯一 ID + */ +function generateId() { + return "proj-" + Date.now().toString(36) + "-" + Math.random().toString(36).substring(2, 8); +} + +/** + * 规整维度映射方式,非法值回退为默认的 path + * @param {string} mapping - 传入的映射方式 + * @returns {string} 合法的映射方式(path/label) + */ +function normalizeMapping(mapping) { + return VALID_MAPPINGS.includes(mapping) ? mapping : "path"; +} + +/** + * 获取所有项目列表 + * @returns {Array} 项目列表 + */ +function getAllProjects() { + return loadProjects(); +} + +/** + * 根据 ID 获取单个项目 + * @param {string} id - 项目 ID + * @returns {Object|null} 项目对象,不存在时返回 null + */ +function getProjectById(id) { + const projects = loadProjects(); + return projects.find((p) => p.id === id) || null; +} + +/** + * 创建新项目 + * 维度开关(enableIdc/enableEnvironment/enableGroup)在创建时确定,后续不可修改 + * 维度映射方式(idcMapping/environmentMapping/groupMapping)默认 path,可后续调整 + * @param {Object} data - 项目数据 + * @param {string} data.name - 项目名称(必填) + * @param {string} data.description - 项目描述 + * @param {boolean} data.enableIdc - 是否启用机房维度 + * @param {boolean} data.enableEnvironment - 是否启用环境维度 + * @param {boolean} data.enableGroup - 是否启用分组维度 + * @param {string} data.idcMapping - 机房映射方式(path/label) + * @param {string} data.environmentMapping - 环境映射方式(path/label) + * @param {string} data.groupMapping - 分组映射方式(path/label) + * @returns {Object} 创建的项目对象 + */ +function createProject(data) { + if (!data.name || data.name.trim() === "") { + throw new Error("项目名称不能为空"); + } + + const projects = loadProjects(); + const now = new Date().toISOString(); + const project = { + id: generateId(), + name: data.name.trim(), + description: (data.description || "").trim(), + enableIdc: !!data.enableIdc, + enableEnvironment: !!data.enableEnvironment, + enableGroup: !!data.enableGroup, + idcMapping: normalizeMapping(data.idcMapping), + environmentMapping: normalizeMapping(data.environmentMapping), + groupMapping: normalizeMapping(data.groupMapping), + createdAt: now, + updatedAt: now, + }; + + projects.push(project); + saveProjects(projects); + return project; +} + +/** + * 更新项目的维度映射方式 + * 仅允许修改已启用维度的映射方式,维度开关本身不可变 + * @param {string} id - 项目 ID + * @param {Object} mappings - 映射配置 + * @param {string} mappings.idcMapping - 机房映射方式(path/label) + * @param {string} mappings.environmentMapping - 环境映射方式(path/label) + * @param {string} mappings.groupMapping - 分组映射方式(path/label) + * @returns {Object} 更新后的项目对象 + */ +function updateMappings(id, mappings) { + const projects = loadProjects(); + const index = projects.findIndex((p) => p.id === id); + if (index === -1) { + throw new Error("项目不存在"); + } + const project = projects[index]; + if (project.enableIdc && mappings.idcMapping !== undefined) { + project.idcMapping = normalizeMapping(mappings.idcMapping); + } + if (project.enableEnvironment && mappings.environmentMapping !== undefined) { + project.environmentMapping = normalizeMapping(mappings.environmentMapping); + } + if (project.enableGroup && mappings.groupMapping !== undefined) { + project.groupMapping = normalizeMapping(mappings.groupMapping); + } + project.updatedAt = new Date().toISOString(); + projects[index] = project; + saveProjects(projects); + return project; +} + +/** + * 删除项目及其所有关联配置 + * @param {string} id - 项目 ID + * @returns {boolean} 是否删除成功 + */ +function deleteProject(id) { + const projects = loadProjects(); + const index = projects.findIndex((p) => p.id === id); + if (index === -1) { + return false; + } + projects.splice(index, 1); + saveProjects(projects); + deleteConfigs(id); + return true; +} + +module.exports = { + getAllProjects, + getProjectById, + createProject, + updateMappings, + deleteProject, +}; diff --git a/src/utils/validator.js b/src/utils/validator.js new file mode 100644 index 0000000..4133f8b --- /dev/null +++ b/src/utils/validator.js @@ -0,0 +1,91 @@ +/** + * 配置值校验工具 + * 根据配置类型校验值的格式,编辑模式下额外校验值范围 + */ + +/** + * 支持的值类型列表 + */ +const VALID_TYPES = ["string", "number", "boolean", "json"]; + +/** + * 校验配置值是否符合指定的类型 + * @param {any} value - 配置值 + * @param {string} type - 配置类型(string/number/boolean/json) + * @returns {{ valid: boolean, message: string }} 校验结果 + */ +function validateByType(value, type) { + if (!VALID_TYPES.includes(type)) { + return { valid: false, message: `不支持的类型: ${type},支持的类型: ${VALID_TYPES.join(", ")}` }; + } + + switch (type) { + case "string": + if (typeof value !== "string") { + return { valid: false, message: "值必须是字符串类型" }; + } + break; + case "number": { + const num = Number(value); + if (isNaN(num)) { + return { valid: false, message: "值必须是有效的数字" }; + } + break; + } + case "boolean": + if (typeof value !== "boolean" && value !== "true" && value !== "false") { + return { valid: false, message: "值必须是布尔类型(true 或 false)" }; + } + break; + case "json": + try { + JSON.parse(typeof value === "string" ? value : JSON.stringify(value)); + } catch { + return { valid: false, message: "值必须是有效的 JSON 格式" }; + } + break; + } + return { valid: true, message: "" }; +} + +/** + * 校验值范围约束(仅在编辑模式下生效) + * 支持简单的范围描述,如 "合法 IPv4 地址"、"1-100"、"枚举: A,B,C" + * @param {any} value - 配置值 + * @param {string} valueRange - 值范围描述 + * @returns {{ valid: boolean, message: string }} 校验结果 + */ +function validateRange(value, valueRange) { + if (!valueRange || valueRange.trim() === "") { + return { valid: true, message: "" }; + } + + const range = valueRange.trim(); + + // 区间范围: "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]); + const num = Number(value); + if (isNaN(num) || num < min || num > max) { + return { valid: false, message: `值必须在 ${min} 到 ${max} 之间` }; + } + return { valid: true, message: "" }; + } + + // 枚举范围: "枚举: A,B,C" + const enumMatch = range.match(/^枚举[::]\s*(.+)$/); + if (enumMatch) { + const options = enumMatch[1].split(/[,,]/).map((s) => s.trim()); + if (!options.includes(String(value).trim())) { + return { valid: false, message: `值必须是以下选项之一: ${options.join(", ")}` }; + } + return { valid: true, message: "" }; + } + + // 其他描述性范围(如 "合法 IPv4 地址")目前不做强制校验,仅作提示 + return { valid: true, message: "" }; +} + +module.exports = { VALID_TYPES, validateByType, validateRange }; diff --git a/test/configService.test.js b/test/configService.test.js new file mode 100644 index 0000000..07de664 --- /dev/null +++ b/test/configService.test.js @@ -0,0 +1,57 @@ +/** + * 配置服务单元测试 + * 覆盖维度映射路径构建与嵌套键规整两项核心逻辑 + * 运行:npm test + */ +const test = require("node:test"); +const assert = require("node:assert"); +const { buildConfigPath, normalizeKey } = require("../src/services/configService"); + +// 构造测试用项目对象的辅助函数 +function makeProject(overrides) { + return { + enableIdc: true, + enableEnvironment: true, + enableGroup: true, + idcMapping: "path", + environmentMapping: "path", + groupMapping: "path", + ...overrides, + }; +} + +test("1-1 全部维度映射为 path 时,路径包含全部维度前缀", () => { + const project = makeProject({}); + const config = { idc: "华北", environment: "dev", group: "用户模块", key: "db.host" }; + assert.strictEqual(buildConfigPath(project, config), "华北/dev/用户模块/db.host"); +}); + +test("1-2 分组映射为 label 时,分组不进入路径", () => { + const project = makeProject({ groupMapping: "label" }); + const config = { idc: "华北", environment: "dev", group: "用户模块", key: "db/host" }; + assert.strictEqual(buildConfigPath(project, config), "华北/dev/db/host"); +}); + +test("1-3 全部维度映射为 label 时,路径仅含嵌套键", () => { + const project = makeProject({ idcMapping: "label", environmentMapping: "label", groupMapping: "label" }); + const config = { idc: "华北", environment: "dev", group: "用户模块", key: "db/pool/max" }; + assert.strictEqual(buildConfigPath(project, config), "db/pool/max"); +}); + +test("1-4 维度未启用时,即便映射为 path 也不进入路径", () => { + const project = makeProject({ enableIdc: false }); + const config = { idc: "华北", environment: "dev", group: "用户模块", key: "db.host" }; + assert.strictEqual(buildConfigPath(project, config), "dev/用户模块/db.host"); +}); + +test("2-1 规整键:去除首尾斜杠与空白", () => { + assert.strictEqual(normalizeKey(" /db/host/ "), "db/host"); +}); + +test("2-2 规整键:压缩连续斜杠并去除空段", () => { + assert.strictEqual(normalizeKey("db//pool///max"), "db/pool/max"); +}); + +test("2-3 规整键:空输入返回空字符串", () => { + assert.strictEqual(normalizeKey(" "), ""); +});