@qcplay/cli 1.0.3 → 1.0.5
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 +14 -88
- package/bin/qcplay.js +846 -355
- package/package.json +3 -2
- package/templates/skills/qcplay-auth/SKILL.md +268 -0
- package/vendor/darwin/publish-article-darwin-arm64 +0 -0
- package/vendor/darwin/qcplay-auth-darwin-arm64 +0 -0
- package/vendor/win32/publish-article.exe +0 -0
- package/vendor/win32/qcplay-auth.exe +0 -0
package/bin/qcplay.js
CHANGED
|
@@ -1,14 +1,97 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
+
import { spawn } from "child_process";
|
|
3
4
|
import fs from "fs";
|
|
5
|
+
import http from "http";
|
|
6
|
+
import https from "https";
|
|
4
7
|
import os from "os";
|
|
5
8
|
import path from "path";
|
|
6
|
-
import
|
|
7
|
-
import { fileURLToPath } from "url";
|
|
9
|
+
import readline from "readline";
|
|
10
|
+
import { fileURLToPath, pathToFileURL } from "url";
|
|
8
11
|
|
|
9
12
|
const __filename = fileURLToPath(import.meta.url);
|
|
10
13
|
const __dirname = path.dirname(__filename);
|
|
11
14
|
const PACKAGE_JSON = path.resolve(__dirname, "../package.json");
|
|
15
|
+
const AUTH_PAGE = resolveAuthPagePath();
|
|
16
|
+
const DEFAULT_AUTH_BACKEND_URL = "https://cli.qcg.ink";
|
|
17
|
+
const DEFAULT_PUBLISH_BACKEND_URL = "https://cli.qcg.ink";
|
|
18
|
+
const DEFAULT_AUTH_PAGE_URL = "https://cli.qcg.ink/auth.html";
|
|
19
|
+
const DEFAULT_LOCAL_BASE_URL = "http://127.0.0.1:8787";
|
|
20
|
+
const ENV_AUTH_BACKEND_URL = process.env.QCPLAY_BACKEND_URL || process.env.QCPLAY_AUTH_BACKEND_URL;
|
|
21
|
+
const ENV_PUBLISH_BACKEND_URL = process.env.QCPLAY_PUBLISH_BACKEND_URL;
|
|
22
|
+
const ENV_AUTH_PAGE_URL = process.env.QCPLAY_AUTH_PAGE_URL;
|
|
23
|
+
const ENV_LOCAL_BASE_URL = process.env.QCPLAY_LOCAL_BASE_URL;
|
|
24
|
+
|
|
25
|
+
function resolveAuthPagePath() {
|
|
26
|
+
const repoAuthPage = path.resolve(__dirname, "../../web/auth.html");
|
|
27
|
+
if (fs.existsSync(repoAuthPage)) {
|
|
28
|
+
return repoAuthPage;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return path.resolve(__dirname, "../web/auth.html");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const CATEGORY_MAP = {
|
|
35
|
+
"2": "2",
|
|
36
|
+
"3": "3",
|
|
37
|
+
"7": "7",
|
|
38
|
+
"8": "8",
|
|
39
|
+
"9": "9",
|
|
40
|
+
"25": "25",
|
|
41
|
+
"26": "26",
|
|
42
|
+
"27": "27",
|
|
43
|
+
"29": "29",
|
|
44
|
+
"综合": "2",
|
|
45
|
+
"活动": "3",
|
|
46
|
+
"游戏攻略": "7",
|
|
47
|
+
"视频中心": "8",
|
|
48
|
+
"萌新入门": "9",
|
|
49
|
+
"萌新入门-攻略专区": "25",
|
|
50
|
+
"高手进阶": "26",
|
|
51
|
+
"活动攻略": "27",
|
|
52
|
+
"视频攻略": "29"
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const AREA_MAP = {
|
|
56
|
+
"1": "1",
|
|
57
|
+
"3": "3",
|
|
58
|
+
"4": "4",
|
|
59
|
+
pc: "1",
|
|
60
|
+
PC: "1",
|
|
61
|
+
"资料站": "3",
|
|
62
|
+
"公益网站": "4"
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const GAME_MAP = {
|
|
66
|
+
"39": "39",
|
|
67
|
+
"最强蜗牛": "39"
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const STATUS_MAP = {
|
|
71
|
+
"0": "0",
|
|
72
|
+
"1": "1",
|
|
73
|
+
"未发布": "0",
|
|
74
|
+
"已发布": "1",
|
|
75
|
+
"发布": "1"
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const BOOLEAN_TEXT_MAP = {
|
|
79
|
+
"0": "0",
|
|
80
|
+
"1": "1",
|
|
81
|
+
false: "0",
|
|
82
|
+
true: "1",
|
|
83
|
+
no: "0",
|
|
84
|
+
yes: "1",
|
|
85
|
+
"非热门": "0",
|
|
86
|
+
"热门": "1",
|
|
87
|
+
"不推荐": "0",
|
|
88
|
+
"推荐": "1"
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const QCPLAY_DIR = path.join(os.homedir(), ".qcplay");
|
|
92
|
+
const AGENTS_DIR = path.join(os.homedir(), ".agents");
|
|
93
|
+
const CONFIG_FILE = path.join(QCPLAY_DIR, "config.json");
|
|
94
|
+
const SKILLS_DIR = path.join(AGENTS_DIR, "skills");
|
|
12
95
|
|
|
13
96
|
function readPackageVersion() {
|
|
14
97
|
try {
|
|
@@ -20,13 +103,6 @@ function readPackageVersion() {
|
|
|
20
103
|
}
|
|
21
104
|
|
|
22
105
|
const PACKAGE_VERSION = readPackageVersion();
|
|
23
|
-
|
|
24
|
-
const QCPLAY_DIR = path.join(os.homedir(), ".qcplay");
|
|
25
|
-
const AGENTS_DIR = path.join(os.homedir(), ".agents");
|
|
26
|
-
const AUTH_FILE = path.join(QCPLAY_DIR, "auth.json");
|
|
27
|
-
const CONFIG_FILE = path.join(QCPLAY_DIR, "config.json");
|
|
28
|
-
const SKILLS_DIR = path.join(AGENTS_DIR, "skills");
|
|
29
|
-
|
|
30
106
|
const colorsEnabled = process.stdout.isTTY && process.env.NO_COLOR !== "1";
|
|
31
107
|
|
|
32
108
|
const chalk = {
|
|
@@ -49,13 +125,13 @@ function printRootHelp() {
|
|
|
49
125
|
console.log(`QCPlay CLI
|
|
50
126
|
|
|
51
127
|
Usage:
|
|
52
|
-
qcplay-cli install
|
|
53
|
-
qcplay-cli auth [login|status|logout]
|
|
54
|
-
qcplay-cli auth permissions [--key <key>] [--json]
|
|
128
|
+
qcplay-cli install [--local]
|
|
129
|
+
qcplay-cli auth [login|status|logout] [--local] [--backend <url>] [--auth-page <url>]
|
|
130
|
+
qcplay-cli auth permissions [--local] [--key <key>] [--json]
|
|
55
131
|
qcplay-cli article
|
|
56
132
|
qcplay-cli article init [file]
|
|
57
|
-
qcplay-cli article publish <file> [--
|
|
58
|
-
qcplay-cli www-article-list.store <file> [--
|
|
133
|
+
qcplay-cli article publish <file> [--local] [--backend <url>] [--dry-run]
|
|
134
|
+
qcplay-cli www-article-list.store <file> [--local] [--backend <url>] [--dry-run]
|
|
59
135
|
qcplay-cli features
|
|
60
136
|
qcplay-cli where
|
|
61
137
|
|
|
@@ -67,107 +143,48 @@ Options:
|
|
|
67
143
|
|
|
68
144
|
function printAuthHelp() {
|
|
69
145
|
console.log("Usage:");
|
|
70
|
-
console.log(" qcplay-cli auth");
|
|
71
|
-
console.log(" qcplay-cli auth
|
|
72
|
-
console.log(" qcplay-cli auth logout");
|
|
73
|
-
console.log(" qcplay-cli auth permissions [--key <key>] [--json]");
|
|
146
|
+
console.log(" qcplay-cli auth [login|status|logout] [--local] [--backend <url>] [--auth-page <url>]");
|
|
147
|
+
console.log(" qcplay-cli auth permissions [--local] [--backend <url>] [--key <key>] [--json]");
|
|
74
148
|
}
|
|
75
149
|
|
|
76
150
|
function printArticleHelp() {
|
|
77
151
|
console.log(`Usage:
|
|
78
152
|
qcplay-cli article
|
|
79
153
|
qcplay-cli article init [file]
|
|
80
|
-
qcplay-cli article publish <file> [--
|
|
154
|
+
qcplay-cli article publish <file> [--local] [--backend <url>] [--dry-run]`);
|
|
81
155
|
}
|
|
82
156
|
|
|
83
|
-
function
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
return [`${name}.exe`, name];
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
if (process.platform === "darwin") {
|
|
105
|
-
return [`${name}-darwin-${process.arch}`, name];
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
if (process.platform === "linux") {
|
|
109
|
-
return [`${name}-linux-${process.arch}`, name];
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
return [name];
|
|
157
|
+
function printFeatures() {
|
|
158
|
+
console.log("");
|
|
159
|
+
console.log(chalk.green("你现在可以使用以下功能:"));
|
|
160
|
+
console.log("");
|
|
161
|
+
console.log(chalk.cyan("1. 登录认证"));
|
|
162
|
+
console.log(" qcplay-cli auth");
|
|
163
|
+
console.log(chalk.gray(" 会唤起网页登录页,不再在控制台输入密码。"));
|
|
164
|
+
console.log("");
|
|
165
|
+
console.log(chalk.cyan("2. 查看权限"));
|
|
166
|
+
console.log(" qcplay-cli auth permissions");
|
|
167
|
+
console.log(" qcplay-cli auth permissions --key www-article-list.store");
|
|
168
|
+
console.log("");
|
|
169
|
+
console.log(chalk.cyan("3. 发布官网文章"));
|
|
170
|
+
console.log(" qcplay-cli www-article-list.store article.md");
|
|
171
|
+
console.log("");
|
|
172
|
+
console.log(chalk.cyan("4. 查看本地目录"));
|
|
173
|
+
console.log(" qcplay-cli where");
|
|
174
|
+
console.log("");
|
|
113
175
|
}
|
|
114
176
|
|
|
115
|
-
function
|
|
116
|
-
|
|
117
|
-
const candidates = getExeCandidates(name);
|
|
118
|
-
|
|
119
|
-
for (const candidate of candidates) {
|
|
120
|
-
const exePath = path.join(vendorDir, candidate);
|
|
121
|
-
if (fs.existsSync(exePath)) {
|
|
122
|
-
return exePath;
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
return path.join(vendorDir, candidates[0]);
|
|
177
|
+
function printWhere() {
|
|
178
|
+
console.log(QCPLAY_DIR);
|
|
127
179
|
}
|
|
128
180
|
|
|
129
|
-
function
|
|
130
|
-
return
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
if (!fs.existsSync(exePath)) {
|
|
134
|
-
reject(new Error(`执行程序不存在: ${exePath}`));
|
|
135
|
-
return;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
const child = spawn(exePath, args, {
|
|
139
|
-
stdio: "inherit",
|
|
140
|
-
shell: false
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
child.on("error", err => {
|
|
144
|
-
reject(err);
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
child.on("exit", code => {
|
|
148
|
-
if (code === 0) {
|
|
149
|
-
resolve();
|
|
150
|
-
} else {
|
|
151
|
-
reject(new Error(`${name} 执行失败,退出码: ${code}`));
|
|
152
|
-
}
|
|
153
|
-
});
|
|
181
|
+
function createReadline() {
|
|
182
|
+
return readline.createInterface({
|
|
183
|
+
input: process.stdin,
|
|
184
|
+
output: process.stdout
|
|
154
185
|
});
|
|
155
186
|
}
|
|
156
187
|
|
|
157
|
-
async function runAuthLogin() {
|
|
158
|
-
try {
|
|
159
|
-
await runNative("qcplay-auth", []);
|
|
160
|
-
} catch (err) {
|
|
161
|
-
const message = String(err?.message || err);
|
|
162
|
-
|
|
163
|
-
if (!message.includes("退出码")) {
|
|
164
|
-
throw err;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
await runNative("qcplay-auth", ["login"]);
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
|
|
171
188
|
async function pathExists(targetPath) {
|
|
172
189
|
try {
|
|
173
190
|
await fs.promises.access(targetPath);
|
|
@@ -199,16 +216,19 @@ async function copyRecursive(sourcePath, targetPath) {
|
|
|
199
216
|
|
|
200
217
|
async function writeJson(filePath, value) {
|
|
201
218
|
await ensureDir(path.dirname(filePath));
|
|
202
|
-
await fs.promises.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`,
|
|
219
|
+
await fs.promises.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, {
|
|
220
|
+
encoding: "utf8",
|
|
221
|
+
mode: 0o600
|
|
222
|
+
});
|
|
203
223
|
}
|
|
204
224
|
|
|
205
225
|
async function readJson(filePath) {
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
226
|
+
try {
|
|
227
|
+
const raw = await fs.promises.readFile(filePath, "utf8");
|
|
228
|
+
return JSON.parse(raw);
|
|
229
|
+
} catch {
|
|
230
|
+
return {};
|
|
231
|
+
}
|
|
212
232
|
}
|
|
213
233
|
|
|
214
234
|
async function ensureLocalDirs() {
|
|
@@ -227,102 +247,90 @@ async function installSkills() {
|
|
|
227
247
|
return true;
|
|
228
248
|
}
|
|
229
249
|
|
|
230
|
-
async function saveConfig() {
|
|
250
|
+
async function saveConfig(authBackendUrl, publishBackendUrl, authPageUrl, localMode = false) {
|
|
231
251
|
await writeJson(CONFIG_FILE, {
|
|
232
252
|
cli: "qcplay-cli",
|
|
233
253
|
version: PACKAGE_VERSION,
|
|
234
|
-
|
|
254
|
+
auth_backend_url: authBackendUrl,
|
|
255
|
+
publish_backend_url: publishBackendUrl,
|
|
256
|
+
auth_page_url: authPageUrl || "",
|
|
257
|
+
local_mode: localMode,
|
|
235
258
|
skills_dir: SKILLS_DIR,
|
|
236
259
|
configured_at: Date.now()
|
|
237
260
|
});
|
|
238
261
|
}
|
|
239
262
|
|
|
240
|
-
async function
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
ok: false,
|
|
244
|
-
reason: `认证文件不存在: ${AUTH_FILE}`
|
|
245
|
-
};
|
|
246
|
-
}
|
|
263
|
+
async function loadConfig() {
|
|
264
|
+
return readJson(CONFIG_FILE);
|
|
265
|
+
}
|
|
247
266
|
|
|
248
|
-
|
|
267
|
+
function normalizeText(value) {
|
|
268
|
+
return String(value ?? "").trim();
|
|
269
|
+
}
|
|
249
270
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
return
|
|
254
|
-
ok: false,
|
|
255
|
-
reason: `认证文件不是合法 JSON: ${err.message}`
|
|
256
|
-
};
|
|
271
|
+
function normalizeMappedValue(value, map) {
|
|
272
|
+
const text = normalizeText(value);
|
|
273
|
+
if (!text) {
|
|
274
|
+
return "";
|
|
257
275
|
}
|
|
258
276
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
auth.accessToken ||
|
|
262
|
-
auth.token ||
|
|
263
|
-
auth.data?.access_token ||
|
|
264
|
-
auth.data?.accessToken;
|
|
277
|
+
return map[text] ?? map[text.toLowerCase?.() || ""] ?? text;
|
|
278
|
+
}
|
|
265
279
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
};
|
|
280
|
+
function normalizeBoolLike(value, defaultValue = "0") {
|
|
281
|
+
const text = normalizeText(value);
|
|
282
|
+
if (!text) {
|
|
283
|
+
return defaultValue;
|
|
271
284
|
}
|
|
272
285
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
auth.expiresAt ||
|
|
276
|
-
auth.data?.expires_at ||
|
|
277
|
-
auth.data?.expiresAt;
|
|
278
|
-
|
|
279
|
-
if (expiresAt) {
|
|
280
|
-
expiresAt = Number(expiresAt);
|
|
286
|
+
return BOOLEAN_TEXT_MAP[text] ?? BOOLEAN_TEXT_MAP[text.toLowerCase()] ?? text;
|
|
287
|
+
}
|
|
281
288
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
289
|
+
function parseBackendOptions(args) {
|
|
290
|
+
const options = {
|
|
291
|
+
backend: undefined,
|
|
292
|
+
authPage: undefined,
|
|
293
|
+
local: false
|
|
294
|
+
};
|
|
295
|
+
const passthrough = [];
|
|
285
296
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
ok: false,
|
|
289
|
-
reason: `登录状态已过期: ${new Date(expiresAt).toLocaleString()}`
|
|
290
|
-
};
|
|
291
|
-
}
|
|
292
|
-
}
|
|
297
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
298
|
+
const current = args[index];
|
|
293
299
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
}
|
|
300
|
+
if (current === "--backend") {
|
|
301
|
+
const next = args[index + 1];
|
|
302
|
+
if (!next || next.startsWith("--")) {
|
|
303
|
+
throw new Error("--backend 缺少参数值");
|
|
304
|
+
}
|
|
299
305
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
306
|
+
options.backend = next;
|
|
307
|
+
index += 1;
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
304
310
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
311
|
+
if (current === "--auth-page") {
|
|
312
|
+
const next = args[index + 1];
|
|
313
|
+
if (!next || next.startsWith("--")) {
|
|
314
|
+
throw new Error("--auth-page 缺少参数值");
|
|
315
|
+
}
|
|
308
316
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
317
|
+
options.authPage = next;
|
|
318
|
+
index += 1;
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
313
321
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
322
|
+
if (current === "--local") {
|
|
323
|
+
options.local = true;
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
318
326
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
console.log("");
|
|
322
|
-
}
|
|
327
|
+
passthrough.push(current);
|
|
328
|
+
}
|
|
323
329
|
|
|
324
|
-
|
|
325
|
-
|
|
330
|
+
return {
|
|
331
|
+
options,
|
|
332
|
+
args: passthrough
|
|
333
|
+
};
|
|
326
334
|
}
|
|
327
335
|
|
|
328
336
|
function parsePermissionsOptions(args) {
|
|
@@ -356,7 +364,324 @@ function parsePermissionsOptions(args) {
|
|
|
356
364
|
return options;
|
|
357
365
|
}
|
|
358
366
|
|
|
359
|
-
|
|
367
|
+
function parsePublishOptions(args) {
|
|
368
|
+
const options = {
|
|
369
|
+
backend: undefined,
|
|
370
|
+
local: false,
|
|
371
|
+
dryRun: false
|
|
372
|
+
};
|
|
373
|
+
const positional = [];
|
|
374
|
+
|
|
375
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
376
|
+
const current = args[index];
|
|
377
|
+
|
|
378
|
+
if (current === "--dry-run") {
|
|
379
|
+
options.dryRun = true;
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (current === "--backend") {
|
|
384
|
+
const next = args[index + 1];
|
|
385
|
+
if (!next || next.startsWith("--")) {
|
|
386
|
+
throw new Error("--backend 缺少参数值");
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
options.backend = next;
|
|
390
|
+
index += 1;
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (current === "--local") {
|
|
395
|
+
options.local = true;
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (current.startsWith("--")) {
|
|
400
|
+
throw new Error(`未知参数: ${current}`);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
positional.push(current);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
return {
|
|
407
|
+
file: positional[0],
|
|
408
|
+
options
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function requestJson(method, baseUrl, pathname, payload) {
|
|
413
|
+
return new Promise((resolve, reject) => {
|
|
414
|
+
const url = new URL(pathname, baseUrl);
|
|
415
|
+
const client = url.protocol === "https:" ? https : http;
|
|
416
|
+
const body = payload === undefined ? undefined : JSON.stringify(payload);
|
|
417
|
+
|
|
418
|
+
const req = client.request(
|
|
419
|
+
url,
|
|
420
|
+
{
|
|
421
|
+
method,
|
|
422
|
+
headers: {
|
|
423
|
+
Accept: "application/json",
|
|
424
|
+
"Content-Type": "application/json",
|
|
425
|
+
"User-Agent": `qcplay-cli/${PACKAGE_VERSION}`,
|
|
426
|
+
...(body ? { "Content-Length": Buffer.byteLength(body) } : {})
|
|
427
|
+
}
|
|
428
|
+
},
|
|
429
|
+
res => {
|
|
430
|
+
const chunks = [];
|
|
431
|
+
res.on("data", chunk => chunks.push(chunk));
|
|
432
|
+
res.on("end", () => {
|
|
433
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
434
|
+
let json = {};
|
|
435
|
+
|
|
436
|
+
try {
|
|
437
|
+
json = text ? JSON.parse(text) : {};
|
|
438
|
+
} catch {
|
|
439
|
+
reject(new Error(`后端返回了非 JSON 内容: ${text.slice(0, 160)}`));
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if ((res.statusCode || 0) < 200 || (res.statusCode || 0) >= 300) {
|
|
444
|
+
reject(new Error(json.message || `接口请求失败 (${res.statusCode})`));
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
resolve(json);
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
);
|
|
452
|
+
|
|
453
|
+
req.on("error", err => {
|
|
454
|
+
reject(new Error(`无法连接后端服务 ${baseUrl},请检查服务地址或网络连接。原始错误: ${err.message}`));
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
if (body) {
|
|
458
|
+
req.write(body);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
req.end();
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function openBrowser(targetUrl) {
|
|
466
|
+
return new Promise((resolve, reject) => {
|
|
467
|
+
let command;
|
|
468
|
+
let args;
|
|
469
|
+
|
|
470
|
+
if (process.platform === "win32") {
|
|
471
|
+
command = "cmd";
|
|
472
|
+
args = ["/c", "start", "", targetUrl];
|
|
473
|
+
} else if (process.platform === "darwin") {
|
|
474
|
+
command = "open";
|
|
475
|
+
args = [targetUrl];
|
|
476
|
+
} else {
|
|
477
|
+
command = "xdg-open";
|
|
478
|
+
args = [targetUrl];
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const child = spawn(command, args, {
|
|
482
|
+
detached: true,
|
|
483
|
+
stdio: "ignore"
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
child.on("error", reject);
|
|
487
|
+
child.unref();
|
|
488
|
+
resolve();
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function delay(ms) {
|
|
493
|
+
return new Promise(resolve => {
|
|
494
|
+
setTimeout(resolve, ms);
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function normalizeOptionalUrl(value) {
|
|
499
|
+
const text = normalizeText(value);
|
|
500
|
+
return text || "";
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function ensureAbsoluteUrl(value, flagName) {
|
|
504
|
+
try {
|
|
505
|
+
return new URL(value).toString();
|
|
506
|
+
} catch {
|
|
507
|
+
throw new Error(`${flagName} 必须是完整 URL,例如 https://example.com/auth.html`);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function resolveLocalBaseUrl() {
|
|
512
|
+
const localBaseUrl = normalizeOptionalUrl(ENV_LOCAL_BASE_URL) || DEFAULT_LOCAL_BASE_URL;
|
|
513
|
+
return ensureAbsoluteUrl(localBaseUrl, "QCPLAY_LOCAL_BASE_URL");
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function isLocalMode(options, config = {}) {
|
|
517
|
+
return Boolean(options.local || config.local_mode);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function resolveAuthBackendUrl(options, config = {}) {
|
|
521
|
+
if (!normalizeOptionalUrl(options.backend) && isLocalMode(options, config)) {
|
|
522
|
+
return resolveLocalBaseUrl();
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const backendUrl =
|
|
526
|
+
normalizeOptionalUrl(options.backend) ||
|
|
527
|
+
normalizeOptionalUrl(ENV_AUTH_BACKEND_URL) ||
|
|
528
|
+
normalizeOptionalUrl(config.auth_backend_url) ||
|
|
529
|
+
normalizeOptionalUrl(config.backend_url) ||
|
|
530
|
+
DEFAULT_AUTH_BACKEND_URL;
|
|
531
|
+
return ensureAbsoluteUrl(backendUrl, "--backend");
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function resolveAuthPageUrl(options, config = {}) {
|
|
535
|
+
if (!normalizeOptionalUrl(options.authPage) && isLocalMode(options, config)) {
|
|
536
|
+
return "";
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const authPageUrl =
|
|
540
|
+
normalizeOptionalUrl(options.authPage) ||
|
|
541
|
+
normalizeOptionalUrl(ENV_AUTH_PAGE_URL) ||
|
|
542
|
+
normalizeOptionalUrl(config.auth_page_url) ||
|
|
543
|
+
DEFAULT_AUTH_PAGE_URL;
|
|
544
|
+
return authPageUrl ? ensureAbsoluteUrl(authPageUrl, "--auth-page") : "";
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function resolvePublishBackendUrl(options, config = {}) {
|
|
548
|
+
if (!normalizeOptionalUrl(options.backend) && isLocalMode(options, config)) {
|
|
549
|
+
return resolveLocalBaseUrl();
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const backendUrl =
|
|
553
|
+
normalizeOptionalUrl(options.backend) ||
|
|
554
|
+
normalizeOptionalUrl(ENV_PUBLISH_BACKEND_URL) ||
|
|
555
|
+
normalizeOptionalUrl(config.publish_backend_url) ||
|
|
556
|
+
DEFAULT_PUBLISH_BACKEND_URL;
|
|
557
|
+
return ensureAbsoluteUrl(backendUrl, "--backend");
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
async function fetchLoginSuccessSequence(baseUrl) {
|
|
561
|
+
const response = await requestJson("GET", baseUrl, "/api/auth/login-success");
|
|
562
|
+
return Number(response.data?.sequence || 0);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function isNonJsonResponseError(error) {
|
|
566
|
+
return String(error?.message || "").includes("后端返回了非 JSON 内容");
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function buildAuthBackendError(baseUrl, error) {
|
|
570
|
+
if (isNonJsonResponseError(error)) {
|
|
571
|
+
return new Error(
|
|
572
|
+
`认证接口 ${baseUrl} 返回了 HTML 页面,不是 JSON。请确认 ${baseUrl}/api/auth/login、${baseUrl}/api/auth/login-success、${baseUrl}/api/auth/status 已正确部署,而不是被站点首页或登录页接管。`
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
return error;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
async function waitForLoginSuccessSequence(baseUrl, previousSequence, intervalMs = 1000, timeoutMs = 60000) {
|
|
580
|
+
const startedAt = Date.now();
|
|
581
|
+
let lastError;
|
|
582
|
+
|
|
583
|
+
while (true) {
|
|
584
|
+
try {
|
|
585
|
+
const nextSequence = await fetchLoginSuccessSequence(baseUrl);
|
|
586
|
+
if (nextSequence > previousSequence) {
|
|
587
|
+
return nextSequence;
|
|
588
|
+
}
|
|
589
|
+
} catch (error) {
|
|
590
|
+
lastError = error;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
if (Date.now() - startedAt >= timeoutMs) {
|
|
594
|
+
if (lastError) {
|
|
595
|
+
throw buildAuthBackendError(baseUrl, lastError);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
throw new Error(`等待登录结果超时,请确认认证接口 ${baseUrl} 可用`);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
await delay(intervalMs);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function buildAuthPageUrl(backendUrl, authPageUrl = "") {
|
|
606
|
+
const targetUrl = authPageUrl ? new URL(authPageUrl) : pathToFileURL(AUTH_PAGE);
|
|
607
|
+
|
|
608
|
+
if (shouldAttachBackendQuery(targetUrl, backendUrl)) {
|
|
609
|
+
targetUrl.searchParams.set("backend", backendUrl);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
return targetUrl.toString();
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function shouldAttachBackendQuery(targetUrl, backendUrl) {
|
|
616
|
+
if (!backendUrl) {
|
|
617
|
+
return false;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const normalizedBackendUrl = new URL(backendUrl);
|
|
621
|
+
if (targetUrl.protocol === "file:") {
|
|
622
|
+
return normalizedBackendUrl.origin !== new URL(DEFAULT_LOCAL_BASE_URL).origin;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
return normalizedBackendUrl.origin !== targetUrl.origin;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function flattenPermissionTree(items, bucket = []) {
|
|
629
|
+
for (const item of items) {
|
|
630
|
+
bucket.push(item);
|
|
631
|
+
if (Array.isArray(item.children) && item.children.length > 0) {
|
|
632
|
+
flattenPermissionTree(item.children, bucket);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
return bucket;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function printPermissionTree(items) {
|
|
640
|
+
const flatItems = flattenPermissionTree(items);
|
|
641
|
+
const indexed = new Map();
|
|
642
|
+
const children = new Map();
|
|
643
|
+
const roots = [];
|
|
644
|
+
|
|
645
|
+
for (const item of flatItems) {
|
|
646
|
+
const id = Number(item.id ?? 0);
|
|
647
|
+
indexed.set(id || item.key, item);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
for (const item of flatItems) {
|
|
651
|
+
const parentId = Number(item.menu_id ?? item.parent_id ?? 0);
|
|
652
|
+
if (parentId && indexed.has(parentId)) {
|
|
653
|
+
if (!children.has(parentId)) {
|
|
654
|
+
children.set(parentId, []);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
children.get(parentId).push(item);
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
roots.push(item);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
if (roots.length === 0) {
|
|
665
|
+
console.log(chalk.yellow("未返回任何权限"));
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
for (const item of roots) {
|
|
670
|
+
console.log(item.key);
|
|
671
|
+
const nested = children.get(Number(item.id ?? 0)) || [];
|
|
672
|
+
for (const child of nested) {
|
|
673
|
+
console.log(` - ${child.key}`);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
async function installCommand(options) {
|
|
679
|
+
const config = await loadConfig();
|
|
680
|
+
const localMode = isLocalMode(options, config);
|
|
681
|
+
const authBackendUrl = resolveAuthBackendUrl(options, config);
|
|
682
|
+
const publishBackendUrl = resolvePublishBackendUrl(options, config);
|
|
683
|
+
const authPageUrl = resolveAuthPageUrl(options, config);
|
|
684
|
+
|
|
360
685
|
console.log("");
|
|
361
686
|
console.log(chalk.cyan("正在安装 QCPlay CLI..."));
|
|
362
687
|
console.log("");
|
|
@@ -372,170 +697,373 @@ async function installCommand() {
|
|
|
372
697
|
console.log(chalk.yellow("! 未找到 Skills 模板,已跳过"));
|
|
373
698
|
}
|
|
374
699
|
|
|
375
|
-
await saveConfig();
|
|
700
|
+
await saveConfig(authBackendUrl, publishBackendUrl, authPageUrl, localMode);
|
|
376
701
|
console.log(chalk.green("✔ 配置文件已生成"));
|
|
377
|
-
|
|
378
|
-
console.log(
|
|
379
|
-
console.log(chalk.
|
|
380
|
-
console.log(chalk.gray("请在浏览器中完成账号密码登录。"));
|
|
702
|
+
console.log(chalk.gray(`认证后端: ${authBackendUrl}`));
|
|
703
|
+
console.log(chalk.gray(`发布后端: ${publishBackendUrl}`));
|
|
704
|
+
console.log(chalk.gray(`登录页地址: ${authPageUrl || "本地内置页面"}`));
|
|
381
705
|
console.log("");
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
const loginStatus = await getLoginStatus();
|
|
386
|
-
if (!loginStatus.ok) {
|
|
387
|
-
throw new Error(`登录流程结束,但未检测到有效认证文件。\n原因: ${loginStatus.reason}`);
|
|
388
|
-
}
|
|
389
|
-
|
|
706
|
+
console.log("安装完成后可直接执行:");
|
|
707
|
+
console.log(" qcplay-cli auth");
|
|
390
708
|
console.log("");
|
|
391
|
-
console.log(chalk.green("✔ QCPlay CLI 安装完成"));
|
|
392
|
-
|
|
393
|
-
printFeatures();
|
|
394
709
|
}
|
|
395
710
|
|
|
396
|
-
async function authCommand(
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
711
|
+
async function authCommand(rawArgs = []) {
|
|
712
|
+
const parsed = parseBackendOptions(rawArgs);
|
|
713
|
+
const options = parsed.options;
|
|
714
|
+
const args = parsed.args;
|
|
715
|
+
const action = args[0];
|
|
716
|
+
const config = await loadConfig();
|
|
717
|
+
const backendUrl = resolveAuthBackendUrl(options, config);
|
|
718
|
+
const authPageUrl = resolveAuthPageUrl(options, config);
|
|
403
719
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
720
|
+
if (!action || action === "login") {
|
|
721
|
+
const loginPageUrl = buildAuthPageUrl(backendUrl, authPageUrl);
|
|
722
|
+
let previousSequence = 0;
|
|
723
|
+
|
|
724
|
+
console.log(chalk.cyan("正在登录中"));
|
|
725
|
+
try {
|
|
726
|
+
await openBrowser(loginPageUrl);
|
|
727
|
+
} catch {
|
|
728
|
+
console.log(chalk.yellow("自动打开浏览器失败,请手动打开下面地址:"));
|
|
729
|
+
console.log(chalk.gray(loginPageUrl));
|
|
407
730
|
}
|
|
408
731
|
|
|
732
|
+
try {
|
|
733
|
+
previousSequence = await fetchLoginSuccessSequence(backendUrl);
|
|
734
|
+
} catch {}
|
|
735
|
+
|
|
736
|
+
await waitForLoginSuccessSequence(backendUrl, previousSequence);
|
|
737
|
+
console.log(chalk.green("登录成功"));
|
|
409
738
|
return;
|
|
410
739
|
}
|
|
411
740
|
|
|
412
741
|
if (action === "status") {
|
|
413
|
-
const
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
console.log(chalk.yellow("未登录或登录无效"));
|
|
417
|
-
console.log(`原因: ${loginStatus.reason}`);
|
|
418
|
-
console.log("");
|
|
419
|
-
console.log("请执行:");
|
|
420
|
-
console.log(" qcplay-cli auth");
|
|
742
|
+
const response = await requestJson("GET", backendUrl, "/api/auth/status");
|
|
743
|
+
if (response.data?.logged_in) {
|
|
744
|
+
console.log(chalk.green("已登录"));
|
|
421
745
|
return;
|
|
422
746
|
}
|
|
423
747
|
|
|
424
|
-
console.log(chalk.
|
|
748
|
+
console.log(chalk.yellow("未登录"));
|
|
425
749
|
return;
|
|
426
750
|
}
|
|
427
751
|
|
|
428
752
|
if (action === "logout") {
|
|
429
|
-
await
|
|
430
|
-
console.log(chalk.green("已退出登录"));
|
|
753
|
+
const response = await requestJson("POST", backendUrl, "/api/auth/logout", {});
|
|
754
|
+
console.log(chalk.green(response.message || "已退出登录"));
|
|
431
755
|
return;
|
|
432
756
|
}
|
|
433
757
|
|
|
434
758
|
if (action === "permissions") {
|
|
435
|
-
const
|
|
759
|
+
const flags = parsePermissionsOptions(args.slice(1));
|
|
760
|
+
const response = await requestJson("GET", backendUrl, "/api/permissions");
|
|
761
|
+
let permissions = response.data || [];
|
|
436
762
|
|
|
437
|
-
if (
|
|
438
|
-
|
|
763
|
+
if (flags.key) {
|
|
764
|
+
permissions = permissions.filter(item => item.key === flags.key || item.key.startsWith(`${flags.key}.`));
|
|
439
765
|
}
|
|
440
766
|
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
if (options.key) {
|
|
445
|
-
nativeArgs.push("--key", options.key);
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
if (options.json) {
|
|
449
|
-
nativeArgs.push("--json");
|
|
767
|
+
if (flags.json) {
|
|
768
|
+
console.log(JSON.stringify(permissions, null, 2));
|
|
769
|
+
return;
|
|
450
770
|
}
|
|
451
771
|
|
|
452
|
-
|
|
772
|
+
printPermissionTree(permissions);
|
|
453
773
|
return;
|
|
454
774
|
}
|
|
455
775
|
|
|
456
776
|
printAuthHelp();
|
|
457
777
|
}
|
|
458
778
|
|
|
459
|
-
function
|
|
460
|
-
const
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
779
|
+
function unquoteValue(input) {
|
|
780
|
+
const trimmed = String(input).trim();
|
|
781
|
+
if (
|
|
782
|
+
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
|
783
|
+
(trimmed.startsWith("'") && trimmed.endsWith("'"))
|
|
784
|
+
) {
|
|
785
|
+
return trimmed.slice(1, -1);
|
|
786
|
+
}
|
|
466
787
|
|
|
467
|
-
|
|
468
|
-
|
|
788
|
+
return trimmed;
|
|
789
|
+
}
|
|
469
790
|
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
791
|
+
function stripBom(input) {
|
|
792
|
+
return input.charCodeAt(0) === 0xfeff ? input.slice(1) : input;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
function escapeHtml(value) {
|
|
796
|
+
return String(value)
|
|
797
|
+
.replaceAll("&", "&")
|
|
798
|
+
.replaceAll("<", "<")
|
|
799
|
+
.replaceAll(">", ">")
|
|
800
|
+
.replaceAll('"', """)
|
|
801
|
+
.replaceAll("'", "'");
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function applyInlineMarkdown(text) {
|
|
805
|
+
const tokens = [];
|
|
806
|
+
let output = String(text);
|
|
807
|
+
|
|
808
|
+
function storeToken(value) {
|
|
809
|
+
const token = `@@MDTOKEN${tokens.length}@@`;
|
|
810
|
+
tokens.push(value);
|
|
811
|
+
return token;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
output = output.replace(/`([^`]+)`/g, (_, code) => {
|
|
815
|
+
return storeToken(`<code>${escapeHtml(code)}</code>`);
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
output = output.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_, alt, url) => {
|
|
819
|
+
return storeToken(`<img src="${escapeHtml(url.trim())}" alt="${escapeHtml(alt.trim())}" />`);
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
output = output.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, url) => {
|
|
823
|
+
return storeToken(`<a href="${escapeHtml(url.trim())}" target="_blank" rel="noreferrer">${escapeHtml(label.trim())}</a>`);
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
output = escapeHtml(output);
|
|
827
|
+
|
|
828
|
+
output = output.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
|
829
|
+
output = output.replace(/__([^_]+)__/g, "<strong>$1</strong>");
|
|
830
|
+
output = output.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, "<em>$1</em>");
|
|
831
|
+
output = output.replace(/(?<!_)_([^_]+)_(?!_)/g, "<em>$1</em>");
|
|
832
|
+
|
|
833
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
834
|
+
output = output.replace(`@@MDTOKEN${index}@@`, tokens[index]);
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
return output;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
function markdownToHtml(markdown) {
|
|
841
|
+
const lines = stripBom(markdown).replace(/\r\n/g, "\n").split("\n");
|
|
842
|
+
const html = [];
|
|
843
|
+
let paragraph = [];
|
|
844
|
+
let quote = [];
|
|
845
|
+
let listType = "";
|
|
846
|
+
let listItems = [];
|
|
847
|
+
let inCodeBlock = false;
|
|
848
|
+
let codeLines = [];
|
|
849
|
+
|
|
850
|
+
function flushParagraph() {
|
|
851
|
+
if (paragraph.length === 0) {
|
|
852
|
+
return;
|
|
473
853
|
}
|
|
474
854
|
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
855
|
+
html.push(`<p>${paragraph.map(line => applyInlineMarkdown(line)).join("<br />")}</p>`);
|
|
856
|
+
paragraph = [];
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
function flushQuote() {
|
|
860
|
+
if (quote.length === 0) {
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
html.push(`<blockquote><p>${quote.map(line => applyInlineMarkdown(line)).join("<br />")}</p></blockquote>`);
|
|
865
|
+
quote = [];
|
|
866
|
+
}
|
|
480
867
|
|
|
481
|
-
|
|
482
|
-
|
|
868
|
+
function flushList() {
|
|
869
|
+
if (!listType || listItems.length === 0) {
|
|
870
|
+
listType = "";
|
|
871
|
+
listItems = [];
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
html.push(`<${listType}>${listItems.map(item => `<li>${applyInlineMarkdown(item)}</li>`).join("")}</${listType}>`);
|
|
876
|
+
listType = "";
|
|
877
|
+
listItems = [];
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
function flushCodeBlock() {
|
|
881
|
+
if (!inCodeBlock) {
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
html.push(`<pre><code>${escapeHtml(codeLines.join("\n"))}</code></pre>`);
|
|
886
|
+
inCodeBlock = false;
|
|
887
|
+
codeLines = [];
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
function flushAll() {
|
|
891
|
+
flushParagraph();
|
|
892
|
+
flushQuote();
|
|
893
|
+
flushList();
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
for (const line of lines) {
|
|
897
|
+
const trimmed = line.trim();
|
|
898
|
+
|
|
899
|
+
if (trimmed.startsWith("```")) {
|
|
900
|
+
if (inCodeBlock) {
|
|
901
|
+
flushCodeBlock();
|
|
483
902
|
} else {
|
|
484
|
-
|
|
903
|
+
flushAll();
|
|
904
|
+
inCodeBlock = true;
|
|
905
|
+
codeLines = [];
|
|
485
906
|
}
|
|
907
|
+
continue;
|
|
908
|
+
}
|
|
486
909
|
|
|
487
|
-
|
|
910
|
+
if (inCodeBlock) {
|
|
911
|
+
codeLines.push(line);
|
|
488
912
|
continue;
|
|
489
913
|
}
|
|
490
914
|
|
|
491
|
-
if (
|
|
492
|
-
|
|
915
|
+
if (!trimmed) {
|
|
916
|
+
flushAll();
|
|
917
|
+
continue;
|
|
493
918
|
}
|
|
494
919
|
|
|
495
|
-
|
|
920
|
+
const headingMatch = trimmed.match(/^(#{1,6})\s+(.+)$/);
|
|
921
|
+
if (headingMatch) {
|
|
922
|
+
flushAll();
|
|
923
|
+
const level = headingMatch[1].length;
|
|
924
|
+
html.push(`<h${level}>${applyInlineMarkdown(headingMatch[2].trim())}</h${level}>`);
|
|
925
|
+
continue;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
if (/^(-{3,}|\*{3,}|_{3,})$/.test(trimmed)) {
|
|
929
|
+
flushAll();
|
|
930
|
+
html.push("<hr />");
|
|
931
|
+
continue;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
const quoteMatch = line.match(/^\s*>\s?(.*)$/);
|
|
935
|
+
if (quoteMatch) {
|
|
936
|
+
flushParagraph();
|
|
937
|
+
flushList();
|
|
938
|
+
quote.push(quoteMatch[1]);
|
|
939
|
+
continue;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
const unorderedMatch = line.match(/^\s*[-*+]\s+(.+)$/);
|
|
943
|
+
if (unorderedMatch) {
|
|
944
|
+
flushParagraph();
|
|
945
|
+
flushQuote();
|
|
946
|
+
if (listType && listType !== "ul") {
|
|
947
|
+
flushList();
|
|
948
|
+
}
|
|
949
|
+
listType = "ul";
|
|
950
|
+
listItems.push(unorderedMatch[1].trim());
|
|
951
|
+
continue;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
const orderedMatch = line.match(/^\s*\d+\.\s+(.+)$/);
|
|
955
|
+
if (orderedMatch) {
|
|
956
|
+
flushParagraph();
|
|
957
|
+
flushQuote();
|
|
958
|
+
if (listType && listType !== "ol") {
|
|
959
|
+
flushList();
|
|
960
|
+
}
|
|
961
|
+
listType = "ol";
|
|
962
|
+
listItems.push(orderedMatch[1].trim());
|
|
963
|
+
continue;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
flushQuote();
|
|
967
|
+
flushList();
|
|
968
|
+
paragraph.push(trimmed);
|
|
496
969
|
}
|
|
497
970
|
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
};
|
|
971
|
+
flushCodeBlock();
|
|
972
|
+
flushAll();
|
|
973
|
+
return html.join("\n");
|
|
502
974
|
}
|
|
503
975
|
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
976
|
+
function parseFrontMatter(raw) {
|
|
977
|
+
const normalized = stripBom(raw).replace(/\r\n/g, "\n");
|
|
978
|
+
const lines = normalized.split("\n");
|
|
979
|
+
|
|
980
|
+
if (lines[0]?.trim() !== "---") {
|
|
981
|
+
throw new Error("文章文件缺少 Front Matter,首行必须是 ---");
|
|
507
982
|
}
|
|
508
983
|
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
984
|
+
let dividerIndex = -1;
|
|
985
|
+
for (let index = 1; index < lines.length; index += 1) {
|
|
986
|
+
if (lines[index].trim() === "---") {
|
|
987
|
+
dividerIndex = index;
|
|
988
|
+
break;
|
|
989
|
+
}
|
|
512
990
|
}
|
|
513
991
|
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
throw new Error(`未检测到有效登录状态。\n原因: ${loginStatus.reason}\n请先执行:qcplay-cli auth`);
|
|
992
|
+
if (dividerIndex === -1) {
|
|
993
|
+
throw new Error("文章文件缺少 Front Matter 结束标记 ---");
|
|
517
994
|
}
|
|
518
995
|
|
|
519
|
-
const
|
|
996
|
+
const meta = {};
|
|
997
|
+
for (const line of lines.slice(1, dividerIndex)) {
|
|
998
|
+
if (!line.trim()) {
|
|
999
|
+
continue;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
const separatorIndex = line.indexOf(":");
|
|
1003
|
+
if (separatorIndex === -1) {
|
|
1004
|
+
throw new Error(`Front Matter 格式错误: ${line}`);
|
|
1005
|
+
}
|
|
520
1006
|
|
|
521
|
-
|
|
522
|
-
args.push("--env", options.env);
|
|
1007
|
+
meta[line.slice(0, separatorIndex).trim()] = unquoteValue(line.slice(separatorIndex + 1));
|
|
523
1008
|
}
|
|
524
1009
|
|
|
525
|
-
|
|
526
|
-
|
|
1010
|
+
return {
|
|
1011
|
+
meta,
|
|
1012
|
+
content: lines.slice(dividerIndex + 1).join("\n").trim()
|
|
1013
|
+
};
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
function normalizeArticlePayload(meta, content) {
|
|
1017
|
+
const payload = {
|
|
1018
|
+
article_title: normalizeText(meta.article_title),
|
|
1019
|
+
thumbnail: normalizeText(meta.thumbnail),
|
|
1020
|
+
move_thumbnail: normalizeText(meta.move_thumbnail),
|
|
1021
|
+
article_content: markdownToHtml(content),
|
|
1022
|
+
article_excerpt: normalizeText(meta.article_excerpt),
|
|
1023
|
+
article_url: normalizeText(meta.article_url),
|
|
1024
|
+
origin: normalizeText(meta.origin),
|
|
1025
|
+
status: normalizeMappedValue(meta.status, STATUS_MAP) || "0",
|
|
1026
|
+
cate_id: normalizeMappedValue(meta.cate_id, CATEGORY_MAP),
|
|
1027
|
+
video_link: normalizeText(meta.video_link),
|
|
1028
|
+
is_hot: normalizeBoolLike(meta.is_hot, "0"),
|
|
1029
|
+
is_index: normalizeBoolLike(meta.is_index, "0"),
|
|
1030
|
+
release_time: normalizeText(meta.release_time),
|
|
1031
|
+
area: normalizeMappedValue(meta.area, AREA_MAP) || "1",
|
|
1032
|
+
sort: normalizeText(meta.sort) || "1",
|
|
1033
|
+
game_id: normalizeMappedValue(meta.game_id, GAME_MAP) || "39",
|
|
1034
|
+
is_index2: "0",
|
|
1035
|
+
index_pc_img: normalizeText(meta.index_pc_img),
|
|
1036
|
+
index_move_img: normalizeText(meta.index_move_img),
|
|
1037
|
+
type: "1"
|
|
1038
|
+
};
|
|
1039
|
+
|
|
1040
|
+
if (!payload.article_title) {
|
|
1041
|
+
throw new Error("article_title 不能为空");
|
|
527
1042
|
}
|
|
528
1043
|
|
|
529
|
-
if (
|
|
530
|
-
|
|
1044
|
+
if (!payload.thumbnail) {
|
|
1045
|
+
throw new Error("thumbnail 不能为空");
|
|
531
1046
|
}
|
|
532
1047
|
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
1048
|
+
if (!payload.article_content) {
|
|
1049
|
+
throw new Error("文章正文不能为空");
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
return payload;
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
async function parseArticleFile(file) {
|
|
1056
|
+
const articleFile = path.resolve(process.cwd(), file);
|
|
1057
|
+
if (!(await pathExists(articleFile))) {
|
|
1058
|
+
throw new Error(`文章文件不存在: ${articleFile}`);
|
|
1059
|
+
}
|
|
537
1060
|
|
|
538
|
-
await
|
|
1061
|
+
const raw = await fs.promises.readFile(articleFile, "utf8");
|
|
1062
|
+
const parsed = parseFrontMatter(raw);
|
|
1063
|
+
return {
|
|
1064
|
+
articleFile,
|
|
1065
|
+
payload: normalizeArticlePayload(parsed.meta, parsed.content)
|
|
1066
|
+
};
|
|
539
1067
|
}
|
|
540
1068
|
|
|
541
1069
|
async function initArticleTemplate(file = "article.md") {
|
|
@@ -579,15 +1107,15 @@ move_thumbnail: https://example.com/mobile-thumbnail.png
|
|
|
579
1107
|
article_excerpt: 文章描述,可以为空
|
|
580
1108
|
article_url: article-url-slug
|
|
581
1109
|
origin: QCPlay
|
|
582
|
-
status:
|
|
583
|
-
cate_id:
|
|
1110
|
+
status: 未发布
|
|
1111
|
+
cate_id: 综合
|
|
584
1112
|
video_link: ""
|
|
585
|
-
is_hot:
|
|
586
|
-
is_index:
|
|
587
|
-
release_time:
|
|
588
|
-
area:
|
|
589
|
-
sort:
|
|
590
|
-
game_id:
|
|
1113
|
+
is_hot: 非热门
|
|
1114
|
+
is_index: 不推荐
|
|
1115
|
+
release_time: 2026-07-16
|
|
1116
|
+
area: pc
|
|
1117
|
+
sort: 100
|
|
1118
|
+
game_id: 最强蜗牛
|
|
591
1119
|
index_pc_img: ""
|
|
592
1120
|
index_move_img: ""
|
|
593
1121
|
---
|
|
@@ -595,77 +1123,39 @@ index_move_img: ""
|
|
|
595
1123
|
# 文章标题
|
|
596
1124
|
|
|
597
1125
|
这里填写文章正文内容。
|
|
598
|
-
|
|
599
|
-
## 一、文章小标题
|
|
600
|
-
|
|
601
|
-
这里填写正文内容。
|
|
602
|
-
|
|
603
|
-
## 二、内容说明
|
|
604
|
-
|
|
605
|
-
这里继续填写正文。
|
|
606
|
-
|
|
607
|
-
## 三、总结
|
|
608
|
-
|
|
609
|
-
这里填写文章结尾内容。
|
|
610
1126
|
${chalk.gray("--------------------------------------------------")}
|
|
611
1127
|
|
|
612
1128
|
${chalk.cyan("发布命令:")}
|
|
613
1129
|
|
|
614
1130
|
qcplay-cli www-article-list.store article.md
|
|
1131
|
+
`);
|
|
1132
|
+
}
|
|
615
1133
|
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
move_thumbnail 移动端缩略图,可为空
|
|
621
|
-
article_excerpt 文章描述,可为空
|
|
622
|
-
article_url 文章链接,建议英文 slug
|
|
623
|
-
origin 作者来源
|
|
624
|
-
status 1 发布,0 未发布
|
|
625
|
-
cate_id 分类 ID
|
|
626
|
-
video_link 视频链接,可为空
|
|
627
|
-
is_hot 1 热门,0 非热门
|
|
628
|
-
is_index 1 推荐,0 不推荐
|
|
629
|
-
release_time 发布时间,格式:年-月-日
|
|
630
|
-
area 所属区域
|
|
631
|
-
sort 排序,数字
|
|
632
|
-
game_id 游戏 ID,最强蜗牛是 39
|
|
633
|
-
index_pc_img 推荐 PC 端缩略图,可为空
|
|
634
|
-
index_move_img 推荐移动端缩略图,可为空
|
|
635
|
-
|
|
636
|
-
${chalk.cyan("分类 ID:")}
|
|
637
|
-
|
|
638
|
-
综合 2
|
|
639
|
-
视频中心 8
|
|
640
|
-
活动 3
|
|
641
|
-
游戏攻略 7
|
|
642
|
-
萌新入门 9
|
|
643
|
-
萌新入门-攻略专区 25
|
|
644
|
-
高手进阶 26
|
|
645
|
-
活动攻略 27
|
|
646
|
-
视频攻略 29
|
|
647
|
-
|
|
648
|
-
${chalk.cyan("区域 ID:")}
|
|
649
|
-
|
|
650
|
-
PC 1
|
|
651
|
-
资料站 3
|
|
652
|
-
公益网站 4
|
|
653
|
-
|
|
654
|
-
${chalk.cyan("游戏 ID:")}
|
|
655
|
-
|
|
656
|
-
最强蜗牛 39
|
|
657
|
-
|
|
658
|
-
${chalk.yellow("注意:")}
|
|
659
|
-
|
|
660
|
-
1. article_content 不需要写在 Front Matter 中。
|
|
661
|
-
2. Markdown 正文会自动作为 article_content。
|
|
662
|
-
3. is_index2 默认 0,不需要填写。
|
|
663
|
-
4. type 默认 1,不需要填写。
|
|
664
|
-
5. 发布前请先登录:
|
|
665
|
-
|
|
666
|
-
qcplay-cli auth
|
|
1134
|
+
async function publishArticleCommand(file, options) {
|
|
1135
|
+
if (!file) {
|
|
1136
|
+
throw new Error("缺少文章文件,例如:qcplay-cli www-article-list.store article.md");
|
|
1137
|
+
}
|
|
667
1138
|
|
|
668
|
-
|
|
1139
|
+
const { articleFile, payload } = await parseArticleFile(file);
|
|
1140
|
+
const config = await loadConfig();
|
|
1141
|
+
const backendUrl = resolvePublishBackendUrl(options, config);
|
|
1142
|
+
|
|
1143
|
+
if (options.dryRun) {
|
|
1144
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
1145
|
+
return;
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
console.log("");
|
|
1149
|
+
console.log(chalk.cyan("正在发布官网文章..."));
|
|
1150
|
+
console.log(chalk.gray(`文章文件: ${articleFile}`));
|
|
1151
|
+
console.log("");
|
|
1152
|
+
|
|
1153
|
+
const response = await requestJson("POST", backendUrl, "/api/articles/publish", payload);
|
|
1154
|
+
console.log(chalk.green(response.message || "发布成功"));
|
|
1155
|
+
const articleId = response.data?.id ?? response.data?.article_id ?? response.data?.articleId;
|
|
1156
|
+
if (articleId !== undefined) {
|
|
1157
|
+
console.log(`文章 ID: ${articleId}`);
|
|
1158
|
+
}
|
|
669
1159
|
}
|
|
670
1160
|
|
|
671
1161
|
async function runWithErrorBanner(title, action) {
|
|
@@ -694,7 +1184,8 @@ async function main() {
|
|
|
694
1184
|
}
|
|
695
1185
|
|
|
696
1186
|
if (command === "install") {
|
|
697
|
-
|
|
1187
|
+
const parsed = parseBackendOptions([subcommand, ...rest].filter(Boolean));
|
|
1188
|
+
await runWithErrorBanner("安装失败", () => installCommand(parsed.options));
|
|
698
1189
|
return;
|
|
699
1190
|
}
|
|
700
1191
|
|
|
@@ -704,7 +1195,7 @@ async function main() {
|
|
|
704
1195
|
return;
|
|
705
1196
|
}
|
|
706
1197
|
|
|
707
|
-
await runWithErrorBanner("认证失败", () => authCommand(subcommand, rest));
|
|
1198
|
+
await runWithErrorBanner("认证失败", () => authCommand([subcommand, ...rest].filter(Boolean)));
|
|
708
1199
|
return;
|
|
709
1200
|
}
|
|
710
1201
|
|
|
@@ -725,8 +1216,8 @@ async function main() {
|
|
|
725
1216
|
}
|
|
726
1217
|
|
|
727
1218
|
if (subcommand === "publish") {
|
|
728
|
-
const
|
|
729
|
-
await runWithErrorBanner("发布失败", () => publishArticleCommand(file, options));
|
|
1219
|
+
const parsed = parsePublishOptions(rest);
|
|
1220
|
+
await runWithErrorBanner("发布失败", () => publishArticleCommand(parsed.file, parsed.options));
|
|
730
1221
|
return;
|
|
731
1222
|
}
|
|
732
1223
|
|