@qcplay/cli 1.0.11 → 1.0.12

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 CHANGED
@@ -9,7 +9,7 @@
9
9
  - 登录默认打开线上页面:`https://cli.qcg.ink/auth.html`
10
10
  - `qcplay-cli install` 初始化完成后会自动打开登录页并等待登录完成
11
11
  - 登录凭证保存在当前用户本机的 `~/.qcplay/auth.json`,不同客户端之间不共享账号
12
- - 状态、权限和发布请求通过 Bearer Token 识别当前账号
12
+ - 状态、权限和文章请求通过 Bearer Token 识别当前账号
13
13
  - `qcplay-cli article import <微信文章链接> [文件]` 会提取正文、转存图片并生成未发布的官网文章草稿
14
14
  - `qcplay-cli update` 会更新 npm 全局包,并同步 `~/.agents/skills`
15
15
  - 本地调试时可直接使用 `--local`
@@ -26,12 +26,32 @@ qcplay-cli article import "https://mp.weixin.qq.com/s/..." article.md
26
26
 
27
27
  转换流程会读取微信文章标题、来源、发布日期和完整正文,经过安全清理后保留微信原文的 HTML 层级、内联样式、字号、颜色、对齐、间距、媒体链接和表格,并转存正文图片、CSS 背景图及封面图。背景图片仍保留在原容器中,不会额外生成一张普通图片。正文保持微信的 `677px` 阅读宽度并兼容窄屏;独占一行的图片会使用块级布局,纯图片标题容器会转换为普通区块,避免官网 PC 标题样式造成相邻图片重叠。任何正文节点遗漏或图片处理失败都会终止转换,不生成不完整草稿。输出固定为 `status: "0"`,并保留空的 `cate_id` 供发布前确认。
28
28
 
29
- 确认草稿内容和业务字段后再发布:
29
+ 确认草稿内容和业务字段后保存为未上线文章:
30
30
 
31
31
  ```bash
32
32
  qcplay-cli www-article-list.store article.md
33
33
  ```
34
34
 
35
+ 保存成功后,CLI 会输出文章 ID、`未上线` 状态和可点击的排版预览地址:
36
+
37
+ ```text
38
+ https://snail.qingcigame.com/official/news-details.html?id=<文章ID>
39
+ ```
40
+
41
+ 检查 PC 和移动端排版无误后再上线。ID 可以省略,此时只使用当前账号最近一次保存的未上线文章:
42
+
43
+ ```bash
44
+ qcplay-cli article update [id]
45
+ ```
46
+
47
+ 删除使用软删除,将同一文章的 `status` 更新为 `2`:
48
+
49
+ ```bash
50
+ qcplay-cli article delete [id]
51
+ ```
52
+
53
+ 兼容权限名形式的命令:`www-article-list.update [id]` 和 `www-article-list.delete [id]`。首次保存始终强制使用 `status: "0"`,不会因为 Front Matter 中误填 `1` 而直接上线。
54
+
35
55
  仓库整体需求说明仍在根目录:
36
56
 
37
57
  - `../docs/requirement.md`
package/bin/qcplay.js CHANGED
@@ -99,6 +99,8 @@ const QCPLAY_DIR = path.join(os.homedir(), ".qcplay");
99
99
  const AGENTS_DIR = path.join(os.homedir(), ".agents");
100
100
  const CONFIG_FILE = path.join(QCPLAY_DIR, "config.json");
101
101
  const AUTH_FILE = path.join(QCPLAY_DIR, "auth.json");
102
+ const ARTICLE_STATE_FILE = path.join(QCPLAY_DIR, "article-state.json");
103
+ const ARTICLE_PREVIEW_BASE_URL = "https://snail.qingcigame.com/official/news-details.html";
102
104
  const SKILLS_DIR = path.join(AGENTS_DIR, "skills");
103
105
 
104
106
  function readPackageMetadata() {
@@ -149,7 +151,11 @@ Usage:
149
151
  qcplay-cli article init [file]
150
152
  qcplay-cli article import <wechat-url> [file]
151
153
  qcplay-cli article publish <file> [--local] [--backend <url>] [--dry-run]
154
+ qcplay-cli article update [id] [--local] [--backend <url>] [--dry-run]
155
+ qcplay-cli article delete [id] [--local] [--backend <url>] [--dry-run]
152
156
  qcplay-cli www-article-list.store <file> [--local] [--backend <url>] [--dry-run]
157
+ qcplay-cli www-article-list.update [id] [--local] [--backend <url>] [--dry-run]
158
+ qcplay-cli www-article-list.delete [id] [--local] [--backend <url>] [--dry-run]
153
159
  qcplay-cli features
154
160
  qcplay-cli where
155
161
 
@@ -175,7 +181,9 @@ function printArticleHelp() {
175
181
  qcplay-cli article
176
182
  qcplay-cli article init [file]
177
183
  qcplay-cli article import <wechat-url> [file]
178
- qcplay-cli article publish <file> [--local] [--backend <url>] [--dry-run]`);
184
+ qcplay-cli article publish <file> [--local] [--backend <url>] [--dry-run]
185
+ qcplay-cli article update [id] [--local] [--backend <url>] [--dry-run]
186
+ qcplay-cli article delete [id] [--local] [--backend <url>] [--dry-run]`);
179
187
  }
180
188
 
181
189
  function printFeatures() {
@@ -193,6 +201,8 @@ function printFeatures() {
193
201
  console.log(chalk.cyan("3. 发布官网文章"));
194
202
  console.log(" qcplay-cli article import <微信文章链接> article.md");
195
203
  console.log(" qcplay-cli www-article-list.store article.md");
204
+ console.log(" qcplay-cli article update [id]");
205
+ console.log(" qcplay-cli article delete [id]");
196
206
  console.log("");
197
207
  console.log(chalk.cyan("4. 查看本地目录"));
198
208
  console.log(" qcplay-cli where");
@@ -497,12 +507,24 @@ function parsePublishOptions(args) {
497
507
  positional.push(current);
498
508
  }
499
509
 
510
+ if (positional.length > 1) {
511
+ throw new Error(`未知参数: ${positional.slice(1).join(" ")}`);
512
+ }
513
+
500
514
  return {
501
515
  file: positional[0],
502
516
  options
503
517
  };
504
518
  }
505
519
 
520
+ function parseArticleMutationOptions(args) {
521
+ const parsed = parsePublishOptions(args);
522
+ return {
523
+ articleId: parsed.file,
524
+ options: parsed.options
525
+ };
526
+ }
527
+
506
528
  function requestJson(method, baseUrl, pathname, payload, headers = {}) {
507
529
  return new Promise((resolve, reject) => {
508
530
  const url = new URL(pathname, baseUrl);
@@ -874,6 +896,87 @@ async function authenticatedRequest(method, baseUrl, pathname, payload) {
874
896
  });
875
897
  }
876
898
 
899
+ function normalizeArticleId(value) {
900
+ const text = normalizeText(value);
901
+ if (!/^\d+$/.test(text)) {
902
+ throw new Error("文章 ID 必须是正整数");
903
+ }
904
+
905
+ const articleId = Number(text);
906
+ if (!Number.isSafeInteger(articleId) || articleId <= 0) {
907
+ throw new Error("文章 ID 必须是正整数");
908
+ }
909
+ return articleId;
910
+ }
911
+
912
+ function articlePreviewUrl(articleId) {
913
+ const url = new URL(ARTICLE_PREVIEW_BASE_URL);
914
+ url.searchParams.set("id", String(articleId));
915
+ return url.toString();
916
+ }
917
+
918
+ function backendOriginsMatch(left, right) {
919
+ try {
920
+ return new URL(left).origin === new URL(right).origin;
921
+ } catch {
922
+ return false;
923
+ }
924
+ }
925
+
926
+ async function saveRecentArticleState(articleId, status, backendUrl, previewUrl = "") {
927
+ const authState = await loadAuthState();
928
+ await writeJson(ARTICLE_STATE_FILE, {
929
+ article_id: articleId,
930
+ status: String(status),
931
+ preview_url: previewUrl,
932
+ backend_url: backendUrl,
933
+ administrator_id: Number(authState.id || 0),
934
+ account: normalizeText(authState.account),
935
+ updated_at: Date.now()
936
+ });
937
+ }
938
+
939
+ async function trySaveRecentArticleState(articleId, status, backendUrl, previewUrl = "") {
940
+ try {
941
+ await saveRecentArticleState(articleId, status, backendUrl, previewUrl);
942
+ return "";
943
+ } catch (error) {
944
+ return normalizeText(error?.message) || String(error);
945
+ }
946
+ }
947
+
948
+ async function resolveArticleId(value, backendUrl) {
949
+ if (normalizeText(value)) {
950
+ return normalizeArticleId(value);
951
+ }
952
+
953
+ const [recentArticle, authState] = await Promise.all([readJson(ARTICLE_STATE_FILE), loadAuthState()]);
954
+ if (!recentArticle.article_id) {
955
+ throw new Error("没有找到最近发布的未上线文章,请手动输入文章 ID");
956
+ }
957
+ if (!backendOriginsMatch(recentArticle.backend_url, backendUrl)) {
958
+ throw new Error("最近文章属于其他后端,请手动输入文章 ID");
959
+ }
960
+ if (String(recentArticle.status) !== "0") {
961
+ throw new Error("最近文章不是未上线状态,请手动输入文章 ID");
962
+ }
963
+
964
+ const recentAdministratorId = Number(recentArticle.administrator_id || 0);
965
+ const currentAdministratorId = Number(authState.id || 0);
966
+ if (recentAdministratorId && recentAdministratorId !== currentAdministratorId) {
967
+ throw new Error("最近文章属于其他登录账号,请手动输入文章 ID");
968
+ }
969
+ if (
970
+ !recentAdministratorId &&
971
+ normalizeText(recentArticle.account) &&
972
+ normalizeText(recentArticle.account) !== normalizeText(authState.account)
973
+ ) {
974
+ throw new Error("最近文章属于其他登录账号,请手动输入文章 ID");
975
+ }
976
+
977
+ return normalizeArticleId(recentArticle.article_id);
978
+ }
979
+
877
980
  function flattenPermissionTree(items, bucket = []) {
878
981
  for (const item of items) {
879
982
  bucket.push(item);
@@ -1640,6 +1743,7 @@ async function publishArticleCommand(file, options) {
1640
1743
  }
1641
1744
 
1642
1745
  const { articleFile, payload } = await parseArticleFile(file);
1746
+ payload.status = "0";
1643
1747
  const config = await loadConfig();
1644
1748
  const backendUrl = resolvePublishBackendUrl(options, config);
1645
1749
 
@@ -1649,15 +1753,66 @@ async function publishArticleCommand(file, options) {
1649
1753
  }
1650
1754
 
1651
1755
  console.log("");
1652
- console.log(chalk.cyan("正在发布官网文章..."));
1756
+ console.log(chalk.cyan("正在保存官网文章为未上线状态..."));
1653
1757
  console.log(chalk.gray(`文章文件: ${articleFile}`));
1654
1758
  console.log("");
1655
1759
 
1656
1760
  const response = await authenticatedRequest("POST", backendUrl, "/api/articles/publish", payload);
1657
- console.log(chalk.green(response.message || "发布成功"));
1658
- const articleId = response.data?.id ?? response.data?.article_id ?? response.data?.articleId;
1659
- if (articleId !== undefined) {
1660
- console.log(`文章 ID: ${articleId}`);
1761
+ const rawArticleId = response.data?.id ?? response.data?.article_id ?? response.data?.articleId;
1762
+ const articleId = normalizeArticleId(rawArticleId);
1763
+ const previewUrl = normalizeText(response.data?.preview_url) || articlePreviewUrl(articleId);
1764
+ const stateWarning = await trySaveRecentArticleState(articleId, "0", backendUrl, previewUrl);
1765
+
1766
+ console.log(chalk.green(response.message || "文章已保存为未上线状态"));
1767
+ console.log(`文章 ID: ${articleId}`);
1768
+ console.log("文章状态: 未上线");
1769
+ console.log(`预览地址: ${previewUrl}`);
1770
+ if (stateWarning) {
1771
+ console.log(chalk.yellow(`本地未能记录最近文章 ID,后续操作请手动输入 ${articleId}: ${stateWarning}`));
1772
+ }
1773
+ console.log("");
1774
+ console.log("请打开预览地址检查 PC 和移动端排版。确认无误后执行:");
1775
+ console.log("");
1776
+ console.log(` qcplay-cli article update ${articleId}`);
1777
+ console.log("");
1778
+ }
1779
+
1780
+ async function updateArticleStatusCommand(articleIdValue, targetStatus, options) {
1781
+ const config = await loadConfig();
1782
+ const backendUrl = resolvePublishBackendUrl(options, config);
1783
+ const articleId = await resolveArticleId(articleIdValue, backendUrl);
1784
+ const payload = {
1785
+ id: articleId,
1786
+ status: targetStatus
1787
+ };
1788
+
1789
+ if (options.dryRun) {
1790
+ console.log(JSON.stringify(payload, null, 2));
1791
+ return;
1792
+ }
1793
+
1794
+ const action = targetStatus === "2" ? "删除" : "上线";
1795
+ console.log("");
1796
+ console.log(chalk.cyan(`正在${action}官网文章...`));
1797
+ console.log(chalk.gray(`文章 ID: ${articleId}`));
1798
+ console.log("");
1799
+
1800
+ const response = await authenticatedRequest("PATCH", backendUrl, "/api/articles/status", payload);
1801
+ const responseStatus = normalizeText(response.data?.status) || targetStatus;
1802
+ const previewUrl =
1803
+ responseStatus === "1"
1804
+ ? normalizeText(response.data?.preview_url) || articlePreviewUrl(articleId)
1805
+ : "";
1806
+ const stateWarning = await trySaveRecentArticleState(articleId, responseStatus, backendUrl, previewUrl);
1807
+
1808
+ console.log(chalk.green(response.message || (responseStatus === "2" ? "文章已删除" : "文章已上线")));
1809
+ console.log(`文章 ID: ${articleId}`);
1810
+ console.log(`文章状态: ${responseStatus === "2" ? "已删除" : "已上线"}`);
1811
+ if (previewUrl) {
1812
+ console.log(`文章地址: ${previewUrl}`);
1813
+ }
1814
+ if (stateWarning) {
1815
+ console.log(chalk.yellow(`本地未能记录文章状态,后续操作请手动输入 ${articleId}: ${stateWarning}`));
1661
1816
  }
1662
1817
  }
1663
1818
 
@@ -1747,6 +1902,16 @@ async function main() {
1747
1902
  return;
1748
1903
  }
1749
1904
 
1905
+ if (subcommand === "update" || subcommand === "delete") {
1906
+ const parsed = parseArticleMutationOptions(rest);
1907
+ const targetStatus = subcommand === "delete" ? "2" : "1";
1908
+ const errorTitle = subcommand === "delete" ? "删除失败" : "上线失败";
1909
+ await runWithErrorBanner(errorTitle, () =>
1910
+ updateArticleStatusCommand(parsed.articleId, targetStatus, parsed.options)
1911
+ );
1912
+ return;
1913
+ }
1914
+
1750
1915
  printArticleHelp();
1751
1916
  process.exit(1);
1752
1917
  }
@@ -1757,6 +1922,16 @@ async function main() {
1757
1922
  return;
1758
1923
  }
1759
1924
 
1925
+ if (command === "www-article-list.update" || command === "www-article-list.delete") {
1926
+ const parsed = parseArticleMutationOptions([subcommand, ...rest].filter(Boolean));
1927
+ const targetStatus = command === "www-article-list.delete" ? "2" : "1";
1928
+ const errorTitle = targetStatus === "2" ? "删除失败" : "上线失败";
1929
+ await runWithErrorBanner(errorTitle, () =>
1930
+ updateArticleStatusCommand(parsed.articleId, targetStatus, parsed.options)
1931
+ );
1932
+ return;
1933
+ }
1934
+
1760
1935
  if (command === "features") {
1761
1936
  printFeatures();
1762
1937
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qcplay/cli",
3
- "version": "1.0.11",
3
+ "version": "1.0.12",
4
4
  "description": "QCPlay CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -5,24 +5,25 @@ description: "QCPlay 官网文章上传:Use when user mentions 发布文章、
5
5
  metadata:
6
6
  requires:
7
7
  bins: ["qcplay", "qcplay-cli"]
8
- native_bins: ["publish-article.exe", "qcplay-auth.exe"]
9
8
  cliHelp: "qcplay article --help"
10
9
  authFile: "~/.qcplay/auth.json"
11
10
  templateCommand: "qcplay article"
12
11
  initCommand: "qcplay article init"
13
12
  importCommand: "qcplay article import <wechat-url> [file]"
14
13
  publishCommand: "qcplay www-article-list.store article.md"
14
+ updateCommand: "qcplay article update [id]"
15
+ deleteCommand: "qcplay article delete [id]"
15
16
  ---
16
17
 
17
18
  # qcplay www-article-list.store
18
19
 
19
20
  ## CRITICAL — 文章上传工作流,必须遵循
20
21
 
21
- 本 Skill 用于通过 QCPlay CLI 上传 / 发布官网文章。
22
+ 本 Skill 用于通过 QCPlay CLI 保存、预览、上线和删除官网文章。
22
23
 
23
24
  文章参数必须写在 Markdown 文件顶部的 Front Matter 中,正文写在 Front Matter 之后。
24
25
 
25
- 用户发布文章时,默认只需要执行:
26
+ 用户提交文章时,先执行:
26
27
 
27
28
  ```bash
28
29
  qcplay www-article-list.store article.md
@@ -42,7 +43,21 @@ qcplay-cli www-article-list.store article.md
42
43
  qcplay article import "https://mp.weixin.qq.com/s/..." article.md
43
44
  ```
44
45
 
45
- 该命令只生成未发布的官网文章草稿,不会直接发布。确认草稿正文和 Front Matter 后,再执行发布命令。
46
+ 该命令只生成本地官网文章草稿,不会提交到官网。确认草稿正文和 Front Matter 后,再执行保存命令。
47
+
48
+ 保存命令始终以 `status: "0"` 创建未上线文章,并输出文章 ID 和可点击预览地址。必须先让用户检查 PC、移动端排版;只有用户明确确认上线后,才能执行:
49
+
50
+ ```bash
51
+ qcplay article update [id]
52
+ ```
53
+
54
+ 用户要求删除时执行软删除,将状态更新为 `2`:
55
+
56
+ ```bash
57
+ qcplay article delete [id]
58
+ ```
59
+
60
+ `id` 省略时,只能使用当前账号最近一次保存的未上线文章;找不到时必须要求手动输入 ID。
46
61
 
47
62
  正确方式是:
48
63
 
@@ -53,7 +68,7 @@ Front Matter 解析文章参数
53
68
 
54
69
  Markdown 正文作为 article_content
55
70
 
56
- publish-article.exe 执行上传
71
+ QCPlay CLI 调用认证后端保存文章
57
72
  ```
58
73
 
59
74
  ---
@@ -63,9 +78,7 @@ publish-article.exe 执行上传
63
78
  - **文章文件(Article File)**:一个 Markdown 文件,例如 `article.md`,顶部包含 YAML Front Matter,下面是正文内容。
64
79
  - **Front Matter**:`---` 包裹的 YAML 参数区,用来填写文章标题、缩略图、分类、标签、发布时间等。
65
80
  - **文章正文(article_content)**:Front Matter 后面的 Markdown 正文内容,程序自动作为 `article_content` 上传。
66
- - **发布程序(publish-article.exe)**:Go 编译的本地文章上传程序,负责读取文章文件、解析参数、读取登录凭证并调用发布接口。
67
- - **登录程序(qcplay-auth.exe)**:Go 编译的本地登录程序,负责浏览器登录并生成 `~/.qcplay/auth.json`。
68
- - **统一 CLI(qcplay / qcplay-cli)**:npm 包提供的统一入口,根据子命令调用不同 exe。
81
+ - **统一 CLI(qcplay / qcplay-cli)**:npm 包提供的统一入口,负责解析文章、读取登录凭证并调用认证后端。
69
82
  - **认证文件(auth.json)**:登录成功后保存的本地认证文件,路径为 `~/.qcplay/auth.json`。
70
83
  - **文章模板**:通过 `qcplay article` 查看模板说明,或通过 `qcplay article init` 生成 `article.md`。
71
84
  - **微信文章导入**:通过 `qcplay article import <wechat-url> [file]` 提取推文正文,将图片转存到官网图片服务并生成 Markdown 草稿。
@@ -109,12 +122,12 @@ publish-article.exe 执行上传
109
122
  6. **文章正文不能为空**
110
123
  Front Matter 后面的 Markdown 正文为空时不得上传。
111
124
 
112
- 7. **正式发布需要用户明确设置**
113
- `status: "1"` 表示发布,`status: "0"` 表示未发布。
114
- 如果用户没有明确要求上线,建议使用:
125
+ 7. **首次保存固定为未上线**
126
+ `www-article-list.store` 必须忽略 Front Matter 中的上线值并强制使用:
115
127
  ```yaml
116
128
  status: "0"
117
129
  ```
130
+ 用户检查预览并明确确认后,使用 `article update [id]` 将状态更新为 `1`。不得通过首次保存直接上线。
118
131
 
119
132
  8. **不得伪造发布结果**
120
133
  如果接口返回失败,必须原样提示失败原因,不得编造文章 URL 或成功状态。
@@ -318,22 +331,41 @@ qcplay article import "https://mp.weixin.qq.com/s/..." article.md
318
331
 
319
332
  用户在 `article.md` 顶部 Front Matter 填写文章参数,在下方填写正文。
320
333
 
321
- 6. **发布文章**
334
+ 6. **保存为未上线文章**
322
335
 
323
336
  ```bash
324
337
  qcplay www-article-list.store article.md
325
338
  ```
326
339
 
327
- 7. **查看发布结果**
340
+ 7. **检查预览**
328
341
 
329
- 发布成功后应输出:
342
+ 保存成功后必须输出:
330
343
 
331
344
  ```txt
332
- 官网文章发布成功
345
+ 文章已保存为未上线状态
346
+ 文章 ID: 123
347
+ 文章状态: 未上线
348
+ 预览地址: https://snail.qingcigame.com/official/news-details.html?id=123
349
+ ```
350
+
351
+ 让用户打开预览地址检查 PC 和移动端排版。用户明确确认上线后执行:
333
352
 
334
- 标题: xxx
335
- 状态: 已发布 / 未发布
336
- 文章链接: xxx
353
+ ```bash
354
+ qcplay article update 123
355
+ ```
356
+
357
+ 也可以省略 ID,使用当前账号最近一次保存的未上线文章:
358
+
359
+ ```bash
360
+ qcplay article update
361
+ ```
362
+
363
+ 8. **删除文章**
364
+
365
+ 用户明确要求删除时,将文章状态软删除为 `2`:
366
+
367
+ ```bash
368
+ qcplay article delete 123
337
369
  ```
338
370
 
339
371
  ---
@@ -395,50 +427,54 @@ qcplay article init --force
395
427
 
396
428
  ---
397
429
 
398
- ### 发布文章
430
+ ### 保存未上线文章
399
431
 
400
432
  ```bash
401
433
  qcplay www-article-list.store article.md
402
434
  ```
403
435
 
404
- 内部应调用:
436
+ 首次保存始终将 `status` 强制为 `0`。成功后记录最近文章 ID,并输出预览地址。
437
+
438
+ ### 确认上线
405
439
 
406
440
  ```bash
407
- publish-article.exe --content-file <article.md绝对路径>
441
+ qcplay article update [id]
408
442
  ```
409
443
 
410
- 例如:
444
+ 等价命令:
411
445
 
412
446
  ```bash
413
- publish-article.exe --content-file E:\go-admin\article.md
447
+ qcplay www-article-list.update [id]
414
448
  ```
415
449
 
416
- ---
450
+ 该命令通过更新操作将 `status` 修改为 `1`。
417
451
 
418
- ### Dry Run,可选
452
+ ### 删除文章
419
453
 
420
- 如果支持:
454
+ ```bash
455
+ qcplay article delete [id]
456
+ ```
457
+
458
+ 等价命令:
421
459
 
422
460
  ```bash
423
- qcplay www-article-list.store article.md --dry-run
461
+ qcplay www-article-list.delete [id]
424
462
  ```
425
463
 
426
- 则只解析、校验并展示 payload,不真正调用发布接口。
464
+ 该命令通过更新操作将 `status` 修改为 `2`,不得执行物理删除。
427
465
 
428
466
  ---
429
467
 
430
- ### 指定环境,可选
431
-
432
- ```bash
433
- qcplay www-article-list.store article.md --env prod
434
- ```
468
+ ### Dry Run,可选
435
469
 
436
- 内部传给 Go 程序:
470
+ 如果支持:
437
471
 
438
472
  ```bash
439
- publish-article.exe --content-file E:\go-admin\article.md --env prod
473
+ qcplay www-article-list.store article.md --dry-run
440
474
  ```
441
475
 
476
+ 则只解析、校验并展示 payload,不真正调用发布接口。
477
+
442
478
  ---
443
479
 
444
480
  ## article.md 文件格式
@@ -455,7 +491,7 @@ move_thumbnail: https://example.com/mobile-thumbnail.png
455
491
  article_excerpt: 文章描述,可以为空
456
492
  article_url: article-url-slug
457
493
  origin: QCPlay
458
- status: "1"
494
+ status: "0"
459
495
  cate_id: "2"
460
496
  video_link: ""
461
497
  is_hot: "0"
@@ -497,7 +533,7 @@ index_move_img: ""
497
533
  | `article_excerpt` | 否 | 文章描述,可以为空 |
498
534
  | `article_url` | 建议 | 文章链接,建议使用英文 slug |
499
535
  | `origin` | 否 | 作者来源 |
500
- | `status` | 是 | `1` 发布,`0` 未发布 |
536
+ | `status` | 是 | 模板填写 `0`;首次保存会强制为 `0` |
501
537
  | `cate_id` | 是 | 分类 ID |
502
538
  | `video_link` | 否 | 视频链接 |
503
539
  | `is_hot` | 是 | `1` 热门,`0` 非热门 |
@@ -586,20 +622,17 @@ Front Matter.index_move_img -> index_move_img
586
622
 
587
623
  | 状态 | status |
588
624
  |---|---:|
589
- | 未发布 | 0 |
590
- | 发布 | 1 |
625
+ | 未上线 | 0 |
626
+ | 已上线 | 1 |
627
+ | 已删除(软删除) | 2 |
591
628
 
592
- 默认建议:
629
+ 首次保存固定使用:
593
630
 
594
631
  ```yaml
595
632
  status: "0"
596
633
  ```
597
634
 
598
- 用户明确要求立即发布时:
599
-
600
- ```yaml
601
- status: "1"
602
- ```
635
+ 用户明确确认预览排版无误后,不修改 Front Matter,执行 `qcplay article update [id]` 上线。
603
636
 
604
637
  ---
605
638
 
@@ -818,7 +851,7 @@ index_move_img: ""
818
851
  1. `article_title` 不能为空;
819
852
  2. `thumbnail` 不能为空;
820
853
  3. `cate_id` 不能为空;
821
- 4. `status` 只能是 `"0"` 或 `"1"`;
854
+ 4. Front Matter 的 `status` 只能是 `"0"` 或 `"1"`,首次保存请求固定覆盖为 `"0"`;
822
855
  5. `is_hot` 只能是 `"0"` 或 `"1"`;
823
856
  6. `is_index` 只能是 `"0"` 或 `"1"`;
824
857
  7. `release_time` 格式必须是 `YYYY-MM-DD`,例如:
@@ -880,7 +913,7 @@ qcplay article import "https://mp.weixin.qq.com/s/..." article.md
880
913
  - 输出状态固定为未发布,发布前必须检查分类、缩略图、发布日期和正文;
881
914
  - 不允许覆盖已经存在的文件。
882
915
 
883
- ### 发布文章
916
+ ### 保存未上线文章
884
917
 
885
918
  ```bash
886
919
  qcplay www-article-list.store article.md
@@ -894,88 +927,21 @@ qcplay www-article-list.store ./articles/news.md
894
927
 
895
928
  ---
896
929
 
897
- ## npm 调用到 Go 程序的约定
898
-
899
- Node CLI 只负责路由,不负责发布逻辑。
900
-
901
- 当用户执行:
930
+ ## CLI 与后端约定
902
931
 
903
- ```bash
904
- qcplay www-article-list.store article.md
905
- ```
906
-
907
- Node CLI 内部应调用:
908
-
909
- ```bash
910
- publish-article.exe --content-file <article.md绝对路径>
911
- ```
932
+ Node CLI 负责解析 Markdown、构建请求、读取本地认证状态和输出结果;Go 后端负责认证、权限校验和数据库更新。
912
933
 
913
- 不要把 Front Matter 中的字段拆成命令行参数传递。
914
-
915
- 正确:
916
-
917
- ```bash
918
- publish-article.exe --content-file E:\go-admin\article.md
919
- ```
920
-
921
- 不推荐:
922
-
923
- ```bash
924
- publish-article.exe --article-title "标题" --cate-id 2 --thumbnail xxx
925
- ```
926
-
927
- ---
928
-
929
- ## Go 程序实现要求
930
-
931
- `publish-article.exe` 必须完成:
932
-
933
- 1. 读取 `--content-file`;
934
- 2. 解析 Markdown Front Matter;
935
- 3. 将正文作为 `article_content`;
936
- 4. 从 `~/.qcplay/auth.json` 读取 `access_token`;
937
- 5. 校验 token 是否存在;
938
- 6. 校验 token 是否过期;
939
- 7. 补充默认参数:
940
- ```txt
941
- is_index2 = 0
942
- type = 1
943
- ```
944
- 8. 构建发布请求;
945
- 9. 调用官网文章发布接口;
946
- 10. 输出发布结果。
947
-
948
- ---
949
-
950
- ## Go 字段结构参考
951
-
952
- ```go
953
- type ArticleMeta struct {
954
- ArticleTitle string `yaml:"article_title"`
955
- Thumbnail string `yaml:"thumbnail"`
956
- MoveThumbnail string `yaml:"move_thumbnail"`
957
- ArticleExcerpt string `yaml:"article_excerpt"`
958
- ArticleURL string `yaml:"article_url"`
959
- Origin string `yaml:"origin"`
960
- Status string `yaml:"status"`
961
- CateID string `yaml:"cate_id"`
962
- VideoLink string `yaml:"video_link"`
963
- IsHot string `yaml:"is_hot"`
964
- IsIndex string `yaml:"is_index"`
965
- ReleaseTime string `yaml:"release_time"`
966
- Area string `yaml:"area"`
967
- Sort string `yaml:"sort"`
968
- GameID string `yaml:"game_id"`
969
- IndexPCImg string `yaml:"index_pc_img"`
970
- IndexMoveImg string `yaml:"index_move_img"`
971
- }
972
- ```
934
+ - 首次保存:`POST /api/articles/publish`,后端再次强制 `status=0`;
935
+ - 确认上线:`PATCH /api/articles/status`,请求 `{ "id": 123, "status": "1" }`;
936
+ - 删除文章:`PATCH /api/articles/status`,请求 `{ "id": 123, "status": "2" }`;
937
+ - 上线和删除都要求 `www-article-list.update` 权限;
938
+ - 删除是状态更新,不得执行物理删除。
973
939
 
974
940
  ---
975
941
 
976
942
  ## Front Matter 解析要求
977
943
 
978
- Go 程序必须兼容:
944
+ CLI 必须兼容:
979
945
 
980
946
  1. UTF-8 BOM;
981
947
  2. Windows 换行 `\r\n`;
@@ -1000,9 +966,9 @@ Go 程序必须兼容:
1000
966
 
1001
967
  ---
1002
968
 
1003
- ## 发布请求字段
969
+ ## 首次保存请求字段
1004
970
 
1005
- 发布接口最终应收到:
971
+ 首次保存接口最终应收到:
1006
972
 
1007
973
  ```json
1008
974
  {
@@ -1013,7 +979,7 @@ Go 程序必须兼容:
1013
979
  "article_excerpt": "文章描述,可以为空",
1014
980
  "article_url": "article-url-slug",
1015
981
  "origin": "QCPlay",
1016
- "status": "1",
982
+ "status": "0",
1017
983
  "cate_id": "2",
1018
984
  "video_link": "",
1019
985
  "is_hot": "0",
@@ -1033,23 +999,30 @@ Go 程序必须兼容:
1033
999
 
1034
1000
  ## 成功输出
1035
1001
 
1036
- 发布成功后输出:
1002
+ 首次保存成功后输出:
1037
1003
 
1038
1004
  ```txt
1039
- 官网文章发布成功
1040
-
1041
- 标题: 文章标题
1042
- 状态: 已发布
1043
- 文章链接: xxx
1005
+ 文章已保存为未上线状态
1006
+ 文章 ID: 123
1007
+ 文章状态: 未上线
1008
+ 预览地址: https://snail.qingcigame.com/official/news-details.html?id=123
1044
1009
  ```
1045
1010
 
1046
- 如果 `status` 是 `"0"`:
1011
+ 确认上线成功后输出:
1047
1012
 
1048
1013
  ```txt
1049
- 官网文章已保存为未发布状态
1014
+ 文章已上线
1015
+ 文章 ID: 123
1016
+ 文章状态: 已上线
1017
+ 文章地址: https://snail.qingcigame.com/official/news-details.html?id=123
1018
+ ```
1019
+
1020
+ 删除成功后输出:
1050
1021
 
1051
- 标题: 文章标题
1052
- 状态: 未发布
1022
+ ```txt
1023
+ 文章已删除
1024
+ 文章 ID: 123
1025
+ 文章状态: 已删除
1053
1026
  ```
1054
1027
 
1055
1028
  ---
@@ -1142,13 +1115,12 @@ release_time 格式错误,请使用 YYYY-MM-DD,例如 2026-07-09
1142
1115
  4. 不要在命令中拼接全部文章字段;
1143
1116
  5. 文章字段从 `article.md` Front Matter 读取;
1144
1117
  6. Markdown 正文作为 `article_content`;
1145
- 7. Node CLI 只负责调用:
1146
- ```bash
1147
- publish-article.exe --content-file <file>
1148
- ```
1149
- 8. Go 程序负责解析、校验、读取 auth、调用接口;
1150
- 9. 发布成功或失败都在当前终端输出;
1151
- 10. 不得打印完整 token。
1118
+ 7. Node CLI 负责解析、校验、读取 auth 并调用后端接口;
1119
+ 8. Go 后端负责权限校验、强制草稿状态和数据库更新;
1120
+ 9. 保存、上线或删除的成功和失败都在当前终端输出;
1121
+ 10. 不得打印完整 token;
1122
+ 11. 未得到用户确认,不得执行上线;
1123
+ 12. 删除只能更新 `status=2`,不得物理删除数据。
1152
1124
 
1153
1125
  ---
1154
1126
 
@@ -1189,14 +1161,21 @@ qcplay article init
1189
1161
  qcplay www-article-list.store article.md
1190
1162
  ```
1191
1163
 
1192
- 读取 `article.md` 并发布文章。
1164
+ 读取 `article.md` 并保存为未上线文章。
1165
+
1166
+ ```bash
1167
+ qcplay article update [id]
1168
+ qcplay article delete [id]
1169
+ ```
1170
+
1171
+ 分别将文章状态更新为已上线和已删除。
1193
1172
 
1194
1173
  并满足:
1195
1174
 
1196
1175
  - 能解析 Front Matter;
1197
1176
  - 能读取 Markdown 正文;
1198
1177
  - 能读取 `~/.qcplay/auth.json`;
1199
- - 能调用 `publish-article.exe`;
1178
+ - 能调用认证后端并处理接口错误;
1200
1179
  - 能处理无权限错误;
1201
1180
  - 能处理字段缺失错误;
1202
1181
  - 不要求用户在命令行传标题;
@@ -1211,8 +1190,11 @@ qcplay www-article-list.store article.md
1211
1190
  命令只传文章文件。
1212
1191
  正文自动作为 article_content。
1213
1192
  登录状态读取 auth.json。
1214
- Node 只做路由。
1215
- Go 负责发布。
1193
+ Node 解析文章并调用后端。
1194
+ Go 后端负责权限和数据库更新。
1216
1195
  默认参数程序内部补齐。
1196
+ 首次保存固定 status=0。
1197
+ 确认后更新 status=1。
1198
+ 删除更新 status=2。
1217
1199
  发布失败不得伪造成成功。
1218
1200
  ```