create-ssnode-cli 0.2.5 → 0.2.7
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/README.md +1 -1
- package/index.js +17 -21
- package/package.json +1 -1
- package/secrets.js +32 -34
- package/templates/backend/Dockerfile +18 -8
- package/templates/backend/config/config.base.yaml +130 -0
- package/templates/backend/config/config.develop.yaml +8 -75
- package/templates/backend/config/config.prod.yaml +12 -67
- package/templates/backend/docker-compose.yml +30 -22
- package/templates/backend/ops/docker/config.sh +30 -15
- package/templates/backend/ops/docker/install.sh +25 -34
- package/templates/backend/ops/docker/run-container.sh +100 -0
- package/templates/backend/ops/docker/start.sh +37 -8
- package/templates/backend/ops/docker/stop.sh +1 -1
- package/templates/backend/ops/ensure-encrypt.cjs +217 -0
- package/templates/backend/ops/install.sh +249 -148
- package/templates/backend/ops/lib-encrypt.sh +98 -0
- package/templates/backend/ops/lib.sh +22 -0
- package/templates/backend/ops/pm2/config.sh +11 -6
- package/templates/backend/ops/pm2/install.sh +27 -3
- package/templates/backend/ops/pm2/start.sh +1 -1
- package/templates/backend/ops/start.sh +7 -5
- package/templates/backend/ops/status.sh +7 -5
- package/templates/backend/ops/stop.sh +7 -5
- package/templates/backend/ops/systemd/config.sh +13 -6
- package/templates/backend/ops/systemd/install.sh +28 -1
- package/templates/backend/ops/systemd/start.sh +1 -1
- package/templates/backend/ops/versionswitch.sh +34 -122
- package/templates/backend/package-lock.json +7561 -0
- package/templates/backend/package.json +3 -1
- package/templates/backend/scripts/build.cjs +104 -10
- package/templates/backend/scripts/config-crypt.cjs +108 -0
- package/templates/backend/scripts/lib/ssh.cjs +72 -3
- package/templates/backend/scripts/publish.cjs +17 -9
- package/templates/backend/scripts/pulldocker.cjs +208 -0
- package/templates/backend/scripts/pushdocker.cjs +323 -0
- package/templates/backend/scripts/rollback.cjs +6 -3
- package/templates/backend/scripts/server.config.example.cjs +43 -15
- package/templates/backend/scripts/slowsql-report.mjs +2 -2
- package/templates/backend/src/common/api-log/api-log.service.ts +1 -1
- package/templates/backend/src/common/config-crypto.ts +133 -0
- package/templates/backend/src/config/config.interface.ts +22 -3
- package/templates/backend/src/config/configuration.ts +173 -86
- package/templates/backend/src/database/migrations/0001-init.migration.ts +1 -1
- package/templates/backend/src/database/slow-sql.logger.ts +1 -1
- package/templates/backend/src/database/sqlite.config.ts +2 -2
- package/templates/backend/src/main.ts +3 -3
- package/templates/backend/src/modules/ops/ops-probe.interface.ts +3 -3
- package/templates/backend/src/modules/ops/ops.module.ts +2 -11
- package/templates/backend/src/modules/user/user.service.ts +2 -2
- package/templates/docs/config-encryption.md +164 -0
- package/templates/root/AGENTS.md +5 -4
- package/templates/root/CLAUDE.md +1 -1
- package/templates/root/README.md +25 -15
- package/templates/root/_gitignore +5 -0
- package/templates/root/scripts/publish.cjs +20 -2
- package/templates/root/scripts/rollback.cjs +20 -2
- package/templates/web/config/router.config.ts +8 -0
- package/templates/web/package.json +5 -1
- package/templates/web/scripts/compress-src-images.cjs +332 -0
- package/templates/web/scripts/lib/interactive.cjs +134 -0
- package/templates/web/scripts/publish.cjs +299 -200
- package/templates/web/scripts/rollback.cjs +110 -41
- package/templates/web/scripts/server.config.example.json +17 -0
- package/templates/web/src/layouts/Background.module.less +0 -4
- package/templates/web/src/layouts/Background.tsx +48 -74
- package/templates/web/src/layouts/components/PageTabs/SortableTab.tsx +133 -0
- package/templates/web/src/layouts/components/PageTabs/index.module.less +105 -0
- package/templates/web/src/layouts/components/PageTabs/index.tsx +110 -0
- package/templates/web/src/layouts/components/PageTabs/usePageTabs.ts +224 -0
- package/templates/web/src/pages/Backend/Image/index.tsx +4 -4
- package/templates/web/src/pages/Maintenance/index.module.less +177 -0
- package/templates/web/src/pages/Maintenance/index.tsx +142 -0
- package/templates/web/src/services/api.ts +4 -0
- package/templates/web/src/services/app-request.ts +8 -1
- package/templates/web/src/services/maintenance.ts +67 -0
- package/templates/web/src/services/request.ts +5 -0
- package/transforms.js +21 -28
- package/templates/backend/ops/docker/.env.example +0 -40
- package/templates/backend/ops/sqlite/README.md +0 -57
- package/templates/backend/ops/sqlite/backup.sh +0 -65
- package/templates/backend/ops/sqlite/config.sh +0 -91
- package/templates/backend/ops/sqlite/healthcheck.sh +0 -25
- package/templates/backend/ops/sqlite/restore.sh +0 -105
- package/templates/backend/src/modules/ops/sqlite-backup-list.probe.ts +0 -100
- package/templates/backend/src/modules/ops/sqlite-backup-log.probe.ts +0 -63
- package/templates/backend/src/modules/ops/sqlite-backup.probe.ts +0 -144
|
@@ -1,100 +0,0 @@
|
|
|
1
|
-
import { Injectable } from '@nestjs/common';
|
|
2
|
-
import { ConfigService } from '@nestjs/config';
|
|
3
|
-
import * as fs from 'fs';
|
|
4
|
-
import * as path from 'path';
|
|
5
|
-
import type { AppConfig } from '../../config/config.interface';
|
|
6
|
-
import type { OpsProbe, OpsProbeResult } from './ops-probe.interface';
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* SQLite 备份历史探针:以表格形式列出每次三件套备份(时间 / 大小 / 是否含 WAL 旁文件)。
|
|
10
|
-
* 只读,来源同 SqliteBackupProbe:{dataDir}/backup/template.{env}.* 子目录。
|
|
11
|
-
*/
|
|
12
|
-
@Injectable()
|
|
13
|
-
export class SqliteBackupListProbe implements OpsProbe {
|
|
14
|
-
readonly key = 'sqlite-backup-list';
|
|
15
|
-
readonly name = '备份历史';
|
|
16
|
-
readonly kind = 'table' as const;
|
|
17
|
-
|
|
18
|
-
constructor(private readonly config: ConfigService<AppConfig, true>) {}
|
|
19
|
-
|
|
20
|
-
private resolveDataDir(): string {
|
|
21
|
-
const dir = this.config.get<AppConfig['database']>('database')!.dir;
|
|
22
|
-
return path.isAbsolute(dir) ? dir : path.resolve(process.cwd(), dir);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
async collect(): Promise<OpsProbeResult> {
|
|
26
|
-
const env = process.env.NODE_ENV === 'prod' ? 'prod' : 'develop';
|
|
27
|
-
const dataDir = this.resolveDataDir();
|
|
28
|
-
const backupDir = path.join(dataDir, 'backup');
|
|
29
|
-
const collectedAt = new Date().toISOString();
|
|
30
|
-
|
|
31
|
-
const base: OpsProbeResult = {
|
|
32
|
-
key: this.key,
|
|
33
|
-
name: this.name,
|
|
34
|
-
kind: 'table',
|
|
35
|
-
healthy: 'unknown',
|
|
36
|
-
summary: '',
|
|
37
|
-
detail: { kind: 'table', columns: [], rows: [] },
|
|
38
|
-
collectedAt
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
if (!fs.existsSync(backupDir)) {
|
|
42
|
-
base.summary = '备份目录不存在';
|
|
43
|
-
return base;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
let entries: string[] = [];
|
|
47
|
-
try {
|
|
48
|
-
entries = fs
|
|
49
|
-
.readdirSync(backupDir, { withFileTypes: true })
|
|
50
|
-
.filter((d) => d.isDirectory() && d.name.startsWith(`template.${env}.`))
|
|
51
|
-
.map((d) => d.name)
|
|
52
|
-
.sort()
|
|
53
|
-
.reverse();
|
|
54
|
-
} catch (e) {
|
|
55
|
-
base.summary = `读取备份目录失败: ${(e as Error).message}`;
|
|
56
|
-
return base;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
const baseName = (env === 'prod' ? 'template.prod' : 'template.develop') + '.sqlite';
|
|
60
|
-
const rows = entries.map((dirName) => {
|
|
61
|
-
const dirPath = path.join(backupDir, dirName);
|
|
62
|
-
const stat = fs.statSync(dirPath);
|
|
63
|
-
const mainFile = path.join(dirPath, baseName);
|
|
64
|
-
let mainBytes = 0;
|
|
65
|
-
try {
|
|
66
|
-
mainBytes = fs.statSync(mainFile).size;
|
|
67
|
-
} catch {
|
|
68
|
-
// 主库不在(残留目录),大小记 0
|
|
69
|
-
}
|
|
70
|
-
const hasWal = fs.existsSync(path.join(dirPath, `${baseName}-wal`));
|
|
71
|
-
const hasShm = fs.existsSync(path.join(dirPath, `${baseName}-shm`));
|
|
72
|
-
const tsMatch = dirName.match(/(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})$/);
|
|
73
|
-
const tsStr = tsMatch
|
|
74
|
-
? `${tsMatch[1]}-${tsMatch[2]}-${tsMatch[3]} ${tsMatch[4]}:${tsMatch[5]}:${tsMatch[6]}`
|
|
75
|
-
: dirName;
|
|
76
|
-
return {
|
|
77
|
-
ts: tsStr,
|
|
78
|
-
dirName,
|
|
79
|
-
mainBytes,
|
|
80
|
-
wal: hasWal ? '有' : '无',
|
|
81
|
-
shm: hasShm ? '有' : '无',
|
|
82
|
-
mtime: stat.mtime.toISOString()
|
|
83
|
-
};
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
base.summary = rows.length ? `共 ${rows.length} 份备份` : '暂无备份';
|
|
87
|
-
base.healthy = rows.length > 0;
|
|
88
|
-
base.detail = {
|
|
89
|
-
kind: 'table',
|
|
90
|
-
columns: [
|
|
91
|
-
{ key: 'ts', title: '备份时间' },
|
|
92
|
-
{ key: 'mainBytes', title: '主库大小' },
|
|
93
|
-
{ key: 'wal', title: 'WAL' },
|
|
94
|
-
{ key: 'shm', title: 'SHM' }
|
|
95
|
-
],
|
|
96
|
-
rows
|
|
97
|
-
};
|
|
98
|
-
return base;
|
|
99
|
-
}
|
|
100
|
-
}
|
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
import { Injectable } from '@nestjs/common';
|
|
2
|
-
import { ConfigService } from '@nestjs/config';
|
|
3
|
-
import * as fs from 'fs';
|
|
4
|
-
import * as path from 'path';
|
|
5
|
-
import type { AppConfig } from '../../config/config.interface';
|
|
6
|
-
import type { OpsProbe, OpsProbeResult } from './ops-probe.interface';
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* SQLite 备份日志探针:只读展示 cron 任务的 `>> logfile 2>&1` 日志尾部。
|
|
10
|
-
* 只读来源:{dataDir}/logs/sqlite-backup.log(backend/ops/sqlite/backup.sh 的输出落地,cron 与手动均写此)。
|
|
11
|
-
* 取尾部 50 行(日志可能很大,只看最近的)。
|
|
12
|
-
*/
|
|
13
|
-
@Injectable()
|
|
14
|
-
export class SqliteBackupLogProbe implements OpsProbe {
|
|
15
|
-
readonly key = 'sqlite-backup-log';
|
|
16
|
-
readonly name = '备份日志';
|
|
17
|
-
readonly kind = 'log' as const;
|
|
18
|
-
private readonly MAX_LINES = 50;
|
|
19
|
-
|
|
20
|
-
constructor(private readonly config: ConfigService<AppConfig, true>) {}
|
|
21
|
-
|
|
22
|
-
private resolveDataDir(): string {
|
|
23
|
-
const dir = this.config.get<AppConfig['database']>('database')!.dir;
|
|
24
|
-
return path.isAbsolute(dir) ? dir : path.resolve(process.cwd(), dir);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
async collect(): Promise<OpsProbeResult> {
|
|
28
|
-
const dataDir = this.resolveDataDir();
|
|
29
|
-
const logFile = path.join(dataDir, 'logs', 'sqlite-backup.log');
|
|
30
|
-
const collectedAt = new Date().toISOString();
|
|
31
|
-
|
|
32
|
-
const base: OpsProbeResult = {
|
|
33
|
-
key: this.key,
|
|
34
|
-
name: this.name,
|
|
35
|
-
kind: 'log',
|
|
36
|
-
healthy: 'unknown',
|
|
37
|
-
summary: '',
|
|
38
|
-
detail: { kind: 'log', lines: [] },
|
|
39
|
-
collectedAt
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
if (!fs.existsSync(logFile)) {
|
|
43
|
-
base.summary = '日志文件不存在(cron 未跑过或未配日志重定向)';
|
|
44
|
-
return base;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
try {
|
|
48
|
-
const content = fs.readFileSync(logFile, 'utf8');
|
|
49
|
-
// 过滤测试残留行(手写测试数据污染判定);真实失败行含 ❌ 且不含"测试"字样
|
|
50
|
-
const allLines = content.split('\n').filter((l) => l.length > 0 && !/测试|test/i.test(l));
|
|
51
|
-
const tail = allLines.slice(-this.MAX_LINES);
|
|
52
|
-
base.summary = `最近 ${tail.length} 行`;
|
|
53
|
-
// 判定:有 ✅ 成功行 = 跑过且成功过;含 ❌ 失败行 = 异常
|
|
54
|
-
const hasSuccess = tail.some((l) => l.includes('✅'));
|
|
55
|
-
const hasFail = tail.some((l) => l.includes('❌'));
|
|
56
|
-
base.healthy = hasSuccess && !hasFail ? true : hasFail ? false : 'unknown';
|
|
57
|
-
base.detail = { kind: 'log', lines: tail };
|
|
58
|
-
} catch (e) {
|
|
59
|
-
base.summary = `读取日志失败: ${(e as Error).message}`;
|
|
60
|
-
}
|
|
61
|
-
return base;
|
|
62
|
-
}
|
|
63
|
-
}
|
|
@@ -1,144 +0,0 @@
|
|
|
1
|
-
import { Injectable, Logger } from '@nestjs/common';
|
|
2
|
-
import { ConfigService } from '@nestjs/config';
|
|
3
|
-
import * as fs from 'fs';
|
|
4
|
-
import * as path from 'path';
|
|
5
|
-
import type { AppConfig } from '../../config/config.interface';
|
|
6
|
-
import type { OpsProbe, OpsProbeResult } from './ops-probe.interface';
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* SQLite 备份探针:只读展示三件套文件拷贝备份的状态。
|
|
10
|
-
*
|
|
11
|
-
* 读取两类来源(全是文件系统,不进 DB、不碰 cron):
|
|
12
|
-
* 1) 备份目录 {dataDir}/backup/template.{env}.* —— 每个子目录是一次备份三件套(主库+-wal+-shm)。
|
|
13
|
-
* 2) 日志文件 {dataDir}/logs/sqlite-backup.log —— cron 任务的 `>> logfile 2>&1` 落地,取尾部展示。
|
|
14
|
-
*
|
|
15
|
-
* dataDir 推导:database.dir 是 sqlite 主库所在目录(prod 部署后为绝对路径 ${dataDir};
|
|
16
|
-
* develop 为相对路径 data,相对 process.cwd())。dir 即 dataDir(无需 dirname)。
|
|
17
|
-
* docker 模式下 dataDir 经 bind mount 映射为容器内 /app/data,app 读到的就是容器内绝对路径,
|
|
18
|
-
* 与宿主 backup 目录是同一份(bind mount),探针读到的状态即宿主真实状态。
|
|
19
|
-
*
|
|
20
|
-
* 健康判定(不谎称完整性,只判新鲜度 + 份数):
|
|
21
|
-
* - backup 目录不存在 / 无备份 → unknown(首次部署或未配 cron)
|
|
22
|
-
* - 最近一次备份 mtime 在 25h 内(每日 cron + 1h 余量)且份数 ≥1 → healthy=true
|
|
23
|
-
* - 超过 25h → healthy=false(cron 可能漏跑)
|
|
24
|
-
* - 不检测库完整性(需 sqlite3 CLI,服务器不一定有,别假设)
|
|
25
|
-
*
|
|
26
|
-
* 异常兜底:目录/文件读不到一律转成 healthy=unknown + summary 说明原因,不抛错。
|
|
27
|
-
*/
|
|
28
|
-
@Injectable()
|
|
29
|
-
export class SqliteBackupProbe implements OpsProbe {
|
|
30
|
-
private readonly logger = new Logger(SqliteBackupProbe.name);
|
|
31
|
-
readonly key = 'sqlite-backup';
|
|
32
|
-
readonly name = 'SQLite 备份';
|
|
33
|
-
readonly kind = 'status' as const;
|
|
34
|
-
|
|
35
|
-
// 一并返回 table + log 视图:通过 collect 组合多视图。
|
|
36
|
-
// 但接口约定一个 probe 一个 kind;这里主视图用 status,明细走 overview 接口的多 probe 组合?
|
|
37
|
-
// ——为保持 A 阶段简单且前端数据驱动,本 probe 返回 kind='status',detail 含 items;
|
|
38
|
-
// 备份明细表与日志尾作为**额外 probe**(SqliteBackupListProbe / SqliteBackupLogProbe)注册,
|
|
39
|
-
// 各自独立 kind=table / kind=log。这样前端循环渲染,新增明细不改 status 卡。
|
|
40
|
-
|
|
41
|
-
constructor(private readonly config: ConfigService<AppConfig, true>) {}
|
|
42
|
-
|
|
43
|
-
/** 解析 dataDir 绝对路径 */
|
|
44
|
-
private resolveDataDir(): string {
|
|
45
|
-
const dir = this.config.get<AppConfig['database']>('database')!.dir;
|
|
46
|
-
// 相对路径相对 process.cwd()(develop);绝对路径原样(prod 部署后)
|
|
47
|
-
return path.isAbsolute(dir) ? dir : path.resolve(process.cwd(), dir);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
async collect(): Promise<OpsProbeResult> {
|
|
51
|
-
const env = process.env.NODE_ENV === 'prod' ? 'prod' : 'develop';
|
|
52
|
-
const dataDir = this.resolveDataDir();
|
|
53
|
-
const backupDir = path.join(dataDir, 'backup');
|
|
54
|
-
const thresholdHours = 25; // 每日 cron + 1h 余量
|
|
55
|
-
const collectedAt = new Date().toISOString();
|
|
56
|
-
|
|
57
|
-
// helper:构造一条 status 结果
|
|
58
|
-
const statusResult = (
|
|
59
|
-
healthy: boolean | 'unknown',
|
|
60
|
-
summary: string,
|
|
61
|
-
items: { label: string; value: string }[]
|
|
62
|
-
): OpsProbeResult => ({
|
|
63
|
-
key: this.key,
|
|
64
|
-
name: this.name,
|
|
65
|
-
kind: 'status',
|
|
66
|
-
healthy,
|
|
67
|
-
summary,
|
|
68
|
-
detail: { kind: 'status', items },
|
|
69
|
-
collectedAt
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
if (!fs.existsSync(backupDir)) {
|
|
73
|
-
return statusResult('unknown', '备份目录不存在(未配 cron 或首次部署)', [
|
|
74
|
-
{ label: '备份目录', value: backupDir }
|
|
75
|
-
]);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// 列出 template.{env}.* 子目录(三件套备份目录),按名字倒序(名含时间戳,字典序≈时间倒序)
|
|
79
|
-
let entries: string[] = [];
|
|
80
|
-
try {
|
|
81
|
-
entries = fs
|
|
82
|
-
.readdirSync(backupDir, { withFileTypes: true })
|
|
83
|
-
.filter((d) => d.isDirectory() && d.name.startsWith(`template.${env}.`))
|
|
84
|
-
.map((d) => d.name)
|
|
85
|
-
.sort()
|
|
86
|
-
.reverse();
|
|
87
|
-
} catch (e) {
|
|
88
|
-
return statusResult('unknown', `读取备份目录失败: ${(e as Error).message}`, [
|
|
89
|
-
{ label: '备份目录', value: backupDir }
|
|
90
|
-
]);
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
if (entries.length === 0) {
|
|
94
|
-
return statusResult('unknown', '暂无备份(未配 cron 或首次部署)', [
|
|
95
|
-
{ label: '备份目录', value: backupDir },
|
|
96
|
-
{ label: '备份数', value: '0' }
|
|
97
|
-
]);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
const latestDir = entries[0];
|
|
101
|
-
const latestPath = path.join(backupDir, latestDir);
|
|
102
|
-
const stat = fs.statSync(latestPath);
|
|
103
|
-
const ageHours = (Date.now() - stat.mtimeMs) / 3_600_000;
|
|
104
|
-
const healthy = ageHours <= thresholdHours;
|
|
105
|
-
const triplet = this.readTriplet(latestPath);
|
|
106
|
-
|
|
107
|
-
return statusResult(
|
|
108
|
-
healthy,
|
|
109
|
-
healthy
|
|
110
|
-
? `最近备份 ${this.formatAge(ageHours)}前 · 共 ${entries.length} 份`
|
|
111
|
-
: `最近备份已是 ${this.formatAge(ageHours)}前(超 ${thresholdHours}h,cron 可能漏跑)`,
|
|
112
|
-
[
|
|
113
|
-
{ label: '最近备份', value: `${this.formatTs(latestDir)}(${this.formatAge(ageHours)}前)` },
|
|
114
|
-
{ label: '备份数', value: String(entries.length) },
|
|
115
|
-
{ label: '新鲜度阈值', value: `${thresholdHours}h` },
|
|
116
|
-
{ label: '最近三件套', value: triplet || '仅主库(无 WAL 旁文件)' },
|
|
117
|
-
{ label: '备份目录', value: backupDir }
|
|
118
|
-
]
|
|
119
|
-
);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/** 读备份目录里三件套构成描述(如 template.prod.sqlite + -wal + -shm) */
|
|
123
|
-
private readTriplet(dir: string): string {
|
|
124
|
-
const env = process.env.NODE_ENV === 'prod' ? 'prod' : 'develop';
|
|
125
|
-
const base = `template.${env}.sqlite`;
|
|
126
|
-
const parts: string[] = [base];
|
|
127
|
-
if (fs.existsSync(path.join(dir, `${base}-wal`))) parts.push(`${base}-wal`);
|
|
128
|
-
if (fs.existsSync(path.join(dir, `${base}-shm`))) parts.push(`${base}-shm`);
|
|
129
|
-
return parts.join(' + ');
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
private formatAge(hours: number): string {
|
|
133
|
-
if (hours < 1) return `${Math.round(hours * 60)} 分钟`;
|
|
134
|
-
if (hours < 24) return `${Math.round(hours)} 小时`;
|
|
135
|
-
return `${Math.round(hours / 24)} 天`;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
private formatTs(dirName: string): string {
|
|
139
|
-
// dirName 形如 template.prod.20260812-104311 → 2026-08-12 10:43:11
|
|
140
|
-
const m = dirName.match(/(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})$/);
|
|
141
|
-
if (!m) return dirName;
|
|
142
|
-
return `${m[1]}-${m[2]}-${m[3]} ${m[4]}:${m[5]}:${m[6]}`;
|
|
143
|
-
}
|
|
144
|
-
}
|