chore: 并入 iotlcm-admin-ui 历史到 admin-ui/(原提交 f305eab)

This commit is contained in:
MiFaZhan
2026-11-02 13:27:59 +08:00
1629 changed files with 189934 additions and 0 deletions
@@ -0,0 +1,427 @@
import type { IncomingMessage, ServerResponse } from 'node:http';
import type { PluginOption } from 'vite';
import { Buffer } from 'node:buffer';
import { constants as fsConstants } from 'node:fs';
import fs from 'node:fs/promises';
import path from 'node:path';
interface ApiTypeGeneratorOptions {
apiRoot: string;
}
interface DirNode {
children?: DirNode[];
key: string;
title: string;
value: string;
}
interface WriteTypePayload {
autoImport?: boolean;
content?: string;
dir?: string;
fileName?: string;
overwrite?: boolean;
}
interface CheckTypeFilePayload {
items?: CheckTypeFileItem[];
}
interface CheckTypeFileItem {
dir?: string;
fileName?: string;
key?: string;
}
const API_TYPE_GENERATOR_PREFIX = '/__api-type-generator__';
export function apiTypeGeneratorPlugin(
options: ApiTypeGeneratorOptions,
): PluginOption {
const apiRoot = path.resolve(options.apiRoot);
return {
apply: 'serve',
configureServer(server) {
server.middlewares.use(async (request, response, next) => {
const url = request.url?.split('?')[0] ?? '';
if (!url.startsWith(API_TYPE_GENERATOR_PREFIX)) {
next();
return;
}
try {
if (
request.method === 'GET' &&
url === `${API_TYPE_GENERATOR_PREFIX}/dirs`
) {
const data = await scanApiDirs(apiRoot);
sendJson(response, 200, { data, success: true });
return;
}
if (
request.method === 'POST' &&
url === `${API_TYPE_GENERATOR_PREFIX}/exists`
) {
const payload = await readJsonBody<CheckTypeFilePayload>(request);
const data = await checkTypeFiles(apiRoot, payload);
sendJson(response, 200, { data, success: true });
return;
}
if (
request.method === 'POST' &&
url === `${API_TYPE_GENERATOR_PREFIX}/write`
) {
const payload = await readJsonBody<WriteTypePayload>(request);
const data = await writeTypeFile(apiRoot, payload);
sendJson(response, 200, { data, success: true });
return;
}
sendJson(response, 404, {
message: '接口不存在',
success: false,
});
} catch (error) {
const statusCode =
error instanceof ApiTypeGeneratorError ? error.status : 500;
sendJson(response, statusCode, {
code:
error instanceof ApiTypeGeneratorError ? error.code : 'UNKNOWN',
message: error instanceof Error ? error.message : '生成失败',
success: false,
});
}
});
},
name: 'web-antd:api-type-generator',
};
}
class ApiTypeGeneratorError extends Error {
constructor(
message: string,
public status = 400,
public code = 'BAD_REQUEST',
) {
super(message);
}
}
async function accessFile(filePath: string) {
try {
await fs.access(filePath, fsConstants.F_OK);
return true;
} catch {
return false;
}
}
function ensureInApiRoot(apiRoot: string, targetPath: string) {
const relativePath = path.relative(apiRoot, targetPath);
if (
relativePath.startsWith('..') ||
path.isAbsolute(relativePath) ||
relativePath === ''
) {
if (relativePath === '') {
return;
}
throw new ApiTypeGeneratorError('目标路径不在 src/api 目录下');
}
}
function extractTypeNames(content: string) {
const names = new Set<string>();
const regex = /export\s+(?:interface|type)\s+([A-Za-z_$][\w$]*)/g;
const results = content.matchAll(regex);
for (const result of results) {
const typeName = result[1];
if (!/(?:Param|Res)$/u.test(typeName)) {
continue;
}
names.add(typeName);
}
return [...names];
}
function formatTypeImport(typeNames: string[], importPath: string) {
const sortedNames = [...new Set(typeNames)].toSorted();
if (sortedNames.length === 0) {
return '';
}
if (sortedNames.length === 1) {
return `import type { ${sortedNames[0]} } from '${importPath}';`;
}
return `import type {\n ${sortedNames.join(',\n ')},\n} from '${importPath}';`;
}
function getImportPath(fileName: string) {
return `./${fileName.replace(/\.ts$/u, '')}`;
}
function getSafeFileName(fileName?: string) {
const safeFileName = fileName?.trim() || 'type.ts';
if (
!/^[\w.-]+\.ts$/u.test(safeFileName) ||
safeFileName.includes('..') ||
safeFileName.includes('/') ||
safeFileName.includes('\\')
) {
throw new ApiTypeGeneratorError('文件名必须是单个 .ts 文件名');
}
return safeFileName;
}
function getSafeRelativeDir(dir?: string) {
const safeDir = (dir ?? '').trim().replaceAll('\\', '/');
if (!safeDir || safeDir === '.') {
return '';
}
if (path.isAbsolute(safeDir) || safeDir.split('/').includes('..')) {
throw new ApiTypeGeneratorError('目录路径不合法');
}
return safeDir.split('/').filter(Boolean).join('/');
}
function mergeTypeImport(
source: string,
importPath: string,
typeNames: string[],
) {
const importRegex = new RegExp(
String.raw`import\s+type\s+\{([\s\S]*?)\}\s+from\s+['"]${escapeRegExp(
importPath,
)}['"];?`,
'u',
);
const matchedImport = source.match(importRegex);
if (matchedImport) {
const existedNames = matchedImport[1]
.split(',')
.map((item) => item.trim())
.filter(Boolean);
const nextImport = formatTypeImport(
[...existedNames, ...typeNames],
importPath,
);
return source.replace(importRegex, nextImport);
}
const importStatement = formatTypeImport(typeNames, importPath);
if (!importStatement) {
return source;
}
return `${importStatement}\n\n${source}`;
}
function escapeRegExp(value: string) {
return value.replaceAll(/[.*+?^${}()|[\]\\]/gu, String.raw`\$&`);
}
async function readJsonBody<T>(request: IncomingMessage) {
const chunks: Buffer[] = [];
for await (const chunk of request) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
const content = Buffer.concat(chunks).toString('utf8');
if (!content) {
return {} as T;
}
try {
return JSON.parse(content) as T;
} catch {
throw new ApiTypeGeneratorError('请求体必须是合法 JSON');
}
}
async function scanApiDirs(apiRoot: string) {
async function scan(currentDir: string): Promise<DirNode[]> {
const dirents = await fs.readdir(currentDir, { withFileTypes: true });
const nodes = await Promise.all(
dirents
.filter((dirent) => dirent.isDirectory())
.toSorted((prev, next) => prev.name.localeCompare(next.name))
.map(async (dirent) => {
const fullPath = path.join(currentDir, dirent.name);
const relativePath = path
.relative(apiRoot, fullPath)
.replaceAll(path.sep, '/');
const children = await scan(fullPath);
return {
children: children.length > 0 ? children : undefined,
key: relativePath,
title: dirent.name,
value: relativePath,
};
}),
);
return nodes;
}
return [
{
children: await scan(apiRoot),
key: '.',
title: 'api',
value: '.',
},
];
}
async function checkTypeFiles(
apiRoot: string,
payload: CheckTypeFilePayload,
) {
const items = payload.items ?? [];
if (!Array.isArray(items)) {
throw new ApiTypeGeneratorError('检查参数必须是数组');
}
return Promise.all(
items.map(async (item, index) => {
const relativeDir = getSafeRelativeDir(item.dir);
const fileName = getSafeFileName(item.fileName);
const targetDir = path.resolve(apiRoot, relativeDir);
ensureInApiRoot(apiRoot, targetDir);
const targetPath = path.resolve(targetDir, fileName);
ensureInApiRoot(apiRoot, targetPath);
return {
dir: relativeDir || '.',
exists: await accessFile(targetPath),
fileName,
filePath: path.relative(apiRoot, targetPath).replaceAll(path.sep, '/'),
key: item.key ?? String(index),
};
}),
);
}
function sendJson(response: ServerResponse, statusCode: number, data: unknown) {
response.statusCode = statusCode;
response.setHeader('Content-Type', 'application/json;charset=utf-8');
response.end(JSON.stringify(data));
}
async function updateIndexTypeImport(
targetDir: string,
fileName: string,
content: string,
) {
const indexPath = path.join(targetDir, 'index.ts');
const hasIndexFile = await accessFile(indexPath);
if (!hasIndexFile) {
return {
importSkipped: true,
importUpdated: false,
};
}
const typeNames = extractTypeNames(content);
if (typeNames.length === 0) {
return {
importSkipped: true,
importUpdated: false,
};
}
const source = await fs.readFile(indexPath, 'utf8');
const nextSource = mergeTypeImport(
source,
getImportPath(fileName),
typeNames,
);
if (nextSource !== source) {
await fs.writeFile(indexPath, nextSource, 'utf8');
}
return {
importSkipped: false,
importUpdated: nextSource !== source,
};
}
async function writeTypeFile(apiRoot: string, payload: WriteTypePayload) {
const relativeDir = getSafeRelativeDir(payload.dir);
const fileName = getSafeFileName(payload.fileName);
const content = payload.content ?? '';
if (!content.trim()) {
throw new ApiTypeGeneratorError('类型定义内容不能为空');
}
const targetDir = path.resolve(apiRoot, relativeDir);
ensureInApiRoot(apiRoot, targetDir);
const targetPath = path.resolve(targetDir, fileName);
ensureInApiRoot(apiRoot, targetPath);
const exists = await accessFile(targetPath);
if (exists && !payload.overwrite) {
throw new ApiTypeGeneratorError('目标文件已存在', 409, 'FILE_EXISTS');
}
await fs.mkdir(targetDir, { recursive: true });
await fs.writeFile(
targetPath,
content.endsWith('\n') ? content : `${content}\n`,
'utf8',
);
const importResult = payload.autoImport
? await updateIndexTypeImport(targetDir, fileName, content)
: {
importSkipped: false,
importUpdated: false,
};
return {
exists,
filePath: path.relative(apiRoot, targetPath).replaceAll(path.sep, '/'),
...importResult,
};
}