596 lines
20 KiB
JavaScript
596 lines
20 KiB
JavaScript
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 <admin-module.json> [--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 backend routes are implemented.`);
|
|
|
|
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, "backend-seed-snippet.go.txt"), renderBackendSeedSnippet(config), { overwrite: true });
|
|
planWrite(path.join(generatedDir, "backend-routes-snippet.go.txt"), renderBackendRoutesSnippet(config), { overwrite: true });
|
|
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<string, unknown>;
|
|
|
|
${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<Response> {
|
|
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<ApiPage<${config.singularName}Record>> {
|
|
return apiRequest<ApiPage<${config.singularName}Record>>(apiEndpointPath(${operationKey}), { query });
|
|
}`;
|
|
}
|
|
|
|
if (route.hasId) {
|
|
typeImports.add("EntityId");
|
|
}
|
|
|
|
if (route.method === "DELETE") {
|
|
return `export function ${route.operationId}(id: EntityId): Promise<unknown> {
|
|
${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<string, unknown>" : "payload: Record<string, unknown>";
|
|
const pathParams = route.hasId ? ", { id }" : "";
|
|
return `export function ${route.operationId}(${params}): Promise<${config.singularName}Record> {
|
|
${endpointLine}
|
|
return apiRequest<${config.singularName}Record, Record<string, unknown>>(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<typeof ${config.singularCamel}FormSchema>;
|
|
`;
|
|
}
|
|
|
|
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 (
|
|
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
|
<section className={styles.root} />
|
|
</DataState>
|
|
);
|
|
}
|
|
`;
|
|
}
|
|
|
|
function renderCss() {
|
|
return `.root {
|
|
min-height: 100%;
|
|
}
|
|
`;
|
|
}
|
|
|
|
function renderBackendSeedSnippet(config) {
|
|
const permissions = config.permissions
|
|
.map((permission) => `\t{Name: "${permission.name}", Code: "${permission.code}", Kind: "${permission.kind}"},`)
|
|
.join("\n");
|
|
return `// Add to hyapp-server/server/admin/internal/repository/seed.go defaultPermissions.
|
|
${permissions}
|
|
|
|
// Add to defaultMenus.
|
|
{Title: "${config.label}", Code: "${config.menuCode}", Path: "${config.routePath}", Icon: "${config.icon}", PermissionCode: "${config.viewPermission.code}", Sort: ${config.sort}, Visible: true},
|
|
`;
|
|
}
|
|
|
|
function renderBackendRoutesSnippet(config) {
|
|
const lines = config.apiRoutes.map((route) => {
|
|
const permissionGuard = route.permission
|
|
? `, middleware.RequirePermission("${route.permission}")`
|
|
: "";
|
|
return `protected.${route.method}("${toGinPath(route.path)}"${permissionGuard}, h.${route.handler})`;
|
|
});
|
|
return `// Add to the module RegisterRoutes function.
|
|
${lines.join("\n")}
|
|
`;
|
|
}
|
|
|
|
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}
|
|
|
|
## 后端路由
|
|
|
|
| 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 toGinPath(routePath) {
|
|
return routePath.replace(/\{([^}]+)\}/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)])
|
|
);
|
|
}
|