@mindbase/cli 1.0.4 → 1.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -7
- package/src/bin/mindbase.ts +39 -0
- package/src/commands/init/backend.ts +34 -0
- package/src/commands/init/frontend.ts +34 -0
- package/src/commands/init/index.ts +50 -0
- package/src/commands/sdk-docs.ts +138 -0
- package/src/index.ts +33 -0
- package/src/utils/BackendInitializer.ts +377 -0
- package/src/utils/FrontendInitializer.ts +370 -0
- package/src/utils/Logger.ts +219 -0
- package/src/utils/prompts.ts +140 -0
- package/tsconfig.json +15 -0
- package/dist/bin/mindbase.d.ts +0 -1
- package/dist/bin/mindbase.js +0 -40
- package/dist/bin/mindbase.js.map +0 -1
- package/dist/chunk-U3UDAQCW.js +0 -936
- package/dist/chunk-U3UDAQCW.js.map +0 -1
- package/dist/index.d.ts +0 -139
- package/dist/index.js +0 -49
- package/dist/index.js.map +0 -1
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import prompts from "prompts";
|
|
4
|
+
import logger from "./Logger";
|
|
5
|
+
import {
|
|
6
|
+
FRONTEND_APP_TYPES,
|
|
7
|
+
getFrontendPackage,
|
|
8
|
+
getAvailableFrontendApps,
|
|
9
|
+
resolveFrontendDependencies,
|
|
10
|
+
} from "./prompts";
|
|
11
|
+
|
|
12
|
+
export interface FrontendConfig {
|
|
13
|
+
name: string;
|
|
14
|
+
description: string;
|
|
15
|
+
author: string;
|
|
16
|
+
port: number;
|
|
17
|
+
proxyTarget: string;
|
|
18
|
+
proxyPath: string;
|
|
19
|
+
appType: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 收集前端项目初始化配置
|
|
24
|
+
*/
|
|
25
|
+
export async function collectFrontendConfig(cwd: string): Promise<FrontendConfig> {
|
|
26
|
+
const folderName = path.basename(cwd);
|
|
27
|
+
const availableApps = getAvailableFrontendApps();
|
|
28
|
+
|
|
29
|
+
const response = await prompts([
|
|
30
|
+
{
|
|
31
|
+
type: "text",
|
|
32
|
+
name: "name",
|
|
33
|
+
message: "项目名称",
|
|
34
|
+
initial: folderName,
|
|
35
|
+
validate: (value: string) => value.length > 0 || "项目名称不能为空",
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
type: "text",
|
|
39
|
+
name: "description",
|
|
40
|
+
message: "项目描述",
|
|
41
|
+
initial: "MindBase 前端应用",
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
type: "text",
|
|
45
|
+
name: "author",
|
|
46
|
+
message: "作者",
|
|
47
|
+
initial: "",
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
type: "text",
|
|
51
|
+
name: "port",
|
|
52
|
+
message: "开发服务器端口",
|
|
53
|
+
initial: "5173",
|
|
54
|
+
validate: (value: string) => {
|
|
55
|
+
const val = typeof value === "number" ? value : parseInt(value, 10);
|
|
56
|
+
if (isNaN(val) || val < 1 || val > 65535) {
|
|
57
|
+
return "端口号必须为在 1-65535 之间的数字";
|
|
58
|
+
}
|
|
59
|
+
return true;
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
type: "text",
|
|
64
|
+
name: "proxyTarget",
|
|
65
|
+
message: "API 代理目标地址",
|
|
66
|
+
initial: "http://localhost:3000",
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
type: "text",
|
|
70
|
+
name: "proxyPath",
|
|
71
|
+
|
|
72
|
+
message: "API 代理路径 (留空则不代理)",
|
|
73
|
+
initial: "/api",
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
type: "select",
|
|
77
|
+
name: "appType",
|
|
78
|
+
message: "选择应用形态",
|
|
79
|
+
choices: availableApps.map((app) => ({
|
|
80
|
+
title: app.description,
|
|
81
|
+
value: app.name,
|
|
82
|
+
})),
|
|
83
|
+
initial: 0,
|
|
84
|
+
},
|
|
85
|
+
]);
|
|
86
|
+
|
|
87
|
+
if (Object.keys(response).length < 7) {
|
|
88
|
+
throw new Error("用户取消了初始化流程");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
logger.info(`已选择应用形态: ${response.appType}`);
|
|
92
|
+
|
|
93
|
+
return response as FrontendConfig;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 初始化 package.json
|
|
98
|
+
*/
|
|
99
|
+
export function setupFrontendPackageJson(cwd: string, config: FrontendConfig) {
|
|
100
|
+
const pkgPath = path.join(cwd, "package.json");
|
|
101
|
+
let pkg: any = {};
|
|
102
|
+
|
|
103
|
+
if (fs.existsSync(pkgPath)) {
|
|
104
|
+
pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
|
|
105
|
+
logger.info("✓ 检测到已存在的 package.json,正在更新配置...");
|
|
106
|
+
} else {
|
|
107
|
+
pkg = {
|
|
108
|
+
name: config.name,
|
|
109
|
+
version: "1.0.0",
|
|
110
|
+
description: config.description,
|
|
111
|
+
author: config.author,
|
|
112
|
+
private: true,
|
|
113
|
+
type: "module",
|
|
114
|
+
dependencies: {
|
|
115
|
+
axios: "^1.7.0",
|
|
116
|
+
"crypto-js": "^4.2.0",
|
|
117
|
+
dayjs: "^1.11.19",
|
|
118
|
+
"element-plus": "^2.9.0",
|
|
119
|
+
"element-plus/icons-vue": "^2.9.0",
|
|
120
|
+
"lodash-es": "^4.17.21",
|
|
121
|
+
pinia: "^2.2.0",
|
|
122
|
+
"spark-md5": "^3.0.2",
|
|
123
|
+
vue: "^3.5.0",
|
|
124
|
+
"vue-router": "^4.5.0",
|
|
125
|
+
},
|
|
126
|
+
devDependencies: {
|
|
127
|
+
"@vitejs/plugin-vue": "^5.2.0",
|
|
128
|
+
typescript: "^5.6.0",
|
|
129
|
+
vite: "^6.0.0",
|
|
130
|
+
"vue-tsc": "^2.2.0",
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
logger.info("✓ 正在生成 package.json...");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// 注入标准脚本
|
|
137
|
+
pkg.scripts = pkg.scripts || {};
|
|
138
|
+
pkg.scripts["start"] = "npm run dev";
|
|
139
|
+
pkg.scripts["dev"] = `vite --port ${config.port}`;
|
|
140
|
+
pkg.scripts["build"] = "vue-tsc -b && vite build";
|
|
141
|
+
pkg.scripts["preview"] = "vite preview";
|
|
142
|
+
|
|
143
|
+
// 添加依赖(包含所有依赖包)
|
|
144
|
+
const allPackages = resolveFrontendDependencies(config.appType);
|
|
145
|
+
for (const packageName of allPackages) {
|
|
146
|
+
pkg.dependencies = pkg.dependencies || {};
|
|
147
|
+
pkg.dependencies[packageName] = "^1.0.0";
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
pkg.devDependencies = pkg.devDependencies || {};
|
|
151
|
+
pkg.devDependencies["vue-tsc"] = "^2.2.0";
|
|
152
|
+
|
|
153
|
+
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2));
|
|
154
|
+
logger.info("✓ package.json 已就绪");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* 初始化 Vite 配置 vite.config.ts
|
|
159
|
+
*/
|
|
160
|
+
export function setupViteConfig(cwd: string, config: FrontendConfig) {
|
|
161
|
+
const viteConfigPath = path.join(cwd, "vite.config.ts");
|
|
162
|
+
|
|
163
|
+
const proxyConfig = config.proxyPath
|
|
164
|
+
? `{
|
|
165
|
+
'${config.proxyPath}': {
|
|
166
|
+
target: '${config.proxyTarget}',
|
|
167
|
+
changeOrigin: true,
|
|
168
|
+
},
|
|
169
|
+
}`
|
|
170
|
+
: "undefined";
|
|
171
|
+
|
|
172
|
+
const viteConfigContent = `import { defineConfig } from "vite";
|
|
173
|
+
import vue from "@vitejs/plugin-vue";
|
|
174
|
+
import path from "path";
|
|
175
|
+
|
|
176
|
+
export default defineConfig({
|
|
177
|
+
plugins: [vue()],
|
|
178
|
+
resolve: {
|
|
179
|
+
alias: {
|
|
180
|
+
"@": path.resolve(__dirname, "./src"),
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
server: {
|
|
184
|
+
port: ${config.port},
|
|
185
|
+
proxy: ${proxyConfig},
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
`;
|
|
189
|
+
fs.writeFileSync(viteConfigPath, viteConfigContent);
|
|
190
|
+
logger.info("✓ vite.config.ts 已就绪");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* 初始化 TypeScript 配置
|
|
195
|
+
*/
|
|
196
|
+
export function setupFrontendTsConfig(cwd: string) {
|
|
197
|
+
// tsconfig.json
|
|
198
|
+
const tsconfigPath = path.join(cwd, "tsconfig.json");
|
|
199
|
+
if (!fs.existsSync(tsconfigPath)) {
|
|
200
|
+
const tsconfigContent = {
|
|
201
|
+
compilerOptions: {
|
|
202
|
+
target: "ES2022",
|
|
203
|
+
module: "ESNext",
|
|
204
|
+
moduleResolution: "bundler",
|
|
205
|
+
strict: true,
|
|
206
|
+
esModuleInterop: true,
|
|
207
|
+
skipLibCheck: true,
|
|
208
|
+
forceConsistentCasingInFileNames: true,
|
|
209
|
+
resolveJsonModule: true,
|
|
210
|
+
isolatedModules: true,
|
|
211
|
+
noEmit: true,
|
|
212
|
+
lib: ["ES2022", "DOM", "DOM.Iterable"],
|
|
213
|
+
types: ["vite/client"],
|
|
214
|
+
},
|
|
215
|
+
include: ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"],
|
|
216
|
+
};
|
|
217
|
+
fs.writeFileSync(tsconfigPath, JSON.stringify(tsconfigContent, null, 2));
|
|
218
|
+
logger.info("✓ tsconfig.json 已就绪");
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* 初始化 index.html
|
|
224
|
+
*/
|
|
225
|
+
export function setupIndexHtml(cwd: string, config: FrontendConfig) {
|
|
226
|
+
const htmlPath = path.join(cwd, "index.html");
|
|
227
|
+
|
|
228
|
+
const htmlContent = `<!DOCTYPE html>
|
|
229
|
+
<html lang="zh-CN">
|
|
230
|
+
<head>
|
|
231
|
+
<meta charset="UTF-8" />
|
|
232
|
+
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
|
|
233
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
234
|
+
<title>${config.name}</title>
|
|
235
|
+
</head>
|
|
236
|
+
<body>
|
|
237
|
+
<div id="app"></div>
|
|
238
|
+
<script type="module" src="/src/main.ts"></script>
|
|
239
|
+
</body>
|
|
240
|
+
</html>
|
|
241
|
+
`;
|
|
242
|
+
fs.writeFileSync(htmlPath, htmlContent);
|
|
243
|
+
logger.info("✓ index.html 已就绪");
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* 初始化源文件
|
|
248
|
+
*/
|
|
249
|
+
export function setupAppFiles(cwd: string, config: FrontendConfig) {
|
|
250
|
+
const srcDir = path.join(cwd, "src");
|
|
251
|
+
if (!fs.existsSync(srcDir)) fs.mkdirSync(srcDir, { recursive: true });
|
|
252
|
+
|
|
253
|
+
// main.ts
|
|
254
|
+
const mainContent = `import {
|
|
255
|
+
createMindBaseApp,
|
|
256
|
+
createGlobComponentMap,
|
|
257
|
+
} from "${getFrontendPackage(config.appType)}";
|
|
258
|
+
|
|
259
|
+
const { app, pinia } = createMindBaseApp({
|
|
260
|
+
router: {},
|
|
261
|
+
componentMap: createGlobComponentMap(import.meta.glob("./views/**/*.vue"), "./views/"),
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
app.mount("#app");
|
|
265
|
+
`;
|
|
266
|
+
fs.writeFileSync(path.join(srcDir, "main.ts"), mainContent);
|
|
267
|
+
logger.info("✓ src/main.ts 已就绪");
|
|
268
|
+
|
|
269
|
+
// App.vue
|
|
270
|
+
const appContent = `<template>
|
|
271
|
+
<router-view />
|
|
272
|
+
</template>
|
|
273
|
+
|
|
274
|
+
<script setup lang="ts">
|
|
275
|
+
// 根组件
|
|
276
|
+
</script>
|
|
277
|
+
|
|
278
|
+
<style>
|
|
279
|
+
html,
|
|
280
|
+
body,
|
|
281
|
+
#app {
|
|
282
|
+
margin: 0;
|
|
283
|
+
padding: 0;
|
|
284
|
+
width: 100%;
|
|
285
|
+
height: 100%;
|
|
286
|
+
}
|
|
287
|
+
</style>
|
|
288
|
+
`;
|
|
289
|
+
fs.writeFileSync(path.join(srcDir, "App.vue"), appContent);
|
|
290
|
+
logger.info("✓ src/App.vue 已就绪");
|
|
291
|
+
|
|
292
|
+
// views 目录
|
|
293
|
+
const viewsDir = path.join(srcDir, "views");
|
|
294
|
+
if (!fs.existsSync(viewsDir)) fs.mkdirSync(viewsDir);
|
|
295
|
+
|
|
296
|
+
// 示例页面
|
|
297
|
+
const exampleView = `<template>
|
|
298
|
+
<div class="home">
|
|
299
|
+
<h1>欢迎使用 ${config.name}</h1>
|
|
300
|
+
</div>
|
|
301
|
+
</template>
|
|
302
|
+
|
|
303
|
+
<script setup lang="ts">
|
|
304
|
+
// 页面逻辑
|
|
305
|
+
</script>
|
|
306
|
+
|
|
307
|
+
<style scoped>
|
|
308
|
+
.home {
|
|
309
|
+
padding: 20px;
|
|
310
|
+
}
|
|
311
|
+
</style>
|
|
312
|
+
`;
|
|
313
|
+
fs.writeFileSync(path.join(viewsDir, "index.vue"), exampleView);
|
|
314
|
+
logger.info("✓ src/views/index.vue 已就绪");
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* 创建 public 目录
|
|
319
|
+
*/
|
|
320
|
+
export function setupPublicDir(cwd: string) {
|
|
321
|
+
const publicDir = path.join(cwd, "public");
|
|
322
|
+
if (!fs.existsSync(publicDir)) {
|
|
323
|
+
fs.mkdirSync(publicDir, { recursive: true });
|
|
324
|
+
logger.info("✓ public 目录已创建");
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* 复制 admin 专用资源(如字体文件)
|
|
330
|
+
* @param cwd 项目根目录
|
|
331
|
+
* @param config 前端配置
|
|
332
|
+
*/
|
|
333
|
+
export function copyAdminAssets(cwd: string, config: FrontendConfig) {
|
|
334
|
+
// 只处理 admin-app 类型
|
|
335
|
+
if (config.appType !== "admin-app") {
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const sourceDir = path.join(__dirname, "../assets/iconfont");
|
|
340
|
+
|
|
341
|
+
// 检查源目录是否存在
|
|
342
|
+
if (!fs.existsSync(sourceDir)) {
|
|
343
|
+
logger.warn("字体源目录不存在,跳过复制");
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// 目标目录:public/assets/mb-iconfont/
|
|
348
|
+
const targetDir = path.join(cwd, "public/assets/mb-iconfont");
|
|
349
|
+
if (!fs.existsSync(targetDir)) {
|
|
350
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// 复制字体文件
|
|
354
|
+
const fonts = ["iconfont.woff2", "iconfont.woff", "iconfont.ttf"];
|
|
355
|
+
let copiedCount = 0;
|
|
356
|
+
|
|
357
|
+
fonts.forEach((font) => {
|
|
358
|
+
const sourcePath = path.join(sourceDir, font);
|
|
359
|
+
const targetPath = path.join(targetDir, font);
|
|
360
|
+
|
|
361
|
+
if (fs.existsSync(sourcePath)) {
|
|
362
|
+
fs.copyFileSync(sourcePath, targetPath);
|
|
363
|
+
copiedCount++;
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
if (copiedCount > 0) {
|
|
368
|
+
logger.info(`✓ 已复制 ${copiedCount} 个字体文件到 public/assets/mb-iconfont/`);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
export type LogLevel = "debug" | "info" | "warn" | "error" | "silent";
|
|
2
|
+
|
|
3
|
+
const LOG_LEVELS = {
|
|
4
|
+
debug: 0,
|
|
5
|
+
info: 1,
|
|
6
|
+
warn: 2,
|
|
7
|
+
error: 3,
|
|
8
|
+
silent: 4,
|
|
9
|
+
} as const;
|
|
10
|
+
|
|
11
|
+
let currentLogLevel: LogLevel = "info";
|
|
12
|
+
|
|
13
|
+
const COLORS: Record<LogLevel | "reset", string> = {
|
|
14
|
+
debug: "\x1b[36m",
|
|
15
|
+
info: "\x1b[32m",
|
|
16
|
+
warn: "\x1b[33m",
|
|
17
|
+
error: "\x1b[31m",
|
|
18
|
+
silent: "\x1b[0m",
|
|
19
|
+
reset: "\x1b[0m",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 格式化时间戳
|
|
24
|
+
*/
|
|
25
|
+
function formatTimestamp(): string {
|
|
26
|
+
const now = new Date();
|
|
27
|
+
const year = now.getFullYear();
|
|
28
|
+
const month = String(now.getMonth() + 1).padStart(2, "0");
|
|
29
|
+
const day = String(now.getDate()).padStart(2, "0");
|
|
30
|
+
const hours = String(now.getHours()).padStart(2, "0");
|
|
31
|
+
const minutes = String(now.getMinutes()).padStart(2, "0");
|
|
32
|
+
const seconds = String(now.getSeconds()).padStart(2, "0");
|
|
33
|
+
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 计算字符串在终端中的视觉宽度
|
|
38
|
+
* 修复:区分单宽符号 (如 ✓) 和双宽符号 (如 中文、Emoji)
|
|
39
|
+
*/
|
|
40
|
+
function getVisualWidth(str: string): number {
|
|
41
|
+
let width = 0;
|
|
42
|
+
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
43
|
+
for (const { segment } of segmenter.segment(str)) {
|
|
44
|
+
const charCode = segment.charCodeAt(0);
|
|
45
|
+
|
|
46
|
+
// 1. 基础 ASCII
|
|
47
|
+
if (charCode <= 255) {
|
|
48
|
+
width += 1;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 2. CJK 字符集范围 (常用中文、标点、全角符号)
|
|
53
|
+
const isCJK =
|
|
54
|
+
(charCode >= 0x4e00 && charCode <= 0x9fff) || // CJK Unified Ideographs
|
|
55
|
+
(charCode >= 0x3000 && charCode <= 0x303f) || // CJK Symbols and Punctuation
|
|
56
|
+
(charCode >= 0xff00 && charCode <= 0xffef); // Fullwidth Forms
|
|
57
|
+
|
|
58
|
+
// 3. Emoji 或代理对 (通常 segment.length > 1)
|
|
59
|
+
const isMultiByte = segment.length > 1;
|
|
60
|
+
|
|
61
|
+
if (isCJK || isMultiByte) {
|
|
62
|
+
width += 2;
|
|
63
|
+
} else {
|
|
64
|
+
// 4. 其他特殊符号 (如 ✓, ★, ☎) 在大多数终端占 1 格
|
|
65
|
+
width += 1;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return width;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 截断字符串以适应视觉宽度,从头部截断并保留末尾
|
|
73
|
+
*/
|
|
74
|
+
function truncateToWidth(str: string, maxWidth: number): string {
|
|
75
|
+
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
76
|
+
const segments = Array.from(segmenter.segment(str)).map((s) => s.segment);
|
|
77
|
+
|
|
78
|
+
if (getVisualWidth(str) <= maxWidth) return str;
|
|
79
|
+
|
|
80
|
+
let currentWidth = 3; // 为 "..." 预留
|
|
81
|
+
let result = "";
|
|
82
|
+
|
|
83
|
+
// 从后往前遍历 segments
|
|
84
|
+
for (let i = segments.length - 1; i >= 0; i--) {
|
|
85
|
+
const segment = segments[i];
|
|
86
|
+
const segmentWidth = getVisualWidth(segment);
|
|
87
|
+
if (currentWidth + segmentWidth > maxWidth) break;
|
|
88
|
+
result = segment + result;
|
|
89
|
+
currentWidth += segmentWidth;
|
|
90
|
+
}
|
|
91
|
+
return "..." + result;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function formatMessage(level: LogLevel, message: string): string {
|
|
95
|
+
const timestamp = formatTimestamp();
|
|
96
|
+
const terminalWidth = process.stdout.columns || 80;
|
|
97
|
+
const timeStr = ` ${timestamp}`;
|
|
98
|
+
const timeWidth = getVisualWidth(timeStr);
|
|
99
|
+
const maxMessageWidth = terminalWidth - timeWidth - 2;
|
|
100
|
+
|
|
101
|
+
let displayMessage = message;
|
|
102
|
+
const messageWidth = getVisualWidth(displayMessage);
|
|
103
|
+
|
|
104
|
+
if (messageWidth > maxMessageWidth) {
|
|
105
|
+
displayMessage = truncateToWidth(displayMessage, maxMessageWidth);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const currentMsgWidth = getVisualWidth(displayMessage);
|
|
109
|
+
const paddingCount = Math.max(0, terminalWidth - currentMsgWidth - timeWidth);
|
|
110
|
+
const padding = " ".repeat(paddingCount);
|
|
111
|
+
|
|
112
|
+
return `${COLORS[level]}${displayMessage}${padding}${timeStr}${COLORS.reset}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const startupTime = Date.now();
|
|
116
|
+
|
|
117
|
+
// 标签填充函数(目标显示宽度 8,空格分散到字符间)
|
|
118
|
+
function padTag(tag: string, targetWidth = 8): string {
|
|
119
|
+
const currentWidth = getVisualWidth(tag);
|
|
120
|
+
const padding = Math.max(0, targetWidth - currentWidth);
|
|
121
|
+
|
|
122
|
+
if (padding === 0) return tag;
|
|
123
|
+
|
|
124
|
+
// 将空格均匀分散到字符之间
|
|
125
|
+
const chars = [...tag]; // 按码点分割
|
|
126
|
+
const gapCount = chars.length + 1; // 间隙数 = 字符数 + 1(前后和中间)
|
|
127
|
+
|
|
128
|
+
// 计算每个间隙的空格数
|
|
129
|
+
const baseSpaces = Math.floor(padding / gapCount);
|
|
130
|
+
const extraSpaces = padding % gapCount;
|
|
131
|
+
|
|
132
|
+
let result = "";
|
|
133
|
+
for (let i = 0; i < chars.length; i++) {
|
|
134
|
+
// 每个间隙的空格 = 基础空格 + 额外空格(前 extraSpaces 个间隙多1个)
|
|
135
|
+
const spaces = baseSpaces + (i < extraSpaces ? 1 : 0);
|
|
136
|
+
result += " ".repeat(spaces) + chars[i];
|
|
137
|
+
}
|
|
138
|
+
// 最后一个间隙
|
|
139
|
+
result += " ".repeat(baseSpaces + (chars.length < extraSpaces ? 1 : 0));
|
|
140
|
+
|
|
141
|
+
return result;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function startupMessage(message: string, tag?: string): string {
|
|
145
|
+
const diff = Date.now() - startupTime;
|
|
146
|
+
const timeStr = diff.toString().padStart(6, "0");
|
|
147
|
+
const tagStr = tag ? `【${padTag(tag)}】` : "";
|
|
148
|
+
return `${COLORS["warn"]}[${timeStr}] ${tagStr}${message}${COLORS["reset"]}`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function shouldLog(level: LogLevel): boolean {
|
|
152
|
+
return LOG_LEVELS[level] >= LOG_LEVELS[currentLogLevel];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function formatArgs(args: any[]): string {
|
|
156
|
+
return args
|
|
157
|
+
.map((arg) => {
|
|
158
|
+
if (arg instanceof Error) {
|
|
159
|
+
return arg.message;
|
|
160
|
+
}
|
|
161
|
+
if (typeof arg === "object") {
|
|
162
|
+
return JSON.stringify(arg, null, 2);
|
|
163
|
+
}
|
|
164
|
+
return String(arg);
|
|
165
|
+
})
|
|
166
|
+
.join(" ");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export const logger = {
|
|
170
|
+
debug(...args: any[]): void {
|
|
171
|
+
if (shouldLog("debug")) {
|
|
172
|
+
console.log(formatMessage("debug", formatArgs(args)));
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
|
|
176
|
+
info(...args: any[]): void {
|
|
177
|
+
if (shouldLog("info")) {
|
|
178
|
+
console.log(formatMessage("info", formatArgs(args)));
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
|
|
182
|
+
warn(...args: any[]): void {
|
|
183
|
+
if (shouldLog("warn")) {
|
|
184
|
+
console.warn(formatMessage("warn", formatArgs(args)));
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
|
|
188
|
+
error(...args: any[]): void {
|
|
189
|
+
if (shouldLog("error")) {
|
|
190
|
+
// 先打印带颜色的格式化消息
|
|
191
|
+
console.error(formatMessage("error", formatArgs(args)));
|
|
192
|
+
// 如果有 Error 对象,用 console.error 单独打印以显示堆栈
|
|
193
|
+
const errorArg = args.find((arg) => arg instanceof Error);
|
|
194
|
+
if (errorArg) {
|
|
195
|
+
console.error(errorArg);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
|
|
200
|
+
startup(tagOrMessage: string, ...args: any[]): void {
|
|
201
|
+
// 如果有额外参数,第一个是标签,其余是消息
|
|
202
|
+
// 否则第一个参数是消息,无标签
|
|
203
|
+
if (args.length > 0) {
|
|
204
|
+
console.log(startupMessage(formatArgs(args), tagOrMessage));
|
|
205
|
+
} else {
|
|
206
|
+
console.log(startupMessage(tagOrMessage));
|
|
207
|
+
}
|
|
208
|
+
},
|
|
209
|
+
|
|
210
|
+
setLevel(level: LogLevel): void {
|
|
211
|
+
currentLogLevel = level;
|
|
212
|
+
},
|
|
213
|
+
|
|
214
|
+
getLevel(): LogLevel {
|
|
215
|
+
return currentLogLevel;
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
export default logger;
|