openlearn-next 0.3.7 → 0.3.9
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/cli-data.mjs +252 -0
- package/cli-doctor.mjs +189 -0
- package/cli.mjs +145 -17
- package/dist/assets/{BatchPickerModal-7SGYP69j.js → BatchPickerModal-C7hweQi7.js} +1 -1
- package/dist/assets/{ClassesView-DlykFThl.js → ClassesView-f7h7q1me.js} +1 -1
- package/dist/assets/{CloudDriveModal-Bu8HsM7n.js → CloudDriveModal-y1cqaYjU.js} +1 -1
- package/dist/assets/{CourseManagement-C780bMgl.js → CourseManagement-Bwfdk7Hi.js} +1 -1
- package/dist/assets/{CourseWizardModal-DFQqYI2e.js → CourseWizardModal-DwKB919Y.js} +1 -1
- package/dist/assets/{Dashboard-DZS7rPzE.js → Dashboard-7ElGQEot.js} +1 -1
- package/dist/assets/{ExportWeightModal-BmHMQzDA.js → ExportWeightModal-Dor_2k5X.js} +1 -1
- package/dist/assets/{HelpView-DNBtzj9s.js → HelpView-Blq21AX9.js} +1 -1
- package/dist/assets/{ImportLessonsModal-D98bqG93.js → ImportLessonsModal-BNb6POIc.js} +1 -1
- package/dist/assets/{InteractiveCoursewareViewer-Bu5lBr1Z.js → InteractiveCoursewareViewer-DZQNSStZ.js} +1 -1
- package/dist/assets/{InteractiveWhiteboard-BYQ-nyrj.js → InteractiveWhiteboard-B-6-PHls.js} +1 -1
- package/dist/assets/{LazyCourseware-DhgJCf1I.js → LazyCourseware-YQTwc8Xc.js} +1 -1
- package/dist/assets/{LazyWhiteboard-C1sVfdFD.js → LazyWhiteboard-DNwlH8kJ.js} +1 -1
- package/dist/assets/{LessonEditorView-HuuFFnNJ.js → LessonEditorView-Biw7dyfp.js} +1 -1
- package/dist/assets/{LiveClassroomView-BRGIGTze.js → LiveClassroomView-D2ZyINSZ.js} +1 -1
- package/dist/assets/{NotificationDetailModal-Bs5EVWer.js → NotificationDetailModal-TJSWdZgO.js} +1 -1
- package/dist/assets/{PluginView-BQj-2SI8.js → PluginView-B2m35ykl.js} +1 -1
- package/dist/assets/{QuizGeneratorModal-BYaKf7LZ.js → QuizGeneratorModal-XNW646aZ.js} +1 -1
- package/dist/assets/{StudentAssignmentView-BhvuQKZs.js → StudentAssignmentView-C7qnbBVV.js} +1 -1
- package/dist/assets/{StudentLessonView-B8IfZguN.js → StudentLessonView-DItUU7Zd.js} +1 -1
- package/dist/assets/{StudentPreviewModal-CQUH7R89.js → StudentPreviewModal-CjxvwHUc.js} +1 -1
- package/dist/assets/{StudentView-C5iJdzhc.js → StudentView-Bg6tAP5x.js} +1 -1
- package/dist/assets/{SystemResourceLibraryModal-DiUpZ3yP.js → SystemResourceLibraryModal-Cf2XJ6sf.js} +1 -1
- package/dist/assets/{TeacherView-e-0NyEqE.js → TeacherView-Baq66mut.js} +1 -1
- package/dist/assets/{index-CV2NRMHT.js → index-B7B4Vq2l.js} +3 -3
- package/dist/assets/{index-DnBjcpig.js → index-KP_ep5bf.js} +1 -1
- package/dist/index.html +1 -1
- package/dist/server.cjs +59 -5
- package/package.json +3 -1
package/cli-data.mjs
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 解析本地目标数据库路径
|
|
7
|
+
* @param {string} [customPath]
|
|
8
|
+
* @returns {string}
|
|
9
|
+
*/
|
|
10
|
+
export function resolveDbPath(customPath) {
|
|
11
|
+
if (customPath) return path.resolve(customPath);
|
|
12
|
+
if (process.env.OPENLEARN_DB_PATH) return path.resolve(process.env.OPENLEARN_DB_PATH);
|
|
13
|
+
return path.join(os.homedir(), 'openlearn-next', 'data.db');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 一键数据冷备
|
|
18
|
+
* @param {string} [outputFile]
|
|
19
|
+
* @param {Object} [options]
|
|
20
|
+
* @returns {Promise<{ ok: boolean, targetFile?: string, error?: string }>}
|
|
21
|
+
*/
|
|
22
|
+
export async function runBackup(outputFile, options = {}) {
|
|
23
|
+
const log = options.silent ? () => {} : (msg) => console.log(`[openlearn-next] ${msg}`);
|
|
24
|
+
const dbPath = resolveDbPath(options.dbPath);
|
|
25
|
+
|
|
26
|
+
if (!fs.existsSync(dbPath)) {
|
|
27
|
+
const msg = `数据库文件未找到: ${dbPath}`;
|
|
28
|
+
log(`✗ 备份失败: ${msg}`);
|
|
29
|
+
return { ok: false, error: msg };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const now = new Date();
|
|
33
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
34
|
+
const timestamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
|
|
35
|
+
const target = outputFile
|
|
36
|
+
? path.resolve(outputFile)
|
|
37
|
+
: path.resolve(process.cwd(), `openlearn_backup_${timestamp}.db`);
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const Database = (await import('better-sqlite3')).default;
|
|
41
|
+
const db = new Database(dbPath, { readonly: true });
|
|
42
|
+
await db.backup(target);
|
|
43
|
+
db.close();
|
|
44
|
+
|
|
45
|
+
const stat = fs.statSync(target);
|
|
46
|
+
const sizeKb = Math.round(stat.size / 1024);
|
|
47
|
+
log(`✓ 数据库冷备完成!`);
|
|
48
|
+
log(` 快照文件: ${target} (${sizeKb} KB)`);
|
|
49
|
+
return { ok: true, targetFile: target };
|
|
50
|
+
} catch (err) {
|
|
51
|
+
// 降级为物理文件复制
|
|
52
|
+
try {
|
|
53
|
+
fs.copyFileSync(dbPath, target);
|
|
54
|
+
const stat = fs.statSync(target);
|
|
55
|
+
const sizeKb = Math.round(stat.size / 1024);
|
|
56
|
+
log(`✓ 数据库文件冷备完成(直接镜像模式):${target} (${sizeKb} KB)`);
|
|
57
|
+
return { ok: true, targetFile: target };
|
|
58
|
+
} catch (copyErr) {
|
|
59
|
+
log(`✗ 备份写入失败: ${copyErr.message}`);
|
|
60
|
+
return { ok: false, error: copyErr.message };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 数据安全回滚与还原
|
|
67
|
+
* @param {string} sourceFile
|
|
68
|
+
* @param {Object} [options]
|
|
69
|
+
* @returns {Promise<{ ok: boolean, error?: string }>}
|
|
70
|
+
*/
|
|
71
|
+
export async function runRestore(sourceFile, options = {}) {
|
|
72
|
+
const log = options.silent ? () => {} : (msg) => console.log(`[openlearn-next] ${msg}`);
|
|
73
|
+
if (!sourceFile) {
|
|
74
|
+
const msg = '未指定待还原的备份文件。用法: npx openlearn-next restore <backup.db>';
|
|
75
|
+
log(`✗ ${msg}`);
|
|
76
|
+
return { ok: false, error: msg };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const src = path.resolve(sourceFile);
|
|
80
|
+
if (!fs.existsSync(src)) {
|
|
81
|
+
const msg = `指定的备份文件不存在: ${src}`;
|
|
82
|
+
log(`✗ ${msg}`);
|
|
83
|
+
return { ok: false, error: msg };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 校验 SQLite 文件头 (Magic Header: "SQLite format 3\0")
|
|
87
|
+
try {
|
|
88
|
+
const fd = fs.openSync(src, 'r');
|
|
89
|
+
const buffer = Buffer.alloc(16);
|
|
90
|
+
fs.readSync(fd, buffer, 0, 16, 0);
|
|
91
|
+
fs.closeSync(fd);
|
|
92
|
+
const headerStr = buffer.toString('utf-8');
|
|
93
|
+
if (!headerStr.startsWith('SQLite format 3')) {
|
|
94
|
+
const msg = `文件并非有效的 SQLite 数据库备份: ${src}`;
|
|
95
|
+
log(`✗ ${msg}`);
|
|
96
|
+
return { ok: false, error: msg };
|
|
97
|
+
}
|
|
98
|
+
} catch (err) {
|
|
99
|
+
log(`✗ 读取备份文件头失败: ${err.message}`);
|
|
100
|
+
return { ok: false, error: err.message };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const dbPath = resolveDbPath(options.dbPath);
|
|
104
|
+
const dbDir = path.dirname(dbPath);
|
|
105
|
+
fs.mkdirSync(dbDir, { recursive: true });
|
|
106
|
+
|
|
107
|
+
// 创建自动回滚副本
|
|
108
|
+
if (fs.existsSync(dbPath)) {
|
|
109
|
+
const bakFile = `${dbPath}.bak_${Date.now()}`;
|
|
110
|
+
fs.copyFileSync(dbPath, bakFile);
|
|
111
|
+
log(`ℹ 已为现有数据库创建安全回滚镜像: ${bakFile}`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
fs.copyFileSync(src, dbPath);
|
|
116
|
+
// 清理旧的 WAL 与 SHM
|
|
117
|
+
const wal = `${dbPath}-wal`;
|
|
118
|
+
const shm = `${dbPath}-shm`;
|
|
119
|
+
if (fs.existsSync(wal)) fs.unlinkSync(wal);
|
|
120
|
+
if (fs.existsSync(shm)) fs.unlinkSync(shm);
|
|
121
|
+
|
|
122
|
+
log(`✓ 数据库还原成功!当前主库已切换为 ${src} 的数据。`);
|
|
123
|
+
return { ok: true };
|
|
124
|
+
} catch (err) {
|
|
125
|
+
log(`✗ 覆盖写入数据库失败: ${err.message}`);
|
|
126
|
+
return { ok: false, error: err.message };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* 命令行一键重置管理员 (admin) 密码
|
|
132
|
+
* @param {string} [newPassword='admin']
|
|
133
|
+
* @param {Object} [options]
|
|
134
|
+
* @returns {Promise<{ ok: boolean, error?: string }>}
|
|
135
|
+
*/
|
|
136
|
+
export async function runResetAdmin(newPassword = 'admin', options = {}) {
|
|
137
|
+
const log = options.silent ? () => {} : (msg) => console.log(`[openlearn-next] ${msg}`);
|
|
138
|
+
const dbPath = resolveDbPath(options.dbPath);
|
|
139
|
+
|
|
140
|
+
if (!fs.existsSync(dbPath)) {
|
|
141
|
+
const msg = `数据库尚未初始化 (${dbPath})。请直接启动一次平台即可生成默认 admin/admin 账号。`;
|
|
142
|
+
log(`ℹ ${msg}`);
|
|
143
|
+
return { ok: false, error: msg };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
let hash;
|
|
148
|
+
try {
|
|
149
|
+
const bcrypt = (await import('bcryptjs')).default;
|
|
150
|
+
hash = bcrypt.hashSync(newPassword, 10);
|
|
151
|
+
} catch {
|
|
152
|
+
const crypto = await import('node:crypto');
|
|
153
|
+
hash = crypto.createHash('sha256').update(newPassword).digest('hex');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const Database = (await import('better-sqlite3')).default;
|
|
157
|
+
const db = new Database(dbPath);
|
|
158
|
+
|
|
159
|
+
const updateRes = db.prepare('UPDATE users SET password_hash = ? WHERE username = ?').run(hash, 'admin');
|
|
160
|
+
|
|
161
|
+
if (updateRes.changes === 0) {
|
|
162
|
+
// 若数据库中无 admin 用户,则插入
|
|
163
|
+
db.prepare('INSERT INTO users (id, username, password_hash, role, name, created_at) VALUES (?, ?, ?, ?, ?, ?)')
|
|
164
|
+
.run(`usr_admin_${Date.now()}`, 'admin', hash, 'administrator', 'System Admin', Date.now());
|
|
165
|
+
log(`✓ 管理员账号不存在,已新建管理员账号: admin (密码: ${newPassword})`);
|
|
166
|
+
} else {
|
|
167
|
+
log(`✓ 管理员 (admin) 密码已成功重置为: ${newPassword}`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
db.close();
|
|
171
|
+
return { ok: true };
|
|
172
|
+
} catch (err) {
|
|
173
|
+
log(`✗ 重置密码失败: ${err.message}`);
|
|
174
|
+
return { ok: false, error: err.message };
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* 命令行插件状态速查
|
|
180
|
+
* @param {Object} [options]
|
|
181
|
+
* @returns {Promise<{ ok: boolean, plugins?: Array<any>, error?: string }>}
|
|
182
|
+
*/
|
|
183
|
+
export async function runPluginsList(options = {}) {
|
|
184
|
+
const log = options.silent ? () => {} : (msg) => console.log(`[openlearn-next] ${msg}`);
|
|
185
|
+
const dbPath = resolveDbPath(options.dbPath);
|
|
186
|
+
|
|
187
|
+
if (!fs.existsSync(dbPath)) {
|
|
188
|
+
log(`数据库文件尚未创建 (${dbPath}),暂无插件安装记录。`);
|
|
189
|
+
return { ok: true, plugins: [] };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
const Database = (await import('better-sqlite3')).default;
|
|
194
|
+
const db = new Database(dbPath, { readonly: true });
|
|
195
|
+
|
|
196
|
+
// 检查 plugins 表是否存在
|
|
197
|
+
const tableCheck = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='plugins'").get();
|
|
198
|
+
if (!tableCheck) {
|
|
199
|
+
log(`未检测到 plugins 数据表,平台尚未加载插件体系。`);
|
|
200
|
+
db.close();
|
|
201
|
+
return { ok: true, plugins: [] };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const rows = db.prepare('SELECT * FROM plugins ORDER BY id ASC').all();
|
|
205
|
+
db.close();
|
|
206
|
+
|
|
207
|
+
const plugins = rows.map((p) => {
|
|
208
|
+
let version = '1.0.0';
|
|
209
|
+
if (p.manifest) {
|
|
210
|
+
try {
|
|
211
|
+
const parsed = JSON.parse(p.manifest);
|
|
212
|
+
if (parsed.version) version = parsed.version;
|
|
213
|
+
} catch {}
|
|
214
|
+
}
|
|
215
|
+
return {
|
|
216
|
+
id: p.id,
|
|
217
|
+
name: p.name,
|
|
218
|
+
version,
|
|
219
|
+
status: p.status,
|
|
220
|
+
loader_version: p.loader_version || p.execution_mode || 'inline',
|
|
221
|
+
};
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
if (!options.silent) {
|
|
225
|
+
const bold = '\x1b[1m';
|
|
226
|
+
const green = '\x1b[32m';
|
|
227
|
+
const yellow = '\x1b[33m';
|
|
228
|
+
const cyan = '\x1b[36m';
|
|
229
|
+
const reset = '\x1b[0m';
|
|
230
|
+
|
|
231
|
+
console.log(`\n${bold}${cyan}已安装插件清单 (${plugins.length} 个):${reset}`);
|
|
232
|
+
console.log(`┌──────────────────────────────────────────────┬─────────┬──────────┬──────────┐`);
|
|
233
|
+
console.log(`│ ${bold}Plugin ID${reset}${' '.repeat(37)}│ ${bold}Version${reset} │ ${bold}Status${reset} │ ${bold}Mode${reset} │`);
|
|
234
|
+
console.log(`├──────────────────────────────────────────────┼─────────┼──────────┼──────────┤`);
|
|
235
|
+
|
|
236
|
+
for (const p of plugins) {
|
|
237
|
+
const idCol = p.id.padEnd(44);
|
|
238
|
+
const verCol = (p.version || '1.0.0').padEnd(7);
|
|
239
|
+
const statusColor = p.status === 'active' ? green : yellow;
|
|
240
|
+
const statusCol = `${statusColor}${p.status.padEnd(8)}${reset}`;
|
|
241
|
+
const modeCol = (p.loader_version || 'inline').padEnd(8);
|
|
242
|
+
console.log(`│ ${idCol} │ ${verCol} │ ${statusCol} │ ${modeCol} │`);
|
|
243
|
+
}
|
|
244
|
+
console.log(`└──────────────────────────────────────────────┴─────────┴──────────┴──────────┘\n`);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return { ok: true, plugins };
|
|
248
|
+
} catch (err) {
|
|
249
|
+
log(`✗ 查询插件失败: ${err.message}`);
|
|
250
|
+
return { ok: false, error: err.message };
|
|
251
|
+
}
|
|
252
|
+
}
|
package/cli-doctor.mjs
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import net from 'node:net';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 诊断指定端口是否可用
|
|
8
|
+
* @param {number} port
|
|
9
|
+
* @param {string} host
|
|
10
|
+
* @returns {Promise<boolean>}
|
|
11
|
+
*/
|
|
12
|
+
export function checkPortAvailable(port, host = '0.0.0.0') {
|
|
13
|
+
return new Promise((resolve) => {
|
|
14
|
+
const server = net.createServer();
|
|
15
|
+
server.once('error', (err) => {
|
|
16
|
+
if (err.code === 'EADDRINUSE') {
|
|
17
|
+
resolve(false);
|
|
18
|
+
} else {
|
|
19
|
+
resolve(false);
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
server.once('listening', () => {
|
|
23
|
+
server.close(() => resolve(true));
|
|
24
|
+
});
|
|
25
|
+
server.listen(port, host);
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 获取本机所有局域网可用 IPv4 地址
|
|
31
|
+
* @returns {string[]}
|
|
32
|
+
*/
|
|
33
|
+
export function getNetworkIps() {
|
|
34
|
+
const ips = [];
|
|
35
|
+
const interfaces = os.networkInterfaces();
|
|
36
|
+
for (const name of Object.keys(interfaces)) {
|
|
37
|
+
for (const iface of interfaces[name] || []) {
|
|
38
|
+
if (iface.family === 'IPv4' && !iface.internal && !iface.address.startsWith('127.')) {
|
|
39
|
+
ips.push(iface.address);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return ips;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 运行系统环境自检与健康诊断
|
|
48
|
+
* @param {Object} [options]
|
|
49
|
+
* @param {number} [options.port]
|
|
50
|
+
* @param {string} [options.dbPath]
|
|
51
|
+
* @param {boolean} [options.silent]
|
|
52
|
+
* @returns {Promise<{ ok: boolean, checks: Array<{ name: string, status: 'ok'|'warn'|'err', message: string }> }>}
|
|
53
|
+
*/
|
|
54
|
+
export async function runDoctor(options = {}) {
|
|
55
|
+
const checks = [];
|
|
56
|
+
const silent = Boolean(options.silent);
|
|
57
|
+
|
|
58
|
+
// 1. Node.js 版本检查 (>= 20.0.0)
|
|
59
|
+
const nodeVersion = process.versions.node;
|
|
60
|
+
const majorNodeVersion = parseInt(nodeVersion.split('.')[0], 10);
|
|
61
|
+
if (majorNodeVersion >= 20) {
|
|
62
|
+
checks.push({
|
|
63
|
+
name: 'Node.js Runtime',
|
|
64
|
+
status: 'ok',
|
|
65
|
+
message: `v${nodeVersion} (满足 >= 20.0.0 要求)`,
|
|
66
|
+
});
|
|
67
|
+
} else {
|
|
68
|
+
checks.push({
|
|
69
|
+
name: 'Node.js Runtime',
|
|
70
|
+
status: 'err',
|
|
71
|
+
message: `v${nodeVersion} 过低!OpenLearn 核心需要 Node.js >= 20.0.0,请升级。`,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 2. 操作系统与硬件
|
|
76
|
+
const cpus = os.cpus() || [];
|
|
77
|
+
const totalMemGb = Math.round((os.totalmem() / 1024 / 1024 / 1024) * 10) / 10;
|
|
78
|
+
const freeMemMb = Math.round(os.freemem() / 1024 / 1024);
|
|
79
|
+
checks.push({
|
|
80
|
+
name: 'Hardware & OS',
|
|
81
|
+
status: 'ok',
|
|
82
|
+
message: `${process.platform} (${process.arch}, ${cpus.length} CPU 核心, ${totalMemGb} GB 内存)`,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
if (freeMemMb < 256) {
|
|
86
|
+
checks.push({
|
|
87
|
+
name: 'Memory Headroom',
|
|
88
|
+
status: 'warn',
|
|
89
|
+
message: `剩余可用内存偏低 (${freeMemMb} MB),可能影响大课件解析`,
|
|
90
|
+
});
|
|
91
|
+
} else {
|
|
92
|
+
checks.push({
|
|
93
|
+
name: 'Memory Headroom',
|
|
94
|
+
status: 'ok',
|
|
95
|
+
message: `充足 (当前剩余 ${freeMemMb} MB 可用)`,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// 3. 数据库目录与读写权限
|
|
100
|
+
const dbPath = options.dbPath
|
|
101
|
+
? path.resolve(options.dbPath)
|
|
102
|
+
: (process.env.OPENLEARN_DB_PATH
|
|
103
|
+
? path.resolve(process.env.OPENLEARN_DB_PATH)
|
|
104
|
+
: path.join(os.homedir(), 'openlearn-next', 'data.db'));
|
|
105
|
+
const dbDir = path.dirname(dbPath);
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
fs.mkdirSync(dbDir, { recursive: true });
|
|
109
|
+
// 测试临时写入
|
|
110
|
+
const testFile = path.join(dbDir, `.doctor_write_test_${Date.now()}`);
|
|
111
|
+
fs.writeFileSync(testFile, 'test');
|
|
112
|
+
fs.unlinkSync(testFile);
|
|
113
|
+
checks.push({
|
|
114
|
+
name: 'Database Storage',
|
|
115
|
+
status: 'ok',
|
|
116
|
+
message: `${dbPath} (目录正常且具备读写权限)`,
|
|
117
|
+
});
|
|
118
|
+
} catch (err) {
|
|
119
|
+
checks.push({
|
|
120
|
+
name: 'Database Storage',
|
|
121
|
+
status: 'err',
|
|
122
|
+
message: `目录 ${dbDir} 写入失败: ${err.message}`,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// 4. 端口可用性检查
|
|
127
|
+
const targetPort = options.port || parseInt(process.env.PORT || '9000', 10);
|
|
128
|
+
const isPortAvailable = await checkPortAvailable(targetPort);
|
|
129
|
+
if (isPortAvailable) {
|
|
130
|
+
checks.push({
|
|
131
|
+
name: `Port ${targetPort}`,
|
|
132
|
+
status: 'ok',
|
|
133
|
+
message: `可用 (未被其他进程占用)`,
|
|
134
|
+
});
|
|
135
|
+
} else {
|
|
136
|
+
checks.push({
|
|
137
|
+
name: `Port ${targetPort}`,
|
|
138
|
+
status: 'warn',
|
|
139
|
+
message: `已被占用!启动时会自动重试或可通过 -p <other_port> 更换端口`,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// 5. 局域网接入点
|
|
144
|
+
const netIps = getNetworkIps();
|
|
145
|
+
if (netIps.length > 0) {
|
|
146
|
+
checks.push({
|
|
147
|
+
name: 'Network Access',
|
|
148
|
+
status: 'ok',
|
|
149
|
+
message: `检测到局域网 IP: ${netIps.join(', ')}`,
|
|
150
|
+
});
|
|
151
|
+
} else {
|
|
152
|
+
checks.push({
|
|
153
|
+
name: 'Network Access',
|
|
154
|
+
status: 'warn',
|
|
155
|
+
message: `未检测到外部局域网 IPv4 地址(仅可通过 localhost 本机访问)`,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const allOk = checks.every((c) => c.status !== 'err');
|
|
160
|
+
|
|
161
|
+
if (!silent) {
|
|
162
|
+
const bold = '\x1b[1m';
|
|
163
|
+
const green = '\x1b[32m';
|
|
164
|
+
const yellow = '\x1b[33m';
|
|
165
|
+
const red = '\x1b[31m';
|
|
166
|
+
const cyan = '\x1b[36m';
|
|
167
|
+
const reset = '\x1b[0m';
|
|
168
|
+
|
|
169
|
+
console.log(`\n${bold}${cyan}╔═════════════════════════════════════════════════════════════╗${reset}`);
|
|
170
|
+
console.log(`${bold}${cyan}║ OpenLearn V2 System Diagnostics (doctor) ║${reset}`);
|
|
171
|
+
console.log(`${bold}${cyan}╚═════════════════════════════════════════════════════════════╝${reset}\n`);
|
|
172
|
+
|
|
173
|
+
for (const c of checks) {
|
|
174
|
+
let icon = `${green}✓${reset}`;
|
|
175
|
+
if (c.status === 'warn') icon = `${yellow}⚠${reset}`;
|
|
176
|
+
if (c.status === 'err') icon = `${red}✗${reset}`;
|
|
177
|
+
console.log(` ${icon} ${bold}${c.name.padEnd(18)}${reset} : ${c.message}`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
console.log(`\n${cyan}───────────────────────────────────────────────────────────────${reset}`);
|
|
181
|
+
if (allOk) {
|
|
182
|
+
console.log(` ${bold}${green}诊断通过!核心运行环境健康,已具备课堂部署就绪状态。${reset}\n`);
|
|
183
|
+
} else {
|
|
184
|
+
console.log(` ${bold}${red}诊断发现阻断性问题,请根据上述提示处理后重试。${reset}\n`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return { ok: allOk, checks };
|
|
189
|
+
}
|
package/cli.mjs
CHANGED
|
@@ -2,16 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
import { spawn } from 'node:child_process';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
|
-
import { dirname, join } from 'node:path';
|
|
6
|
-
import { mkdirSync, readFileSync } from 'node:fs';
|
|
5
|
+
import { dirname, join, resolve } from 'node:path';
|
|
6
|
+
import { mkdirSync, readFileSync, existsSync, rmSync } from 'node:fs';
|
|
7
7
|
import os from 'node:os';
|
|
8
|
+
|
|
8
9
|
import { runClean } from './cli-cleaner.mjs';
|
|
10
|
+
import { runDoctor } from './cli-doctor.mjs';
|
|
11
|
+
import { runBackup, runRestore, runResetAdmin, runPluginsList } from './cli-data.mjs';
|
|
9
12
|
|
|
10
13
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
11
14
|
|
|
12
15
|
const args = process.argv.slice(2);
|
|
16
|
+
const firstArg = args[0] || '';
|
|
13
17
|
|
|
14
|
-
// ── Check Version
|
|
18
|
+
// ── Check Version ────────────────────────────────────────────────────────────
|
|
15
19
|
if (args.includes('-v') || args.includes('--version')) {
|
|
16
20
|
try {
|
|
17
21
|
const pkg = JSON.parse(readFileSync(join(__dirname, 'package.json'), 'utf-8'));
|
|
@@ -22,31 +26,70 @@ if (args.includes('-v') || args.includes('--version')) {
|
|
|
22
26
|
process.exit(0);
|
|
23
27
|
}
|
|
24
28
|
|
|
29
|
+
// ── Help Documentation ───────────────────────────────────────────────────────
|
|
25
30
|
if (args.includes('-h') || args.includes('--help')) {
|
|
26
31
|
console.log(`Usage: npx openlearn-next [command] [options]
|
|
27
32
|
|
|
28
33
|
Commands:
|
|
34
|
+
doctor 运行环境自检与健康诊断 (Node.js/硬件内存/存储权限/端口/网络)
|
|
35
|
+
backup [file] 一键数据库快照在线备份 (默认输出至当前目录 openlearn_backup_*.db)
|
|
36
|
+
restore <file> 从备份快照文件安全还原数据 (自动生成现有数据库回滚副本)
|
|
37
|
+
reset-admin [options] 重置系统管理员 (admin) 密码 (默认重置为 admin)
|
|
38
|
+
选项: --password, -P <pwd>
|
|
39
|
+
plugins [list] 查看当前已安装的平台插件清单与加载状态
|
|
29
40
|
clean, clean-cache 清理 NPX 包缓存与本地临时运行数据
|
|
30
41
|
选项:
|
|
31
42
|
--npx 仅清理 ~/.npm/_npx 中的 openlearn-next 历史包缓存
|
|
32
43
|
--db 重置本地 SQLite 数据库 (下次启动全新自动初始化)
|
|
33
44
|
--all 清理全部(NPX 历史包 + 运行临时日志 + 重置数据库)
|
|
34
45
|
|
|
35
|
-
Options:
|
|
36
|
-
-p, --port <port>
|
|
46
|
+
Server Options:
|
|
47
|
+
-p, --port <port> 服务监听端口 (默认: 9000 或 process.env.PORT)
|
|
48
|
+
-H, --host <host> 绑定网络接口 IP (默认: 0.0.0.0 局域网全网监听)
|
|
49
|
+
-o, --open 服务就绪后自动唤起系统默认浏览器访问
|
|
50
|
+
--demo, --temp 临时演示沙盒模式:在系统临时目录创建一次性数据库,退出即自动销毁
|
|
51
|
+
--cors <origins> 允许跨域访问来源白名单 (多个以逗号分隔,如: "http://example.com,*")
|
|
52
|
+
--db-path <path> 自定义 SQLite 数据库文件绝对路径
|
|
37
53
|
-v, --version 显示版本号
|
|
38
54
|
-h, --help 显示此帮助信息
|
|
39
|
-
--clean, --clean-cache 快捷清理缓存并退出
|
|
40
55
|
|
|
41
|
-
Environment:
|
|
42
|
-
|
|
43
|
-
|
|
56
|
+
Environment Variables:
|
|
57
|
+
PORT HTTP 服务端口 (默认: 9000)
|
|
58
|
+
HOST 监听主机地址 (默认: 0.0.0.0)
|
|
59
|
+
OPENLEARN_DB_PATH SQLite 数据库绝对路径 (默认: ~/openlearn-next/data.db)
|
|
60
|
+
ALLOWED_ORIGINS CORS 白名单来源 (默认放行同源与开发环境)
|
|
61
|
+
GEMINI_API_KEY AI 助手兜底密钥 (推荐在管理员后台界面配置)
|
|
44
62
|
`);
|
|
45
63
|
process.exit(0);
|
|
46
64
|
}
|
|
47
65
|
|
|
48
|
-
// ──
|
|
49
|
-
|
|
66
|
+
// ── Extract Options Helper ──────────────────────────────────────────────────
|
|
67
|
+
let customPort = null;
|
|
68
|
+
let customHost = null;
|
|
69
|
+
let customDbPath = null;
|
|
70
|
+
let customCors = null;
|
|
71
|
+
const isDemo = args.includes('--demo') || args.includes('--temp');
|
|
72
|
+
const isOpen = args.includes('-o') || args.includes('--open');
|
|
73
|
+
|
|
74
|
+
for (let i = 0; i < args.length; i++) {
|
|
75
|
+
if (args[i] === '-p' || args[i] === '--port') {
|
|
76
|
+
customPort = args[++i];
|
|
77
|
+
} else if (args[i] === '-H' || args[i] === '--host') {
|
|
78
|
+
customHost = args[++i];
|
|
79
|
+
} else if (args[i] === '--db-path') {
|
|
80
|
+
customDbPath = args[++i];
|
|
81
|
+
} else if (args[i] === '--cors') {
|
|
82
|
+
customCors = args[++i];
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (customPort) process.env.PORT = customPort;
|
|
87
|
+
if (customHost) process.env.HOST = customHost;
|
|
88
|
+
if (customCors) process.env.ALLOWED_ORIGINS = customCors;
|
|
89
|
+
if (customDbPath) process.env.OPENLEARN_DB_PATH = resolve(customDbPath);
|
|
90
|
+
if (isOpen) process.env.OPEN_BROWSER = 'true';
|
|
91
|
+
|
|
92
|
+
// ── Command: clean / clean-cache ─────────────────────────────────────────────
|
|
50
93
|
const isCleanCommand =
|
|
51
94
|
firstArg === 'clean' ||
|
|
52
95
|
firstArg === 'clean-cache' ||
|
|
@@ -63,17 +106,64 @@ if (isCleanCommand) {
|
|
|
63
106
|
process.exit(0);
|
|
64
107
|
}
|
|
65
108
|
|
|
66
|
-
|
|
109
|
+
// ── Command: doctor ──────────────────────────────────────────────────────────
|
|
110
|
+
if (firstArg === 'doctor' || args.includes('--doctor')) {
|
|
111
|
+
const port = process.env.PORT ? parseInt(process.env.PORT, 10) : 9000;
|
|
112
|
+
const res = await runDoctor({ port, dbPath: process.env.OPENLEARN_DB_PATH });
|
|
113
|
+
process.exit(res.ok ? 0 : 1);
|
|
114
|
+
}
|
|
67
115
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
116
|
+
// ── Command: backup ──────────────────────────────────────────────────────────
|
|
117
|
+
if (firstArg === 'backup' || args.includes('--backup')) {
|
|
118
|
+
const backupIdx = args.findIndex((a) => a === 'backup' || a === '--backup');
|
|
119
|
+
let targetFile = undefined;
|
|
120
|
+
if (backupIdx !== -1 && args[backupIdx + 1] && !args[backupIdx + 1].startsWith('-')) {
|
|
121
|
+
targetFile = args[backupIdx + 1];
|
|
122
|
+
}
|
|
123
|
+
const res = await runBackup(targetFile, { dbPath: process.env.OPENLEARN_DB_PATH });
|
|
124
|
+
process.exit(res.ok ? 0 : 1);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ── Command: restore ─────────────────────────────────────────────────────────
|
|
128
|
+
if (firstArg === 'restore' || args.includes('--restore')) {
|
|
129
|
+
const restoreIdx = args.findIndex((a) => a === 'restore' || a === '--restore');
|
|
130
|
+
let sourceFile = undefined;
|
|
131
|
+
if (restoreIdx !== -1 && args[restoreIdx + 1] && !args[restoreIdx + 1].startsWith('-')) {
|
|
132
|
+
sourceFile = args[restoreIdx + 1];
|
|
133
|
+
}
|
|
134
|
+
const res = await runRestore(sourceFile, { dbPath: process.env.OPENLEARN_DB_PATH });
|
|
135
|
+
process.exit(res.ok ? 0 : 1);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ── Command: reset-admin ─────────────────────────────────────────────────────
|
|
139
|
+
if (firstArg === 'reset-admin' || args.includes('--reset-admin')) {
|
|
140
|
+
let newPassword = 'admin';
|
|
141
|
+
const pwdIdx = args.findIndex((a) => a === '--password' || a === '-P');
|
|
142
|
+
if (pwdIdx !== -1 && args[pwdIdx + 1] && !args[pwdIdx + 1].startsWith('-')) {
|
|
143
|
+
newPassword = args[pwdIdx + 1];
|
|
144
|
+
} else if (args[1] && !args[1].startsWith('-')) {
|
|
145
|
+
newPassword = args[1];
|
|
71
146
|
}
|
|
147
|
+
const res = await runResetAdmin(newPassword, { dbPath: process.env.OPENLEARN_DB_PATH });
|
|
148
|
+
process.exit(res.ok ? 0 : 1);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ── Command: plugins / plugins list ──────────────────────────────────────────
|
|
152
|
+
if (firstArg === 'plugins' || args.includes('--plugins')) {
|
|
153
|
+
const res = await runPluginsList({ dbPath: process.env.OPENLEARN_DB_PATH });
|
|
154
|
+
process.exit(res.ok ? 0 : 1);
|
|
72
155
|
}
|
|
73
156
|
|
|
74
|
-
|
|
157
|
+
// ── Normal Server Startup ────────────────────────────────────────────────────
|
|
158
|
+
let demoDir = null;
|
|
75
159
|
|
|
76
|
-
if (
|
|
160
|
+
if (isDemo) {
|
|
161
|
+
demoDir = join(os.tmpdir(), `openlearn-demo-${Date.now()}-${Math.random().toString(36).substring(2, 7)}`);
|
|
162
|
+
mkdirSync(demoDir, { recursive: true });
|
|
163
|
+
process.env.OPENLEARN_DB_PATH = join(demoDir, 'data.db');
|
|
164
|
+
console.log(`[openlearn-next] 临时沙盒模式已激活: ${demoDir}`);
|
|
165
|
+
console.log(`[openlearn-next] 提示: 退出程序时将自动销毁沙盒数据,不保留任何历史记录。`);
|
|
166
|
+
} else if (!process.env.OPENLEARN_DB_PATH) {
|
|
77
167
|
const dataDir = join(os.homedir(), 'openlearn-next');
|
|
78
168
|
mkdirSync(dataDir, { recursive: true });
|
|
79
169
|
process.env.OPENLEARN_DB_PATH = join(dataDir, 'data.db');
|
|
@@ -82,7 +172,11 @@ if (!process.env.OPENLEARN_DB_PATH) {
|
|
|
82
172
|
process.env.NODE_ENV = process.env.NODE_ENV || 'production';
|
|
83
173
|
|
|
84
174
|
console.log(`[openlearn-next] PORT=${process.env.PORT || '9000'}`);
|
|
175
|
+
console.log(`[openlearn-next] HOST=${process.env.HOST || '0.0.0.0'}`);
|
|
85
176
|
console.log(`[openlearn-next] DB=${process.env.OPENLEARN_DB_PATH}`);
|
|
177
|
+
if (process.env.ALLOWED_ORIGINS) {
|
|
178
|
+
console.log(`[openlearn-next] CORS_ALLOWED=${process.env.ALLOWED_ORIGINS}`);
|
|
179
|
+
}
|
|
86
180
|
|
|
87
181
|
const serverPath = join(__dirname, 'dist', 'server.cjs');
|
|
88
182
|
const child = spawn('node', [serverPath], {
|
|
@@ -90,6 +184,40 @@ const child = spawn('node', [serverPath], {
|
|
|
90
184
|
env: { ...process.env },
|
|
91
185
|
});
|
|
92
186
|
|
|
187
|
+
let cleanedUp = false;
|
|
188
|
+
const cleanup = () => {
|
|
189
|
+
if (cleanedUp) return;
|
|
190
|
+
cleanedUp = true;
|
|
191
|
+
if (isDemo && demoDir && existsSync(demoDir)) {
|
|
192
|
+
try {
|
|
193
|
+
rmSync(demoDir, { recursive: true, force: true });
|
|
194
|
+
console.log('\n[openlearn-next] 临时沙盒环境清理完毕。');
|
|
195
|
+
} catch {}
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
let isShuttingDown = false;
|
|
200
|
+
const handleSignal = (sig) => {
|
|
201
|
+
if (isShuttingDown) return;
|
|
202
|
+
isShuttingDown = true;
|
|
203
|
+
if (child && !child.killed) {
|
|
204
|
+
child.kill(sig);
|
|
205
|
+
// 3秒后强制保底退出
|
|
206
|
+
setTimeout(() => {
|
|
207
|
+
cleanup();
|
|
208
|
+
process.exit(0);
|
|
209
|
+
}, 3000).unref();
|
|
210
|
+
} else {
|
|
211
|
+
cleanup();
|
|
212
|
+
process.exit(0);
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
process.on('SIGINT', () => handleSignal('SIGINT'));
|
|
217
|
+
process.on('SIGTERM', () => handleSignal('SIGTERM'));
|
|
218
|
+
process.on('exit', cleanup);
|
|
219
|
+
|
|
93
220
|
child.on('exit', (code) => {
|
|
221
|
+
cleanup();
|
|
94
222
|
process.exit(code || 0);
|
|
95
223
|
});
|