diff --git a/admin.html b/admin.html index 0551e02..5c803c8 100644 --- a/admin.html +++ b/admin.html @@ -123,16 +123,27 @@ - + - Mock 数据配置 + 数据配置(mock) + + + + + 新增 Mock 文件 + 分组管理 + + + {{ getGroupName(scope.row.groupId) }} + + {{ scope.row.alias || "-" }} @@ -313,19 +324,37 @@ > - - - + + + + + + + + + + + + + + + + 导入 + + + + 新增分组 + + + + + + + {{ scope.row.name }} + + + + + {{ getGroupFileCount(scope.row.id) }} + + + + + 修改 + 删除 + + + + + + + + 分组「{{ deleteGroupDialog.groupName }}」下有 {{ deleteGroupDialog.fileCount }} 个 mock 文件。 + 请选择对这些文件的处理方式: + + + 转移到其他分组 + 取消分组(设为未分组) + 删除全部文件 + + + + + + + + 取消 + 确认删除 + + @@ -459,6 +555,22 @@ input: "", }, mockFiles: [], + mockGroups: [], + mockGroupFilter: null, + mockGroupDialog: { + visible: false, + mode: "create", + editingId: null, + form: { name: "" }, + }, + deleteGroupDialog: { + visible: false, + groupId: null, + groupName: "", + fileCount: 0, + action: "", + targetGroupId: null, + }, contentTypeOptions: [ "application/json", "application/x-www-form-urlencoded", @@ -515,6 +627,7 @@ filePath: "", statusCode: 200, enabled: true, + groupFilter: null, }, }, mockFileDialog: { @@ -531,11 +644,13 @@ watch: { routeSearch: function () { this.routePagination.currentPage = 1; }, mockFileSearch: function () { this.mockFilePagination.currentPage = 1; }, + mockGroupFilter: function () { this.mockFilePagination.currentPage = 1; }, apiSearch: function () { this.apiPagination.currentPage = 1; }, }, created: async function () { await this.loadApiList(); await this.loadMockFiles(); + await this.loadMockGroups(); await this.loadConfig(); }, methods: { @@ -648,6 +763,7 @@ filePath: "", statusCode: 200, enabled: true, + groupFilter: null, }; }, isApiListLocked: function () { @@ -663,6 +779,10 @@ this.routeDialog.form.route = selected.route; this.routeDialog.form.apiName = selected.name; }, + onRouteDialogGroupChange: function () { + // 切换分组时清空已选文件 + this.routeDialog.form.filePath = ""; + }, openRouteDialogForCreate: function () { this.routeDialog.mode = "create"; this.routeDialog.editingIndex = -1; @@ -678,6 +798,8 @@ this.routeDialog.mode = "edit"; this.routeDialog.editingIndex = actualIndex; var matched = this.findApiByRoute(item.route || ""); + var mockFile = this.mockFiles.find(function (f) { return f.filePath === item.filePath; }); + var groupFilter = mockFile && mockFile.groupId ? mockFile.groupId : null; this.routeDialog.form = { apiName: matched ? matched.name : item.apiName || "", selectedApiRoute: matched ? matched.route : "", @@ -686,6 +808,7 @@ filePath: item.filePath || "mock/", statusCode: this.normalizeStatusCode(item.statusCode), enabled: item.enabled !== false, + groupFilter: groupFilter, }; this.routeDialog.visible = true; }, @@ -694,6 +817,7 @@ fileName: "", alias: "", content: "", + groupId: null, }; }, openMockFileDialogForCreate: function () { @@ -709,6 +833,7 @@ fileName: name, alias: String(item.alias || ""), content: String(item.content || ""), + groupId: item.groupId || null, }; this.mockFileDialog.visible = true; }, @@ -745,6 +870,7 @@ filePath: filePath, alias: this.mockFileDialog.form.alias, content: this.mockFileDialog.form.content, + groupId: this.mockFileDialog.form.groupId, }), }); var data = await resp.json(); @@ -1010,6 +1136,148 @@ this.batchImportDialog.visible = false; await this.loadApiList(); }, + getGroupName: function (groupId) { + if (!groupId) return "未分组"; + var group = this.mockGroups.find(function (g) { return g.id === groupId; }); + return group ? group.name : "未分组"; + }, + getGroupFileCount: function (groupId) { + return this.mockFiles.filter(function (f) { return f.groupId === groupId; }).length; + }, + loadMockGroups: async function () { + try { + var resp = await fetch("/__mock-groups"); + var data = await resp.json(); + if (!resp.ok || data.success === false) { + throw new Error(data.error || "加载分组失败"); + } + this.mockGroups = Array.isArray(data.list) ? data.list : []; + } catch (err) { + this.mockGroups = []; + this.$message.error("加载分组失败: " + err.message); + } + }, + openMockGroupDialog: function () { + this.mockGroupDialog.editingId = null; + this.mockGroupDialog.form.name = ""; + this.mockGroupDialog.visible = true; + }, + addMockGroup: async function () { + var self = this; + this.$prompt("请输入分组名称", "新增分组", { + confirmButtonText: "确定", + cancelButtonText: "取消", + inputPattern: /\S+/, + inputErrorMessage: "分组名称不能为空", + }).then(async function (res) { + var name = (res.value || "").trim(); + try { + var resp = await fetch("/__mock-groups", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: name }), + }); + var data = await resp.json(); + if (!resp.ok || data.success === false) { + throw new Error(data.error || "新增分组失败"); + } + self.$message.success("分组已创建"); + await self.loadMockGroups(); + } catch (err) { + self.$message.error("新增分组失败: " + err.message); + } + }).catch(function () {}); + }, + startEditMockGroup: function (group) { + this.mockGroupDialog.editingId = group.id; + this.mockGroupDialog.form.name = group.name; + }, + saveMockGroupEdit: async function (group) { + var newName = (this.mockGroupDialog.form.name || "").trim(); + this.mockGroupDialog.editingId = null; + if (!newName || newName === group.name) return; + try { + var resp = await fetch("/__mock-groups", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: group.id, name: newName }), + }); + var data = await resp.json(); + if (!resp.ok || data.success === false) { + throw new Error(data.error || "修改分组失败"); + } + this.$message.success("分组已修改"); + await this.loadMockGroups(); + } catch (err) { + this.$message.error("修改分组失败: " + err.message); + } + }, + confirmDeleteGroup: async function (group) { + var fileCount = this.getGroupFileCount(group.id); + if (fileCount === 0) { + // 无文件,直接删除 + try { + var resp = await fetch("/__mock-groups", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: group.id }), + }); + var data = await resp.json(); + if (!resp.ok || data.success === false) { + throw new Error(data.error || "删除分组失败"); + } + this.$message.success("分组已删除"); + await this.loadMockGroups(); + } catch (err) { + this.$message.error("删除分组失败: " + err.message); + } + return; + } + // 有文件,弹出确认对话框 + this.deleteGroupDialog.groupId = group.id; + this.deleteGroupDialog.groupName = group.name; + this.deleteGroupDialog.fileCount = fileCount; + this.deleteGroupDialog.action = ""; + this.deleteGroupDialog.targetGroupId = null; + this.deleteGroupDialog.visible = true; + }, + submitDeleteGroup: async function () { + var action = this.deleteGroupDialog.action; + if (!action) { + this.$message.error("请选择处理方式"); + return; + } + if (action === "reassign" && !this.deleteGroupDialog.targetGroupId) { + this.$message.error("请选择目标分组"); + return; + } + var payload = { + id: this.deleteGroupDialog.groupId, + action: action === "unassign" ? "reassign" : action, + }; + if (action === "reassign") { + payload.targetGroupId = this.deleteGroupDialog.targetGroupId; + } else if (action === "unassign") { + payload.targetGroupId = null; + } + try { + var resp = await fetch("/__mock-groups", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + var data = await resp.json(); + if (!resp.ok || data.success === false) { + throw new Error(data.error || "删除分组失败"); + } + this.$message.success("分组已删除"); + this.deleteGroupDialog.visible = false; + await this.loadMockGroups(); + await this.loadMockFiles(); + } catch (err) { + this.$message.error("删除分组失败: " + err.message); + } + }, }, computed: { filteredRoutes: function () { @@ -1030,13 +1298,24 @@ }, filteredMockFiles: function () { var q = (this.mockFileSearch || "").toLowerCase().trim(); - if (!q) return this.mockFiles; - return this.mockFiles.filter(function (item) { - return ( - (item.filePath || "").toLowerCase().indexOf(q) !== -1 || - (item.alias || "").toLowerCase().indexOf(q) !== -1 - ); - }); + var groupFilter = this.mockGroupFilter; + var list = this.mockFiles; + if (groupFilter !== null && groupFilter !== undefined) { + if (groupFilter === -1) { + list = list.filter(function (item) { return !item.groupId; }); + } else { + list = list.filter(function (item) { return item.groupId === groupFilter; }); + } + } + if (q) { + list = list.filter(function (item) { + return ( + (item.filePath || "").toLowerCase().indexOf(q) !== -1 || + (item.alias || "").toLowerCase().indexOf(q) !== -1 + ); + }); + } + return list; }, filteredApiList: function () { var q = (this.apiSearch || "").toLowerCase().trim(); @@ -1057,6 +1336,16 @@ pagedApiList: function () { return this.getPagedData(this.filteredApiList, this.apiPagination); }, + routeDialogMockFiles: function () { + var groupFilter = this.routeDialog.form.groupFilter; + if (groupFilter === null || groupFilter === undefined) { + return this.mockFiles; + } + if (groupFilter === -1) { + return this.mockFiles.filter(function (f) { return !f.groupId; }); + } + return this.mockFiles.filter(function (f) { return f.groupId === groupFilter; }); + }, }, }); diff --git a/src/admin-handlers.ts b/src/admin-handlers.ts index c303651..c7e717e 100644 --- a/src/admin-handlers.ts +++ b/src/admin-handlers.ts @@ -9,6 +9,13 @@ import { insertApiItemToDb, updateApiItemInDb, deleteApiItemFromDb, + loadMockGroups, + insertMockGroup, + updateMockGroup, + deleteMockGroup, + countMockFilesByGroup, + updateMockFilesGroupId, + deleteMockFilesByGroup, } from "./db"; import { loadMockFiles, @@ -393,9 +400,10 @@ async function handleMockFiles( if (clientReq.method === "POST") { const content = String(body.content || ""); + const groupId = body.groupId != null ? Number(body.groupId) : null; fs.mkdirSync(path.dirname(fullPath), { recursive: true }); fs.writeFileSync(fullPath, content, "utf-8"); - await upsertMockFilePathToDb(normalizedPath, alias); + await upsertMockFilePathToDb(normalizedPath, alias, groupId); clientRes.writeHead(200, { "Content-Type": "application/json" }); clientRes.end( JSON.stringify({ @@ -432,6 +440,113 @@ async function handleMockFiles( }); } +async function handleMockGroups( + clientReq: http.IncomingMessage, + clientRes: http.ServerResponse, +): Promise { + if ( + clientReq.method !== "GET" && + clientReq.method !== "POST" && + clientReq.method !== "PUT" && + clientReq.method !== "DELETE" + ) { + clientRes.writeHead(405, { + "Content-Type": "application/json", + Allow: "GET, POST, PUT, DELETE", + }); + clientRes.end( + JSON.stringify({ + success: false, + error: "Method Not Allowed", + allow: ["GET, POST, PUT, DELETE"], + }), + ); + return; + } + + if (clientReq.method === "GET") { + const list = await loadMockGroups(); + clientRes.writeHead(200, { "Content-Type": "application/json" }); + clientRes.end(JSON.stringify({ success: true, list })); + return; + } + + readBody(clientReq) + .then(async (bodyText) => { + const body = bodyText ? JSON.parse(bodyText) : {}; + + if (clientReq.method === "POST") { + const name = String(body.name || "").trim(); + if (!name) { + throw new Error("分组名称不能为空"); + } + const id = await insertMockGroup(name); + clientRes.writeHead(200, { "Content-Type": "application/json" }); + clientRes.end(JSON.stringify({ success: true, id, name })); + return; + } + + if (clientReq.method === "PUT") { + const id = Number(body.id); + const name = String(body.name || "").trim(); + if (!id || !name) { + throw new Error("id 和 name 不能为空"); + } + await updateMockGroup(id, name); + clientRes.writeHead(200, { "Content-Type": "application/json" }); + clientRes.end(JSON.stringify({ success: true, id, name })); + return; + } + + // DELETE + const id = Number(body.id); + if (!id) { + throw new Error("id 不能为空"); + } + const fileCount = await countMockFilesByGroup(id); + const action = String(body.action || "").trim(); + + if (fileCount > 0 && action === "reassign") { + const targetGroupId = body.targetGroupId != null ? Number(body.targetGroupId) : null; + await updateMockFilesGroupId(id, targetGroupId); + } else if (fileCount > 0 && action === "delete") { + const deletedFiles = await deleteMockFilesByGroup(id); + // 删除磁盘上的文件 + for (const file of deletedFiles) { + const fullPath = path.join(__dirname, "..", file.file_path); + if (fs.existsSync(fullPath)) { + fs.unlinkSync(fullPath); + } + } + } else if (fileCount > 0) { + // 有文件但未指定 action,返回需要确认的信息 + clientRes.writeHead(200, { "Content-Type": "application/json" }); + clientRes.end( + JSON.stringify({ + success: false, + needConfirm: true, + fileCount, + message: `该分组下有 ${fileCount} 个 mock 文件,请指定操作`, + }), + ); + return; + } + + await deleteMockGroup(id); + clientRes.writeHead(200, { "Content-Type": "application/json" }); + clientRes.end(JSON.stringify({ success: true, id })); + }) + .catch((error) => { + clientRes.writeHead(400, { "Content-Type": "application/json" }); + clientRes.end( + JSON.stringify({ + success: false, + error: (error as Error).message, + }), + ); + }); +} + async function handleAdminPage( clientReq: http.IncomingMessage, clientRes: http.ServerResponse, @@ -476,6 +591,9 @@ export async function dispatchAdmin( case "/__mock-files": await handleMockFiles(clientReq, clientRes); return true; + case "/__mock-groups": + await handleMockGroups(clientReq, clientRes); + return true; case "/__admin": await handleAdminPage(clientReq, clientRes); return true; diff --git a/src/db.ts b/src/db.ts index 5ca289f..74d44e2 100644 --- a/src/db.ts +++ b/src/db.ts @@ -7,6 +7,7 @@ import type { ApiListItem, AppConfig, MockFileRow, + MockGroupRow, RouteApiNameMap, RouteConfig, RouteEnabledMap, @@ -100,6 +101,12 @@ export async function initDatabase(): Promise { name TEXT NOT NULL ) `); + await dbRun(` + CREATE TABLE IF NOT EXISTS mock_groups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE + ) + `); await dbRun(` CREATE TABLE IF NOT EXISTS mock_files ( file_path TEXT PRIMARY KEY, @@ -111,6 +118,11 @@ export async function initDatabase(): Promise { ).catch(() => { // ignore when column already exists }); + await dbRun( + "ALTER TABLE mock_files ADD COLUMN group_id INTEGER", + ).catch(() => { + // ignore when column already exists + }); await dbRun(` CREATE TABLE IF NOT EXISTS server_config ( key TEXT PRIMARY KEY, @@ -211,8 +223,16 @@ export async function deleteApiItemFromDb(route: string): Promise { export async function upsertMockFilePathToDb( filePath: string, alias?: string, + groupId?: number | null, ): Promise { if (typeof alias === "string") { + if (typeof groupId !== "undefined") { + await dbRun( + "INSERT INTO mock_files(file_path, alias, group_id) VALUES(?, ?, ?) ON CONFLICT(file_path) DO UPDATE SET alias = excluded.alias, group_id = excluded.group_id", + [filePath, alias, groupId ?? null], + ); + return; + } await dbRun( "INSERT INTO mock_files(file_path, alias) VALUES(?, ?) ON CONFLICT(file_path) DO UPDATE SET alias = excluded.alias", [filePath, alias], @@ -230,14 +250,75 @@ export async function removeMockFilePathFromDb(filePath: string): Promise export async function loadMockFilePathsFromDb(): Promise { const rows = await dbAll( - "SELECT file_path, alias FROM mock_files ORDER BY file_path ASC", + "SELECT file_path, alias, group_id FROM mock_files ORDER BY file_path ASC", ); return rows.map((row) => ({ file_path: row.file_path, alias: String(row.alias || ""), + group_id: row.group_id ?? null, })); } +// ── mock_groups CRUD ── + +export async function loadMockGroups(): Promise { + const rows = await dbAll( + "SELECT id, name FROM mock_groups ORDER BY id ASC", + ); + return rows.map((row) => ({ id: row.id, name: row.name })); +} + +export async function insertMockGroup(name: string): Promise { + await dbRun("INSERT INTO mock_groups(name) VALUES(?)", [name]); + const row = await dbAll<{ id: number }>( + "SELECT id FROM mock_groups WHERE name = ?", + [name], + ); + return row[0]?.id ?? 0; +} + +export async function updateMockGroup( + id: number, + name: string, +): Promise { + await dbRun("UPDATE mock_groups SET name = ? WHERE id = ?", [name, id]); +} + +export async function deleteMockGroup(id: number): Promise { + await dbRun("DELETE FROM mock_groups WHERE id = ?", [id]); +} + +export async function countMockFilesByGroup( + groupId: number, +): Promise { + const rows = await dbAll<{ cnt: number }>( + "SELECT COUNT(*) AS cnt FROM mock_files WHERE group_id = ?", + [groupId], + ); + return rows[0]?.cnt ?? 0; +} + +export async function updateMockFilesGroupId( + oldGroupId: number, + newGroupId: number | null, +): Promise { + await dbRun("UPDATE mock_files SET group_id = ? WHERE group_id = ?", [ + newGroupId, + oldGroupId, + ]); +} + +export async function deleteMockFilesByGroup( + groupId: number, +): Promise { + const rows = await dbAll( + "SELECT file_path FROM mock_files WHERE group_id = ?", + [groupId], + ); + await dbRun("DELETE FROM mock_files WHERE group_id = ?", [groupId]); + return rows.map((row) => ({ file_path: row.file_path, alias: "", group_id: null })); +} + const DEFAULT_SERVER_CONFIG: AppConfig = { cacheConfig: true, reloadOnChange: true, diff --git a/src/mock-files.ts b/src/mock-files.ts index 361cecf..2749c82 100644 --- a/src/mock-files.ts +++ b/src/mock-files.ts @@ -63,7 +63,7 @@ export async function loadMockFiles(): Promise { for (const filePath of fsFiles) { await upsertMockFilePathToDb(filePath); } - files = fsFiles.map((filePath) => ({ file_path: filePath, alias: "" })); + files = fsFiles.map((filePath) => ({ file_path: filePath, alias: "", group_id: null })); } const result: MockFileItem[] = []; @@ -75,6 +75,7 @@ export async function loadMockFiles(): Promise { filePath, alias: String(item.alias || ""), content: fs.readFileSync(fullPath, "utf-8"), + groupId: item.group_id ?? null, }); } return result; diff --git a/src/types.ts b/src/types.ts index 45de4e2..b136eb2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -35,6 +35,7 @@ export interface MockFileItem { filePath: string; alias: string; content: string; + groupId: number | null; } export interface RouteRow { @@ -53,7 +54,13 @@ export interface RouteApiNameMap { [route: string]: string; } +export interface MockGroupRow { + id: number; + name: string; +} + export interface MockFileRow { file_path: string; alias: string; + group_id: number | null; }
分组「{{ deleteGroupDialog.groupName }}」下有 {{ deleteGroupDialog.fileCount }} 个 mock 文件。
请选择对这些文件的处理方式: