From 8d20792fc38017908b5b7357b98b8ecae2fa86af Mon Sep 17 00:00:00 2001 From: lenmotion Date: Thu, 23 Apr 2026 17:25:37 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9Eweb=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E4=B8=AD=E5=BF=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 7 + README.md | 14 ++ admin.html | 293 ++++++++++++++++++++++++++++++++++++++++++ config.json | 3 +- index.api.ts | 171 +++++++++++++++++++++++- mock/basic-error.json | 1 + pnpm-lock.yaml | 168 ++++++++++++++++++++++++ 7 files changed, 650 insertions(+), 7 deletions(-) create mode 100644 .gitignore create mode 100644 admin.html create mode 100644 mock/basic-error.json create mode 100644 pnpm-lock.yaml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b4fe469 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.DS_Store +Thumbs.db diff --git a/README.md b/README.md index e0dda02..51e2321 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,21 @@ npm run typecheck | 方法 | 路径 | 说明 | | --- | --- | --- | | `GET` | `http://localhost:/__config` | 查看当前路由与配置 | +| `POST` | `http://localhost:/__config` | 保存 `config.json`(包含 `routes` 与 `config`) | | `POST` | `http://localhost:/__reload-config` | 手动重新加载 `config.json` | +| `POST` | `http://localhost:/__routes` | 动态新增单个路由与 mock 文件 | +| `GET` | `http://localhost:/__admin` | 配置管理页面(Element UI) | + +#### `POST /__routes` 请求示例 + +```json +{ + "route": "/api/new/mock", + "filePath": "mock/new-api.json", + "fileContent": "{\"code\":0,\"message\":\"ok\"}", + "overwrite": false +} +``` ### TypeScript 与编译说明 diff --git a/admin.html b/admin.html new file mode 100644 index 0000000..5bb3b0f --- /dev/null +++ b/admin.html @@ -0,0 +1,293 @@ + + + + + + API Proxy Mock 配置管理 + + + + +
+ +
基础配置
+ + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ 路由配置(routes) + 新增一行 +
+ + + + + + + + + + + +
+ 说明:如果路由以 # 开头,会作为注释保留,不参与 mock 命中。 +
+
+ + +
动态新增接口配置文件
+ + + + + + + + + + + + + + + + + + + + + 创建路由+文件 +
+ + +
+ 保存配置到 config.json + 重载服务内存配置 + 刷新页面数据 +
+
+
+ + + + + + diff --git a/config.json b/config.json index 6136d65..6f8406f 100644 --- a/config.json +++ b/config.json @@ -1,6 +1,7 @@ { "routes": { - "/api2/admin/banner/list": "mock/banner.txt" + "/api2/admin/banner/list": "mock/banner.txt", + "/api2/home/ad/list": "mock/basic-error.json" }, "config": { "mockEnabled": true, diff --git a/index.api.ts b/index.api.ts index ef7a369..539815d 100644 --- a/index.api.ts +++ b/index.api.ts @@ -33,6 +33,7 @@ interface ConfigFile { // 存储当前的路由配置 let MOCK_ROUTES: RouteConfig = {}; +let RAW_ROUTES: RouteConfig = {}; let CONFIG: AppConfig = { cacheConfig: true, reloadOnChange: true, @@ -110,13 +111,15 @@ function loadConfig() { const configData: ConfigFile = JSON.parse( fs.readFileSync(CONFIG_FILE, "utf-8"), ); - MOCK_ROUTES = filterActiveRoutes(configData.routes || {}); + RAW_ROUTES = configData.routes || {}; + MOCK_ROUTES = filterActiveRoutes(RAW_ROUTES); CONFIG = configData.config || CONFIG; } else { const configData: ConfigFile = JSON.parse( fs.readFileSync(CONFIG_FILE, "utf-8"), ); - MOCK_ROUTES = filterActiveRoutes(configData.routes || {}); + RAW_ROUTES = configData.routes || {}; + MOCK_ROUTES = filterActiveRoutes(RAW_ROUTES); CONFIG = configData.config || CONFIG; console.log( `[CONFIG] 配置文件已加载,共 ${Object.keys(MOCK_ROUTES).length} 个路由${CONFIG.mockEnabled !== false ? "" : "(mock 已关闭,全部走代理)"}`, @@ -131,6 +134,7 @@ function loadConfig() { MOCK_ROUTES = { "/api2/user/list": "mock/user_list.txt", }; + RAW_ROUTES = { ...MOCK_ROUTES }; CONFIG = { cacheConfig: true, reloadOnChange: true, @@ -142,6 +146,31 @@ function loadConfig() { } } +function buildCurrentConfigFile(): ConfigFile { + return { + routes: { ...RAW_ROUTES }, + config: { ...CONFIG }, + }; +} + +function saveConfigFile(nextConfig: ConfigFile): void { + fs.writeFileSync(CONFIG_FILE, JSON.stringify(nextConfig, null, 2), "utf-8"); + RAW_ROUTES = nextConfig.routes || {}; + MOCK_ROUTES = filterActiveRoutes(RAW_ROUTES); + CONFIG = nextConfig.config || CONFIG; +} + +function readBody(req: http.IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on("data", (chunk: Buffer) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8"))); + req.on("error", reject); + }); +} + // 验证mock文件是否存在 function validateMockFiles() { console.log(`[CONFIG] 验证mock文件...`); @@ -255,27 +284,58 @@ const proxyServer = http.createServer((clientReq, clientRes) => { } if (requestPath === "/__config") { - if (clientReq.method !== "GET") { + if (clientReq.method !== "GET" && clientReq.method !== "POST") { clientRes.writeHead(405, { "Content-Type": "application/json", - Allow: "GET", + Allow: "GET, POST", }); clientRes.end( JSON.stringify({ success: false, error: "Method Not Allowed", - allow: ["GET"], + allow: ["GET", "POST"], }), ); return; } + if (clientReq.method === "POST") { + readBody(clientReq) + .then((bodyText) => { + const body = bodyText ? JSON.parse(bodyText) : {}; + const nextConfig: ConfigFile = { + routes: body.routes || {}, + config: body.config || CONFIG, + }; + saveConfigFile(nextConfig); + validateMockFiles(); + clientRes.writeHead(200, { "Content-Type": "application/json" }); + clientRes.end( + JSON.stringify({ + success: true, + message: "Configuration saved successfully", + totalRoutes: Object.keys(MOCK_ROUTES).length, + }), + ); + }) + .catch((error) => { + clientRes.writeHead(400, { "Content-Type": "application/json" }); + clientRes.end( + JSON.stringify({ + success: false, + error: (error as Error).message, + }), + ); + }); + return; + } + try { clientRes.writeHead(200, { "Content-Type": "application/json" }); clientRes.end( JSON.stringify( { - routes: MOCK_ROUTES, + routes: RAW_ROUTES, config: CONFIG, timestamp: new Date().toISOString(), totalRoutes: Object.keys(MOCK_ROUTES).length, @@ -296,6 +356,105 @@ const proxyServer = http.createServer((clientReq, clientRes) => { return; } + if (requestPath === "/__routes") { + if (clientReq.method !== "POST") { + clientRes.writeHead(405, { + "Content-Type": "application/json", + Allow: "POST", + }); + clientRes.end( + JSON.stringify({ + success: false, + error: "Method Not Allowed", + allow: ["POST"], + }), + ); + return; + } + + readBody(clientReq) + .then((bodyText) => { + const body = bodyText ? JSON.parse(bodyText) : {}; + const route = String(body.route || "").trim(); + const filePath = String(body.filePath || "").trim(); + const fileContent = String(body.fileContent || ""); + const overwrite = body.overwrite === true; + const template = String(body.template || "").trim(); + const useExistingFile = + body.useExistingFile === true || template === "basicError"; + + if (!route.startsWith("/")) { + throw new Error("route must start with '/'"); + } + if (!filePath) { + throw new Error("filePath is required"); + } + if (path.isAbsolute(filePath)) { + throw new Error("filePath must be a relative path"); + } + + const fullPath = path.join(__dirname, filePath); + if (!fullPath.startsWith(__dirname)) { + throw new Error("filePath is invalid"); + } + if (useExistingFile) { + if (!fs.existsSync(fullPath)) { + throw new Error("mock file does not exist"); + } + } else { + if (!overwrite && fs.existsSync(fullPath)) { + throw new Error( + "mock file already exists, set overwrite=true to replace", + ); + } + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, fileContent, "utf-8"); + } + + const nextConfig = buildCurrentConfigFile(); + nextConfig.routes[route] = filePath; + saveConfigFile(nextConfig); + + clientRes.writeHead(200, { "Content-Type": "application/json" }); + clientRes.end( + JSON.stringify({ + success: true, + message: "Route and mock file created successfully", + route, + filePath, + }), + ); + }) + .catch((error) => { + clientRes.writeHead(400, { "Content-Type": "application/json" }); + clientRes.end( + JSON.stringify({ + success: false, + error: (error as Error).message, + }), + ); + }); + return; + } + + if (requestPath === "/__admin") { + if (clientReq.method !== "GET") { + clientRes.writeHead(405, { "Content-Type": "text/plain; charset=utf-8" }); + clientRes.end("Method Not Allowed"); + return; + } + const adminHtmlPath = path.join(__dirname, "admin.html"); + if (!fs.existsSync(adminHtmlPath)) { + clientRes.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }); + clientRes.end("admin.html not found"); + return; + } + const html = fs.readFileSync(adminHtmlPath, "utf-8"); + clientRes.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + clientRes.end(html); + return; + } + // 检查是否为需要mock的路由 if (isMockRoute(requestPath)) { const mockFile = getMockFilePath(requestPath); diff --git a/mock/basic-error.json b/mock/basic-error.json new file mode 100644 index 0000000..90c2416 --- /dev/null +++ b/mock/basic-error.json @@ -0,0 +1 @@ +{"code": -1, "success": false, "msg": "失败"} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..bb23e25 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,168 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@types/node': + specifier: ^25.5.0 + version: 25.6.0 + ts-node: + specifier: ^10.9.2 + version: 10.9.2(@types/node@25.6.0)(typescript@5.9.3) + typescript: + specifier: ^5.7.3 + version: 5.9.3 + +packages: + + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + + '@tsconfig/node10@1.0.12': + resolution: {integrity: sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==} + + '@tsconfig/node12@1.0.11': + resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==} + + '@tsconfig/node14@1.0.3': + resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==} + + '@tsconfig/node16@1.0.4': + resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} + + '@types/node@25.6.0': + resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==} + + acorn-walk@8.3.5: + resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} + engines: {node: '>=0.4.0'} + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + arg@4.1.3: + resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} + + create-require@1.1.1: + resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + + diff@4.0.4: + resolution: {integrity: sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==} + engines: {node: '>=0.3.1'} + + make-error@1.3.6: + resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + + ts-node@10.9.2: + resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} + hasBin: true + peerDependencies: + '@swc/core': '>=1.2.50' + '@swc/wasm': '>=1.2.50' + '@types/node': '*' + typescript: '>=2.7' + peerDependenciesMeta: + '@swc/core': + optional: true + '@swc/wasm': + optional: true + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.19.2: + resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} + + v8-compile-cache-lib@3.0.1: + resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} + + yn@3.1.1: + resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} + engines: {node: '>=6'} + +snapshots: + + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@tsconfig/node10@1.0.12': {} + + '@tsconfig/node12@1.0.11': {} + + '@tsconfig/node14@1.0.3': {} + + '@tsconfig/node16@1.0.4': {} + + '@types/node@25.6.0': + dependencies: + undici-types: 7.19.2 + + acorn-walk@8.3.5: + dependencies: + acorn: 8.16.0 + + acorn@8.16.0: {} + + arg@4.1.3: {} + + create-require@1.1.1: {} + + diff@4.0.4: {} + + make-error@1.3.6: {} + + ts-node@10.9.2(@types/node@25.6.0)(typescript@5.9.3): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + '@tsconfig/node10': 1.0.12 + '@tsconfig/node12': 1.0.11 + '@tsconfig/node14': 1.0.3 + '@tsconfig/node16': 1.0.4 + '@types/node': 25.6.0 + acorn: 8.16.0 + acorn-walk: 8.3.5 + arg: 4.1.3 + create-require: 1.1.1 + diff: 4.0.4 + make-error: 1.3.6 + typescript: 5.9.3 + v8-compile-cache-lib: 3.0.1 + yn: 3.1.1 + + typescript@5.9.3: {} + + undici-types@7.19.2: {} + + v8-compile-cache-lib@3.0.1: {} + + yn@3.1.1: {}