import { existsSync } from "node:fs"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const args = process.argv.slice(2); const configArg = args.find((arg) => !arg.startsWith("--")); const dryRun = args.includes("--dry-run"); const skipApiGenerate = args.includes("--skip-api-generate"); if (!configArg) { console.error("Usage: pnpm gen:module [--dry-run] [--skip-api-generate]"); process.exit(1); } const configPath = path.resolve(process.cwd(), configArg); const rawConfig = JSON.parse(await readFile(configPath, "utf8")); const moduleConfig = normalizeConfig(rawConfig); const plannedWrites = []; queueFileWrites(moduleConfig); await updatePermissions(moduleConfig); await updateRouteConfig(moduleConfig); await updateOpenApiContract(moduleConfig); if (dryRun) { console.log(`Dry run: ${plannedWrites.length} file changes planned.`); for (const write of plannedWrites) { console.log(`${write.exists ? "Update" : "Create"} ${path.relative(rootDir, write.filePath)}`); } process.exit(0); } for (const write of plannedWrites) { await mkdir(path.dirname(write.filePath), { recursive: true }); if (!write.overwrite && existsSync(write.filePath)) { console.log(`Skipped existing ${path.relative(rootDir, write.filePath)}`); continue; } await writeFile(write.filePath, write.content); console.log(`${write.exists ? "Updated" : "Created"} ${path.relative(rootDir, write.filePath)}`); } if (!skipApiGenerate) { run("pnpm", ["exec", "openapi-typescript", "contracts/admin-openapi.json", "-o", "src/shared/api/generated/schema.d.ts"]); run(process.execPath, ["scripts/generate-api-client.mjs"]); } console.log(`Module ${moduleConfig.name} generated. Run pnpm check:contracts after updating the local API contract.`); function normalizeConfig(config) { const name = toKebabCase(required(config.name, "name")); const camelName = toCamelCase(name); const pascalName = toPascalCase(name); const singularName = toPascalCase(config.singularName || singularize(name)); const singularCamel = lowerFirst(singularName); const label = required(config.label, "label"); const routePath = config.path || `/${name}`; const menuCode = config.menuCode || name; const permissionPrefix = config.permissionPrefix || singularize(name); const pageKey = config.pageKey || name; const menuKey = config.menuKey || toCamelCase(menuCode); const permissions = normalizePermissions(config.permissions || config.actions || defaultActions(), { label, permissionPrefix }); const viewPermission = permissions.find((permission) => permission.action === "view") || permissions[0]; if (!viewPermission) { throw new Error("At least one permission is required"); } const apiRoutes = normalizeApiRoutes(config.apiRoutes || defaultApiRoutes(routePath, viewPermission.code), { name, permissions, routePath, singularName }); return { apiRoutes, camelName, icon: config.icon || "menu", label, menuCode, menuKey, name, pageKey, parentMenuCode: config.parentMenuCode || "", pascalName, permissionPrefix, permissions, routePath, singularCamel, singularName, sort: Number(config.sort || 100), viewPermission }; } function queueFileWrites(config) { const featureDir = path.join(rootDir, "src/features", config.name); const files = new Map([ ["api.ts", renderApi(config)], ["routes.js", renderRoutes(config)], ["permissions.js", renderFeaturePermissions(config)], ["schema.ts", renderSchema(config)], [`hooks/use${config.pascalName}Page.js`, renderPageHook(config)], [`pages/${config.pascalName}Page.jsx`, renderPage(config)], [`${config.name}.module.css`, renderCss()], ["components/.gitkeep", ""] ]); for (const [relativePath, content] of files) { const filePath = path.join(featureDir, relativePath); planWrite(filePath, content, { overwrite: false }); } const generatedDir = path.join(rootDir, "generated/admin-modules", config.name); planWrite(path.join(generatedDir, "permissions.md"), renderPermissionChecklist(config), { overwrite: true }); } async function updatePermissions(config) { const filePath = path.join(rootDir, "src/app/permissions.ts"); let source = await readFile(filePath, "utf8"); const permissionEntries = config.permissions.map((permission) => ({ key: permission.key, value: permission.code })); const menuEntry = { key: config.menuKey, value: config.menuCode }; source = insertObjectEntries(source, "PERMISSIONS", permissionEntries); source = insertObjectEntries(source, "MENU_CODES", [menuEntry]); planWrite(filePath, source, { overwrite: true }); } async function updateRouteConfig(config) { const filePath = path.join(rootDir, "src/app/router/routeConfig.ts"); let source = await readFile(filePath, "utf8"); const importLine = `import { ${config.camelName}Routes } from "@/features/${config.name}/routes.js";`; const routeSpread = ` ...${config.camelName}Routes,`; if (!source.includes(importLine)) { source = source.replace(/(import type \{ MenuCode \} from "@\/app\/permissions";)/, `${importLine}\n$1`); } if (!source.includes(routeSpread)) { const routesStart = source.indexOf("export const adminRoutes: AdminRoute[] = ["); const routesEnd = source.indexOf("\n];", routesStart); if (routesStart < 0 || routesEnd < 0) { throw new Error("Cannot find adminRoutes block in routeConfig.ts"); } const beforeRoutesEnd = source.slice(0, routesEnd); const afterRoutesEnd = source.slice(routesEnd); const separator = beforeRoutesEnd.trimEnd().endsWith(",") ? "\n" : ",\n"; source = `${beforeRoutesEnd}${separator}${routeSpread}${afterRoutesEnd}`; } planWrite(filePath, source, { overwrite: true }); } async function updateOpenApiContract(config) { const filePath = path.join(rootDir, "contracts/admin-openapi.json"); const contract = JSON.parse(await readFile(filePath, "utf8")); contract.paths = contract.paths || {}; for (const route of config.apiRoutes) { const openApiPath = toOpenApiPath(route.path); contract.paths[openApiPath] = contract.paths[openApiPath] || {}; contract.paths[openApiPath][route.method.toLowerCase()] = { operationId: route.operationId, ...(route.permission ? { "x-permission": route.permission, "x-permissions": [route.permission] } : {}), ...(pathParams(openApiPath).length ? { parameters: pathParams(openApiPath).map((name) => name === "id" ? { "$ref": "#/components/parameters/Id" } : { in: "path", name, required: true, schema: { type: "integer" } } ) } : {}), responses: { "200": { "$ref": "#/components/responses/EmptyResponse" } } }; } contract.paths = sortObject(contract.paths, (pathItem) => sortObject(pathItem, undefined, ["get", "post", "put", "patch", "delete"])); planWrite(filePath, `${JSON.stringify(contract, null, 2)}\n`, { overwrite: true }); } function renderApi(config) { const imports = new Set(["apiRequest"]); const typeImports = new Set(); const functions = config.apiRoutes.map((route) => renderApiFunction(route, config, typeImports)); const needsEndpointObject = config.apiRoutes.some((route) => route.method !== "GET" || route.raw); const endpointImports = needsEndpointObject ? "API_ENDPOINTS, API_OPERATIONS, apiEndpointPath" : "API_OPERATIONS, apiEndpointPath"; return `import { ${[...imports].join(", ")} } from "@/shared/api/request"; import { ${endpointImports} } from "@/shared/api/generated/endpoints"; ${typeImports.size ? `import type { ${[...typeImports].sort().join(", ")} } from "@/shared/api/types";\n` : ""} type ${config.singularName}Record = Record; ${functions.join("\n\n")} `; } function renderApiFunction(route, config, typeImports) { const operationKey = `API_OPERATIONS.${route.operationId}`; const endpointLine = ` const endpoint = API_ENDPOINTS.${route.operationId};`; if (route.raw) { typeImports.add("PageQuery"); return `export function ${route.operationId}(query?: PageQuery): Promise { return apiRequest(apiEndpointPath(${operationKey}), { query, raw: true }); }`; } if (route.kind === "list") { typeImports.add("ApiPage"); typeImports.add("PageQuery"); return `export function ${route.operationId}(query?: PageQuery): Promise> { return apiRequest>(apiEndpointPath(${operationKey}), { query }); }`; } if (route.hasId) { typeImports.add("EntityId"); } if (route.method === "DELETE") { return `export function ${route.operationId}(id: EntityId): Promise { ${endpointLine} return apiRequest(apiEndpointPath(${operationKey}, { id }), { method: endpoint.method }); }`; } if (route.method === "GET") { const params = route.hasId ? "id: EntityId" : ""; const pathParams = route.hasId ? ", { id }" : ""; return `export function ${route.operationId}(${params}): Promise<${config.singularName}Record> { return apiRequest<${config.singularName}Record>(apiEndpointPath(${operationKey}${pathParams})); }`; } const params = route.hasId ? "id: EntityId, payload: Record" : "payload: Record"; const pathParams = route.hasId ? ", { id }" : ""; return `export function ${route.operationId}(${params}): Promise<${config.singularName}Record> { ${endpointLine} return apiRequest<${config.singularName}Record, Record>(apiEndpointPath(${operationKey}${pathParams}), { body: payload, method: endpoint.method }); }`; } function renderRoutes(config) { return `import { MENU_CODES, PERMISSIONS } from "@/app/permissions"; export const ${config.camelName}Routes = [ { label: ${JSON.stringify(config.label)}, loader: () => import("./pages/${config.pascalName}Page.jsx").then((module) => module.${config.pascalName}Page), menuCode: MENU_CODES.${config.menuKey}, pageKey: ${JSON.stringify(config.pageKey)}, path: ${JSON.stringify(config.routePath)}, permission: PERMISSIONS.${config.viewPermission.key} } ]; `; } function renderFeaturePermissions(config) { const entries = config.permissions .map((permission) => ` ${abilityName(permission.action)}: can(PERMISSIONS.${permission.key})`) .join(",\n"); return `import { useAuth } from "@/app/auth/AuthProvider.jsx"; import { PERMISSIONS } from "@/app/permissions"; export function use${config.singularName}Abilities() { const { can } = useAuth(); return { ${entries} }; } `; } function renderSchema(config) { return `import { z } from "zod"; export const ${config.singularCamel}FormSchema = z.record(z.string(), z.unknown()); export type ${config.singularName}Form = z.infer; `; } function renderPageHook(config) { const listRoute = config.apiRoutes.find((route) => route.kind === "list") || config.apiRoutes[0]; return `import { useCallback } from "react"; import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js"; import { ${listRoute.operationId} } from "@/features/${config.name}/api"; import { use${config.singularName}Abilities } from "@/features/${config.name}/permissions.js"; const emptyData = { items: [], total: 0, page: 1, pageSize: 10 }; export function use${config.pascalName}Page() { const abilities = use${config.singularName}Abilities(); const queryFn = useCallback(() => ${listRoute.operationId}({ page: 1, page_size: 10 }), []); const { data, error, loading, reload } = useAdminQuery(queryFn, { errorMessage: "加载失败", initialData: emptyData, queryKey: [${JSON.stringify(config.name)}] }); return { abilities, data, error, loading, reload }; } `; } function renderPage(config) { return `import { DataState } from "@/shared/ui/DataState.jsx"; import { use${config.pascalName}Page } from "@/features/${config.name}/hooks/use${config.pascalName}Page.js"; import styles from "@/features/${config.name}/${config.name}.module.css"; export function ${config.pascalName}Page() { const page = use${config.pascalName}Page(); return (
); } `; } function renderCss() { return `.root { min-height: 100%; } `; } function renderPermissionChecklist(config) { const rows = config.permissions .map((permission) => `| \`${permission.code}\` | ${permission.kind} | ${permission.name} |`) .join("\n"); const routeRows = config.apiRoutes .map((route) => `| ${route.method} | \`${route.path}\` | \`${route.permission || "登录态"}\` | ${route.handler} |`) .join("\n"); return `# ${config.label} ## 权限 | Code | Kind | Name | | --- | --- | --- | ${rows} ## API 路由 | Method | Path | Permission | Handler | | --- | --- | --- | --- | ${routeRows} `; } function normalizePermissions(value, context) { return value.map((item) => { const permission = typeof item === "string" ? { action: item } : item; const action = permission.action || permission.key; const code = permission.code || `${context.permissionPrefix}:${action}`; const key = permission.permissionKey || toPermissionKey(code); return { action, code, key, kind: permission.kind || (action === "view" ? "menu" : "button"), name: permission.name || `${context.label}${actionLabel(action)}` }; }); } function normalizeApiRoutes(value, context) { const permissionByAction = new Map(context.permissions.map((permission) => [permission.action, permission.code])); return value.map((route) => { const method = (route.method || "GET").toUpperCase(); const routePath = route.path || context.routePath; const permission = route.permission || permissionByAction.get(route.action || "view") || ""; const operationId = route.operationId || inferOperationId({ method, routePath, route, context }); return { handler: route.handler || toPascalCase(operationId), hasId: routePath.includes("{id}") || routePath.includes(":id"), kind: route.kind || inferRouteKind(method, routePath), method, operationId, path: toOpenApiPath(routePath), permission, raw: Boolean(route.raw) || routePath.includes("export") }; }); } function defaultActions() { return ["view", "create", "update", "delete", "export"]; } function defaultApiRoutes(routePath, viewPermission) { return [{ action: "view", kind: "list", method: "GET", path: routePath, permission: viewPermission }]; } function inferOperationId({ method, routePath, route, context }) { if (route.action === "export" || routePath.includes("export")) { return `export${context.pascalName}`; } if (route.kind === "list" || (method === "GET" && !routePath.includes("{id}") && !routePath.includes(":id"))) { return `list${context.pascalName}`; } if (method === "GET") { return `get${context.singularName}`; } if (method === "POST") { return routePath.includes(":id") || routePath.includes("{id}") ? `${route.action || "run"}${context.singularName}` : `create${context.singularName}`; } if (method === "PATCH" || method === "PUT") { return `update${context.singularName}`; } if (method === "DELETE") { return `delete${context.singularName}`; } return `${method.toLowerCase()}${context.singularName}`; } function inferRouteKind(method, routePath) { if (method === "GET" && !routePath.includes("{id}") && !routePath.includes(":id")) { return "list"; } return "item"; } function insertObjectEntries(source, objectName, entries) { const objectStart = source.indexOf(`export const ${objectName} = {`); if (objectStart < 0) { throw new Error(`Cannot find ${objectName} in permissions.ts`); } const objectEnd = source.indexOf("\n} as const;", objectStart); if (objectEnd < 0) { throw new Error(`Cannot find ${objectName} closing in permissions.ts`); } const before = source.slice(0, objectEnd); const after = source.slice(objectEnd); const existingBlock = source.slice(objectStart, objectEnd); const additions = entries .filter((entry) => !existingBlock.includes(`${entry.key}:`) && !existingBlock.includes(`"${entry.value}"`)) .map((entry) => ` ${entry.key}: "${entry.value}"`); if (!additions.length) { return source; } const separator = before.trimEnd().endsWith(",") ? "\n" : ",\n"; return `${before}${separator}${additions.join(",\n")}${after}`; } function planWrite(filePath, content, options) { plannedWrites.push({ content, exists: existsSync(filePath), filePath, overwrite: options.overwrite }); } function run(command, commandArgs) { const result = spawnSync(command, commandArgs, { cwd: rootDir, stdio: "inherit" }); if (result.status !== 0) { process.exit(result.status || 1); } } function required(value, name) { if (!value) { throw new Error(`Missing required config field: ${name}`); } return value; } function pathParams(routePath) { return [...routePath.matchAll(/\{([^}]+)\}/g)].map((match) => match[1]); } function toOpenApiPath(routePath) { return routePath.replace(/:([A-Za-z0-9_]+)/g, "{$1}"); } function toKebabCase(value) { return value .replace(/([a-z0-9])([A-Z])/g, "$1-$2") .replace(/[^a-zA-Z0-9]+/g, "-") .replace(/^-|-$/g, "") .toLowerCase(); } function toCamelCase(value) { return value.replace(/-([a-z0-9])/g, (_, letter) => letter.toUpperCase()); } function toPascalCase(value) { const camel = toCamelCase(toKebabCase(value)); return camel.charAt(0).toUpperCase() + camel.slice(1); } function lowerFirst(value) { return value.charAt(0).toLowerCase() + value.slice(1); } function singularize(value) { return value.endsWith("ies") ? `${value.slice(0, -3)}y` : value.endsWith("s") ? value.slice(0, -1) : value; } function toPermissionKey(code) { return toCamelCase(code.replace(/:/g, "-")); } function abilityName(action) { return `can${toPascalCase(action)}`; } function actionLabel(action) { const labels = { audit: "审核", config: "配置", create: "创建", delete: "删除", export: "导出", import: "导入", status: "状态变更", update: "更新", view: "查看" }; return labels[action] || action; } function sortObject(object, mapValue = (value) => value, preferredOrder = []) { return Object.fromEntries( Object.entries(object) .sort(([left], [right]) => { const leftIndex = preferredOrder.indexOf(left); const rightIndex = preferredOrder.indexOf(right); if (leftIndex >= 0 || rightIndex >= 0) { return (leftIndex < 0 ? Number.MAX_SAFE_INTEGER : leftIndex) - (rightIndex < 0 ? Number.MAX_SAFE_INTEGER : rightIndex); } return left.localeCompare(right); }) .map(([key, value]) => [key, mapValue(value)]) ); }