wkgeo-cli 0.2.0 → 0.2.1
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 +129 -12
- package/SKILL.md +131 -4
- package/lib/args.mjs +5 -0
- package/lib/cli.mjs +5 -0
- package/lib/cms.mjs +317 -0
- package/lib/commands/auth.mjs +42 -11
- package/lib/commands/billing.mjs +641 -421
- package/lib/commands/cms.mjs +1265 -0
- package/lib/commands/import.mjs +164 -0
- package/lib/commands/index.mjs +83 -4
- package/lib/config.mjs +82 -22
- package/lib/device.mjs +22 -5
- package/lib/index.mjs +5 -0
- package/lib/local-import.mjs +185 -0
- package/lib/login.mjs +42 -22
- package/lib/oauth-urls.mjs +163 -0
- package/lib/runtime.mjs +81 -0
- package/lib/site-state.mjs +137 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CMS 命令(客户站后台的内容管理面):栏目 / 内容 / 素材 / 模板 / 发布 / 选站。
|
|
3
|
+
*
|
|
4
|
+
* ## 这一层的三条口径
|
|
5
|
+
* 1. **站点必须先定**:所有 CMS 动作都落在"一个站"上。来源优先级
|
|
6
|
+
* `--site <站点ID>` > `WKONGAI_SITE` > 本地 `wkgeo site use` 记住的那个。
|
|
7
|
+
* 一个都没有就**当场报用法错误**,并且只说一句能照做的话 —— 不猜、不默认。
|
|
8
|
+
* 2. **动作名照抄云函数动作表**(`tencent/functions/cms/index.js` 的 `actionTable`),
|
|
9
|
+
* 参数名也照抄后台前端(`CMS/src/service/api/cms.ts`)——
|
|
10
|
+
* 两边一旦不一致,用户看到的是"参数传了但没生效"这种最难查的错。
|
|
11
|
+
* 3. **默认人类可读表格,`--json` 给原始数据**(信封由 `runtime.done()` 统一包)。
|
|
12
|
+
*
|
|
13
|
+
* ## 与后台网页的关系
|
|
14
|
+
* 命令**不是**另一套业务逻辑:它就是后台那几个按钮的同一批接口。
|
|
15
|
+
* 所以后台的约束在 CLI 上**一条都不会松**(系统栏目不能删、单页内容只能改不能建、
|
|
16
|
+
* 绑定模板必须是本站已有的 html …)—— 这些都由服务端拦,CLI 只是把原因如实转给用户。
|
|
17
|
+
*
|
|
18
|
+
* ## 别在这里做的事
|
|
19
|
+
* * ❌ 不往请求 body 里塞 `site_id`:后端**故意忽略**它,站点边界只认 `x-cms-site` 和会话;
|
|
20
|
+
* * ❌ 不自己拼 CMS 地址:走 `runtime.cmsCall()`(它管 Bearer 令牌、站点头、错误翻译);
|
|
21
|
+
* * ❌ 不在 CLI 里"补一条内容"绕过单页限制:那是故意绕过后端校验。
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import fs from 'node:fs';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
|
|
27
|
+
import { camel } from '../args.mjs';
|
|
28
|
+
import { uploadToOss } from '../cms.mjs';
|
|
29
|
+
import { CliError, UsageError } from '../errors.mjs';
|
|
30
|
+
import { formatTime } from '../format.mjs';
|
|
31
|
+
import { clearSiteState, readSiteState, writeSiteState } from '../site-state.mjs';
|
|
32
|
+
|
|
33
|
+
/* ------------------------------------------------------------------ 公共小工具 */
|
|
34
|
+
|
|
35
|
+
/** 站点/资源 id 的形状(后端到处用 uuid 做参数类型,先本地挡一道比让 PG 报 22P02 友好) */
|
|
36
|
+
const UUID_RE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
|
37
|
+
|
|
38
|
+
export function looksLikeUuid(v) {
|
|
39
|
+
return UUID_RE.test(String(v || '').trim());
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 所有 CMS 命令共用的 `--site`(默认取 `wkgeo site use` 选中的站点) */
|
|
43
|
+
const SITE_FLAG = {
|
|
44
|
+
site: {
|
|
45
|
+
type: 'string',
|
|
46
|
+
desc: '站点 ID(uuid;默认用 `wkgeo site use` 选中的站点,可用 WKONGAI_SITE 覆盖)',
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const CATEGORY_KIND_LABEL = { channel: '频道页', list: '列表页', page: '单页' };
|
|
51
|
+
const CONTENT_STATUS_LABEL = { draft: '草稿', published: '已发布', hidden: '隐藏' };
|
|
52
|
+
|
|
53
|
+
/** 站点那一行的提示文本(人类输出用) */
|
|
54
|
+
function siteText(site) {
|
|
55
|
+
if (!site) return '';
|
|
56
|
+
/** 没显式选站:服务端按令牌的成员关系判定(见 runtime.cmsSite 的第 4 档) */
|
|
57
|
+
if (!site.id) return '由服务端按你的成员关系自动判定';
|
|
58
|
+
return site.label ? `${site.label}(${site.id})` : site.id;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 信封里的 `site_id`:没显式选站时是 `null`(真实站点由服务端定,返回行里自带 site_id) */
|
|
62
|
+
function siteIdOf(site) {
|
|
63
|
+
return site && site.id ? site.id : null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 按 1024 进制给人看的容量 */
|
|
67
|
+
export function formatBytes(n) {
|
|
68
|
+
const num = Number(n);
|
|
69
|
+
if (!Number.isFinite(num) || num < 0) return '-';
|
|
70
|
+
if (num < 1024) return `${num} B`;
|
|
71
|
+
if (num < 1024 * 1024) return `${(num / 1024).toFixed(1)} KB`;
|
|
72
|
+
return `${(num / 1024 / 1024).toFixed(2)} MB`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 读一个文本文件(`--body-file` / `--file` 用);失败给能照做的中文错误 */
|
|
76
|
+
function readTextFile(flagName, filePath) {
|
|
77
|
+
const abs = path.resolve(String(filePath));
|
|
78
|
+
try {
|
|
79
|
+
return fs.readFileSync(abs, 'utf8');
|
|
80
|
+
} catch (e) {
|
|
81
|
+
throw new CliError(`读不到文件:${abs}`, {
|
|
82
|
+
kind: 'io',
|
|
83
|
+
hint: `${flagName} 需要一个可读的文件路径(${(e && e.code) || (e && e.message) || '未知原因'})`,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** `--ext` 这类"整段 JSON"参数 */
|
|
89
|
+
function parseJsonObjectFlag(name, raw) {
|
|
90
|
+
let parsed;
|
|
91
|
+
try {
|
|
92
|
+
parsed = JSON.parse(String(raw));
|
|
93
|
+
} catch (e) {
|
|
94
|
+
throw new UsageError(`参数 ${name} 需要一段 JSON 对象,收到:${String(raw).slice(0, 80)}(${e.message})`);
|
|
95
|
+
}
|
|
96
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
97
|
+
throw new UsageError(`参数 ${name} 需要一个 JSON **对象**(形如 {"型号":"X1"}),不是数组或标量`);
|
|
98
|
+
}
|
|
99
|
+
return parsed;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** 逗号分隔 → 字符串数组(`--usage home,featured`) */
|
|
103
|
+
function splitList(raw) {
|
|
104
|
+
return String(raw == null ? '' : raw)
|
|
105
|
+
.split(',')
|
|
106
|
+
.map((s) => s.trim())
|
|
107
|
+
.filter(Boolean);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** `--body` / `--body-file` 二选一 → 正文;没给就 undefined(= 不传,后端保持原样) */
|
|
111
|
+
function resolveBodyFlag(ctx) {
|
|
112
|
+
const hasInline = ctx.flags.body !== undefined;
|
|
113
|
+
const hasFile = ctx.flags.bodyFile !== undefined;
|
|
114
|
+
if (hasInline && hasFile) throw new UsageError('--body 与 --body-file 只能给一个');
|
|
115
|
+
if (hasFile) return readTextFile('--body-file', ctx.flags.bodyFile);
|
|
116
|
+
return hasInline ? String(ctx.flags.body) : undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 把「字段表 + flags」变成请求体:**只放用户真的给了的键**。
|
|
121
|
+
*
|
|
122
|
+
* 为什么不能一律塞 `undefined`:`categorySave` / `contentSave` 的语义是
|
|
123
|
+
* "没传的保持原样",塞一个 `null` 进去等于**清空**(`pickValue` 认的是 `undefined`)。
|
|
124
|
+
*
|
|
125
|
+
* ⚠️ 字段表的键是**命令行写法**(`in-menu` / `seo-description`),而解析后的 flags 是 camelCase
|
|
126
|
+
* (`inMenu` / `seoDescription`)—— 必须过 `camel()`,否则读到的永远是 undefined,
|
|
127
|
+
* 表现成"参数传了但没生效"(这条踩过一次,专门写在这)。
|
|
128
|
+
*/
|
|
129
|
+
function pickFields(flags, table, payload) {
|
|
130
|
+
for (const [flagKey, bodyKey, kind] of table) {
|
|
131
|
+
const v = flags[camel(flagKey)];
|
|
132
|
+
if (v === undefined) continue;
|
|
133
|
+
if (kind === 'num') {
|
|
134
|
+
const n = Number(v);
|
|
135
|
+
if (!Number.isFinite(n)) throw new UsageError(`参数 --${flagKey} 需要数字,收到:${v}`);
|
|
136
|
+
payload[bodyKey] = n;
|
|
137
|
+
} else if (kind === 'bool') {
|
|
138
|
+
payload[bodyKey] = v === true;
|
|
139
|
+
} else if (kind === 'list') {
|
|
140
|
+
payload[bodyKey] = splitList(v);
|
|
141
|
+
} else {
|
|
142
|
+
payload[bodyKey] = String(v);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return payload;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/* ------------------------------------------------------------------ 站点 */
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* `wkgeo site use`:把"以后操作哪个站"记在本地。
|
|
152
|
+
*
|
|
153
|
+
* 为什么要这一步(而不是每条命令都问):CMS 的动作**必须**有站点上下文,
|
|
154
|
+
* 而 CLI 没有登录 Cookie;把选择显式化之后,多站用户不会出现"改了 A 站以为改的是 B 站"。
|
|
155
|
+
*
|
|
156
|
+
* 域名 → id 的解析走平台面的 `listSites`(**自己名下**的站)。
|
|
157
|
+
* ⚠️ 如果这个站是"别人邀请你协作的",`listSites` 里没有 ⇒ 只能用站点 ID 选(文案里已写明)。
|
|
158
|
+
*/
|
|
159
|
+
export const siteUseCommand = {
|
|
160
|
+
name: 'site use',
|
|
161
|
+
summary: '选中要操作的站点(本地记住,之后的 CMS 命令都带它)',
|
|
162
|
+
usage: 'wkgeo site use <站点ID或域名>\nwkgeo site use # 看当前选中的是哪个站\nwkgeo site use --clear # 取消选中',
|
|
163
|
+
args: [{ name: 'site', required: false, desc: '站点 ID(uuid)、自有域名、临时域名或站点名' }],
|
|
164
|
+
flags: {
|
|
165
|
+
clear: { type: 'boolean', desc: '取消当前选中的站点' },
|
|
166
|
+
},
|
|
167
|
+
examples: [
|
|
168
|
+
'wkgeo site use 8f3c1a2e-4b5d-4f6a-8b7c-9d0e1f2a3b4c',
|
|
169
|
+
'wkgeo site use www.example.com',
|
|
170
|
+
'wkgeo site use # 只查询,不改',
|
|
171
|
+
'wkgeo categories list --site 8f3c… # 不改本地选择,临时指定',
|
|
172
|
+
],
|
|
173
|
+
notes: [
|
|
174
|
+
'选择存在 `<凭据目录>/config.json`(默认 `~/.wkongai/config.json`),与令牌文件分开:logout 不会清掉它。',
|
|
175
|
+
'域名解析只查**你自己名下**的站;协作站的域名查不到时,请改用站点 ID。',
|
|
176
|
+
],
|
|
177
|
+
async run(ctx) {
|
|
178
|
+
const { out } = ctx;
|
|
179
|
+
|
|
180
|
+
if (ctx.flags.clear) {
|
|
181
|
+
const had = clearSiteState(ctx.siteStateFile);
|
|
182
|
+
const data = { cleared: had, site: null, state_file: ctx.siteStateFile };
|
|
183
|
+
if (out.json) ctx.done(data);
|
|
184
|
+
else if (had) out.ok('已取消选中的站点(后续 CMS 命令需要重新 `wkgeo site use` 或加 `--site`)');
|
|
185
|
+
else out.info('本来就没有选中任何站点,无需取消。');
|
|
186
|
+
return data;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const wanted = String(ctx.positional.site || '').trim();
|
|
190
|
+
if (!wanted) {
|
|
191
|
+
const current = readSiteState(ctx.siteStateFile);
|
|
192
|
+
const data = { site: current, state_file: ctx.siteStateFile, selected: Boolean(current) };
|
|
193
|
+
if (out.json) ctx.done(data);
|
|
194
|
+
else if (current) {
|
|
195
|
+
out.head('当前选中的站点');
|
|
196
|
+
out.line(` 站点:${siteText(current)}`);
|
|
197
|
+
out.line(` 选择时间:${formatTime(current.saved_at)}`);
|
|
198
|
+
out.line(` 记录文件:${ctx.siteStateFile}`);
|
|
199
|
+
} else {
|
|
200
|
+
out.info('还没有选中站点:运行 `wkgeo site use <站点ID或域名>`,或在命令里加 `--site <站点ID>`。');
|
|
201
|
+
}
|
|
202
|
+
return data;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** ① ID 直接认(**不查库**:协作站也可能不在 listSites 里,那是正常情况) */
|
|
206
|
+
let site = null;
|
|
207
|
+
let matchedBy = '';
|
|
208
|
+
if (looksLikeUuid(wanted)) {
|
|
209
|
+
site = { id: wanted, label: '' };
|
|
210
|
+
matchedBy = 'id';
|
|
211
|
+
} else {
|
|
212
|
+
/** ② 域名 / 站点名 → 查平台面的站点列表再匹配 */
|
|
213
|
+
const res = await ctx.callData('listSites', { limit: 200 });
|
|
214
|
+
const rows = (res && Array.isArray(res.rows) ? res.rows : []).map((r) => r || {});
|
|
215
|
+
site = matchSiteRow(rows, wanted);
|
|
216
|
+
matchedBy = site ? site.matched_by : '';
|
|
217
|
+
if (!site) {
|
|
218
|
+
throw new CliError(`没有找到站点「${wanted}」(按 id / 域名 / 站点名都没匹配上)`, {
|
|
219
|
+
kind: 'not_found',
|
|
220
|
+
code: 404,
|
|
221
|
+
hint:
|
|
222
|
+
rows.length
|
|
223
|
+
? `你名下的站:${rows
|
|
224
|
+
.slice(0, 10)
|
|
225
|
+
.map((r) => `${r.name || '(未命名)'}=${r.id}`)
|
|
226
|
+
.join(';')}${rows.length > 10 ? ` …共 ${rows.length} 个` : ''};也可以直接给站点 ID。`
|
|
227
|
+
: '当前账号名下没有站点(协作站不在列表里)—— 请直接用站点 ID。',
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
writeSiteState(ctx.siteStateFile, site);
|
|
233
|
+
const data = {
|
|
234
|
+
site: { id: site.id, label: site.label || '' },
|
|
235
|
+
matched_by: matchedBy,
|
|
236
|
+
state_file: ctx.siteStateFile,
|
|
237
|
+
};
|
|
238
|
+
if (out.json) ctx.done(data);
|
|
239
|
+
else {
|
|
240
|
+
out.ok(`已选中站点:${siteText(site)}`);
|
|
241
|
+
out.info(` 记录文件:${ctx.siteStateFile}(之后的 CMS 命令默认都用这个站)`);
|
|
242
|
+
out.info(' 临时换站:任意 CMS 命令加 `--site <站点ID>`。');
|
|
243
|
+
}
|
|
244
|
+
return data;
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* 在一堆站点行里按「域名 / 临时域名 / 站点名」找唯一一个。
|
|
250
|
+
*
|
|
251
|
+
* @returns {{id:string, label:string, matched_by:string}|null}
|
|
252
|
+
*/
|
|
253
|
+
export function matchSiteRow(rows, wanted) {
|
|
254
|
+
const needle = normalizeSiteRef(wanted);
|
|
255
|
+
if (!needle) return null;
|
|
256
|
+
for (const row of rows || []) {
|
|
257
|
+
const id = String(row.id || '');
|
|
258
|
+
if (!id) continue;
|
|
259
|
+
const label = String(row.name || '') || String(row.domain || '');
|
|
260
|
+
const domains = [];
|
|
261
|
+
if (row.domain) domains.push(String(row.domain));
|
|
262
|
+
if (Array.isArray(row.domains)) for (const d of row.domains) if (d) domains.push(String(d));
|
|
263
|
+
if (row.prefix_host) domains.push(String(row.prefix_host));
|
|
264
|
+
for (const d of domains) {
|
|
265
|
+
if (normalizeSiteRef(d) === needle) return { id, label, matched_by: 'domain' };
|
|
266
|
+
}
|
|
267
|
+
if (String(row.name || '').trim() === String(wanted).trim()) return { id, label, matched_by: 'name' };
|
|
268
|
+
}
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** 域名归一:去掉协议、`/` 尾巴、大小写 —— 用户粘一条网址进来也能匹配 */
|
|
273
|
+
function normalizeSiteRef(v) {
|
|
274
|
+
return String(v || '')
|
|
275
|
+
.trim()
|
|
276
|
+
.replace(/^https?:\/\//i, '')
|
|
277
|
+
.replace(/\/+$/, '')
|
|
278
|
+
.toLowerCase();
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/* ------------------------------------------------------------------ 栏目 */
|
|
282
|
+
|
|
283
|
+
/** 取本站栏目全表(`categoryList` 返回的是**数组**,不是 `{rows}`) */
|
|
284
|
+
async function fetchCategories(ctx) {
|
|
285
|
+
const res = await ctx.cmsCall('categoryList');
|
|
286
|
+
const rows = Array.isArray(res) ? res : res && Array.isArray(res.rows) ? res.rows : [];
|
|
287
|
+
return rows.map((r) => r || {});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* 栏目引用 → 栏目行(支持 栏目 ID / slug / path / 栏目名)。
|
|
292
|
+
*
|
|
293
|
+
* 为什么要这么宽:AI 端手里常常只有「新闻」这个栏目名或 `/news/` 这个路径,
|
|
294
|
+
* 逼它先去记一个 uuid 没有意义 —— 而全表本来就在一次调用里拿回来了。
|
|
295
|
+
*/
|
|
296
|
+
export async function resolveCategory(ctx, ref) {
|
|
297
|
+
const wanted = String(ref || '').trim();
|
|
298
|
+
if (!wanted) throw new UsageError('缺少栏目(可以给栏目 ID、slug、path 或栏目名)');
|
|
299
|
+
const rows = await fetchCategories(ctx);
|
|
300
|
+
const low = wanted.toLowerCase();
|
|
301
|
+
const trimPath = (s) => String(s || '').replace(/^\/+|\/+$/g, '').toLowerCase();
|
|
302
|
+
const hit =
|
|
303
|
+
rows.find((r) => String(r.id) === wanted) ||
|
|
304
|
+
rows.find((r) => String(r.slug || '').toLowerCase() === low) ||
|
|
305
|
+
rows.find((r) => trimPath(r.path) === trimPath(wanted)) ||
|
|
306
|
+
rows.find((r) => String(r.name || '') === wanted);
|
|
307
|
+
if (hit) return hit;
|
|
308
|
+
throw new CliError(`没有找到栏目「${wanted}」`, {
|
|
309
|
+
kind: 'not_found',
|
|
310
|
+
code: 404,
|
|
311
|
+
hint: rows.length
|
|
312
|
+
? `当前栏目:${rows
|
|
313
|
+
.slice(0, 20)
|
|
314
|
+
.map((r) => `${r.name || '(未命名)'}(${r.path || r.slug || r.id})`)
|
|
315
|
+
.join('、')}${rows.length > 20 ? ' …' : ''}`
|
|
316
|
+
: '这个站还没有栏目:先 `wkgeo categories create --name 首页 --kind channel`,或在后台启用默认栏目。',
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export const categoriesListCommand = {
|
|
321
|
+
name: 'categories list',
|
|
322
|
+
summary: '栏目列表(树序:按 level / sort 排)',
|
|
323
|
+
usage: 'wkgeo categories list [--site <站点ID>]',
|
|
324
|
+
flags: { ...SITE_FLAG },
|
|
325
|
+
examples: ['wkgeo categories list', 'wkgeo categories list --json', 'wkgeo categories list --site 8f3c…'],
|
|
326
|
+
async run(ctx) {
|
|
327
|
+
const { out } = ctx;
|
|
328
|
+
const site = ctx.cmsSite();
|
|
329
|
+
const rows = await fetchCategories(ctx);
|
|
330
|
+
const data = { site_id: siteIdOf(site), rows, total: rows.length };
|
|
331
|
+
|
|
332
|
+
if (!out.json) {
|
|
333
|
+
out.head(`栏目列表(${rows.length} 个 · 站点 ${siteText(site)})`);
|
|
334
|
+
out.table(
|
|
335
|
+
[
|
|
336
|
+
{ title: '名称', key: 'name', width: 26, format: (v, row) => `${' '.repeat(Math.max(0, (Number(row.level) || 1) - 1))}${v || ''}` },
|
|
337
|
+
{ title: '路径', key: 'path', width: 20 },
|
|
338
|
+
{ title: '类型', key: 'kind', width: 8, format: (v) => CATEGORY_KIND_LABEL[v] || v || '-' },
|
|
339
|
+
{ title: '状态', key: 'status', width: 6, format: (v) => (v === 'hide' ? '隐藏' : '显示') },
|
|
340
|
+
{ title: '绑定模板', key: 'template_path', width: 14 },
|
|
341
|
+
{ title: '栏目 ID', key: 'id', width: 38 },
|
|
342
|
+
],
|
|
343
|
+
rows,
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
ctx.done(data);
|
|
347
|
+
return data;
|
|
348
|
+
},
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
export const categoriesGetCommand = {
|
|
352
|
+
name: 'categories get',
|
|
353
|
+
summary: '单个栏目详情(含栏目正文与父栏目启用的字段)',
|
|
354
|
+
usage: 'wkgeo categories get <栏目ID|slug|path|栏目名> [--site <站点ID>]',
|
|
355
|
+
args: [{ name: 'category', required: true, desc: '栏目 ID / slug / path(如 /news/)/ 栏目名' }],
|
|
356
|
+
flags: { ...SITE_FLAG },
|
|
357
|
+
examples: ['wkgeo categories get /news/', 'wkgeo categories get 8f3c1a2e-… --json'],
|
|
358
|
+
async run(ctx) {
|
|
359
|
+
const { out } = ctx;
|
|
360
|
+
const site = ctx.cmsSite();
|
|
361
|
+
const row = await resolveCategory(ctx, ctx.positional.category);
|
|
362
|
+
/** 详情要单独调一次:正文在 cms_richtext 里,`categoryList` 故意不带(列表会被拖慢) */
|
|
363
|
+
const one = await ctx.cmsCall('categoryGet', { id: String(row.id) });
|
|
364
|
+
const data = { site_id: siteIdOf(site), category: one };
|
|
365
|
+
|
|
366
|
+
if (!out.json) {
|
|
367
|
+
out.head(`栏目:${one.name || row.name || ''}`);
|
|
368
|
+
const lines = [
|
|
369
|
+
['栏目 ID', one.id],
|
|
370
|
+
['路径', one.path],
|
|
371
|
+
['slug', one.slug],
|
|
372
|
+
['类型', CATEGORY_KIND_LABEL[one.kind] || one.kind || '-'],
|
|
373
|
+
['状态', one.status === 'hide' ? '隐藏' : '显示'],
|
|
374
|
+
['在菜单里', one.in_menu ? '是' : '否'],
|
|
375
|
+
['绑定模板', one.template_path || '(按类型自动选)'],
|
|
376
|
+
['副标题', one.sub_title || '-'],
|
|
377
|
+
['SEO 描述', one.seo_description || '-'],
|
|
378
|
+
['SEO 关键词', one.seo_keywords || '-'],
|
|
379
|
+
['更新时间', formatTime(one.updated_at)],
|
|
380
|
+
];
|
|
381
|
+
for (const [k, v] of lines) out.line(` ${k}:${v === undefined || v === null || v === '' ? '-' : v}`);
|
|
382
|
+
if (one.body) {
|
|
383
|
+
out.line(' 栏目正文:');
|
|
384
|
+
out.line(String(one.body));
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
ctx.done(data);
|
|
388
|
+
return data;
|
|
389
|
+
},
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
/** `categorySave` 支持的字段(flag → body 键) */
|
|
393
|
+
const CATEGORY_FIELDS = [
|
|
394
|
+
['name', 'name', 'str'],
|
|
395
|
+
['slug', 'slug', 'str'],
|
|
396
|
+
['parent', 'parent_id', 'str'],
|
|
397
|
+
['kind', 'kind', 'str'],
|
|
398
|
+
['status', 'status', 'str'],
|
|
399
|
+
['sort', 'sort', 'num'],
|
|
400
|
+
['icon', 'icon', 'str'],
|
|
401
|
+
['link-url', 'link_url', 'str'],
|
|
402
|
+
['description', 'description', 'str'],
|
|
403
|
+
['sub-title', 'sub_title', 'str'],
|
|
404
|
+
['seo-description', 'seo_description', 'str'],
|
|
405
|
+
['seo-keywords', 'seo_keywords', 'str'],
|
|
406
|
+
['in-menu', 'in_menu', 'bool'],
|
|
407
|
+
['featured', 'featured', 'bool'],
|
|
408
|
+
];
|
|
409
|
+
|
|
410
|
+
/** 栏目命令共用的字段参数(新建/修改同源,避免两处写歪) */
|
|
411
|
+
const CATEGORY_FIELD_FLAGS = {
|
|
412
|
+
slug: { type: 'string', desc: 'URL 片段(不传由服务端按名字生成)' },
|
|
413
|
+
parent: { type: 'string', desc: '上级栏目 ID(不传 = 一级栏目)' },
|
|
414
|
+
kind: { type: 'string', desc: '页面类型:channel 频道页 / list 列表页 / page 单页(只有一级栏目能定)' },
|
|
415
|
+
status: { type: 'string', desc: '显示状态:show 显示 / hide 隐藏' },
|
|
416
|
+
sort: { type: 'string', desc: '排序(小的在前)' },
|
|
417
|
+
icon: { type: 'string', desc: '菜单图标(iconify 名,如 mdi:home-outline)' },
|
|
418
|
+
'link-url': { type: 'string', desc: '外链地址(http/https;给了就是"点它直接跳走"的栏目)' },
|
|
419
|
+
description: { type: 'string', desc: '栏目描述(也当前台 SEO 的 description 备选)' },
|
|
420
|
+
'sub-title': { type: 'string', desc: '副标题(下拉菜单第二行小字)' },
|
|
421
|
+
'seo-description': { type: 'string', desc: 'SEO 描述(≤500 字)' },
|
|
422
|
+
'seo-keywords': { type: 'string', desc: 'SEO 关键词(逗号分隔,≤300 字)' },
|
|
423
|
+
'in-menu': { type: 'boolean', desc: '是否显示在前台导航菜单里(默认是;关掉用 --no-in-menu)' },
|
|
424
|
+
featured: { type: 'boolean', desc: '是否推荐(频道页取数用;默认是)' },
|
|
425
|
+
body: { type: 'string', desc: '栏目正文(富文本 HTML)' },
|
|
426
|
+
'body-file': { type: 'string', desc: '从本地文件读栏目正文(与 --body 二选一)' },
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
export const categoriesCreateCommand = {
|
|
430
|
+
name: 'categories create',
|
|
431
|
+
summary: '新建栏目',
|
|
432
|
+
usage: 'wkgeo categories create --name <栏目名> [--parent <上级栏目ID>] [--kind channel|list|page] [其他字段…]',
|
|
433
|
+
flags: {
|
|
434
|
+
name: { type: 'string', required: true, desc: '栏目名(≤100 字)' },
|
|
435
|
+
...CATEGORY_FIELD_FLAGS,
|
|
436
|
+
...SITE_FLAG,
|
|
437
|
+
},
|
|
438
|
+
examples: [
|
|
439
|
+
'wkgeo categories create --name 新闻 --kind list --seo-description "公司新闻动态"',
|
|
440
|
+
'wkgeo categories create --name 公司简介 --kind page --parent <关于我们的ID>',
|
|
441
|
+
'wkgeo categories create --name 产品 --slug product --body-file ./product.html',
|
|
442
|
+
],
|
|
443
|
+
notes: [
|
|
444
|
+
'单页(page)栏目会自动带一条"只能编辑、不能新建也不能删除"的内容;要加一页 = 给它加子栏目。',
|
|
445
|
+
'首页(slug=home)的页面类型会被服务端锁定为 channel。',
|
|
446
|
+
],
|
|
447
|
+
async run(ctx) {
|
|
448
|
+
const { out, flags } = ctx;
|
|
449
|
+
const site = ctx.cmsSite();
|
|
450
|
+
const payload = pickFields(flags, CATEGORY_FIELDS, { name: String(flags.name) });
|
|
451
|
+
const body = resolveBodyFlag(ctx);
|
|
452
|
+
if (body !== undefined) payload.body = body;
|
|
453
|
+
|
|
454
|
+
const one = await ctx.cmsCall('categorySave', payload);
|
|
455
|
+
const data = { site_id: siteIdOf(site), category: one };
|
|
456
|
+
if (!out.json) {
|
|
457
|
+
out.ok(`栏目已创建:${(one && one.name) || payload.name}`);
|
|
458
|
+
out.line(` 栏目 ID:${(one && one.id) || '-'}`);
|
|
459
|
+
out.line(` 路径:${(one && one.path) || '-'}(类型 ${CATEGORY_KIND_LABEL[(one && one.kind) || ''] || (one && one.kind) || '-'})`);
|
|
460
|
+
out.info(' 下一步:`wkgeo contents create --category <栏目ID> --title …`,或 `wkgeo categories bind-template <栏目> index.html`。');
|
|
461
|
+
}
|
|
462
|
+
ctx.done(data);
|
|
463
|
+
return data;
|
|
464
|
+
},
|
|
465
|
+
};
|
|
466
|
+
|
|
467
|
+
export const categoriesUpdateCommand = {
|
|
468
|
+
name: 'categories update',
|
|
469
|
+
summary: '修改栏目(只改给了的字段,其余保持原样)',
|
|
470
|
+
usage: 'wkgeo categories update <栏目ID|slug|path> [--name …] [--status show|hide] [其他字段…]',
|
|
471
|
+
args: [{ name: 'category', required: true, desc: '栏目 ID / slug / path / 栏目名' }],
|
|
472
|
+
flags: {
|
|
473
|
+
/** `name` 单独声明:新建时是**必填**,修改时不能必填("没传的保持原样") */
|
|
474
|
+
name: { type: 'string', desc: '新栏目名(≤100 字)' },
|
|
475
|
+
...CATEGORY_FIELD_FLAGS,
|
|
476
|
+
...SITE_FLAG,
|
|
477
|
+
},
|
|
478
|
+
examples: [
|
|
479
|
+
'wkgeo categories update /news/ --name 新闻中心',
|
|
480
|
+
'wkgeo categories update 8f3c1a2e-… --no-in-menu # 不显示在前台菜单',
|
|
481
|
+
],
|
|
482
|
+
notes: ['"没传的字段保持原样"是服务端口径,所以改名不会顺手把描述清空;要清空请显式传空串(如 --description "")。'],
|
|
483
|
+
async run(ctx) {
|
|
484
|
+
const { out, flags } = ctx;
|
|
485
|
+
const site = ctx.cmsSite();
|
|
486
|
+
const row = await resolveCategory(ctx, ctx.positional.category);
|
|
487
|
+
const payload = pickFields(flags, CATEGORY_FIELDS, { id: String(row.id) });
|
|
488
|
+
const body = resolveBodyFlag(ctx);
|
|
489
|
+
if (body !== undefined) payload.body = body;
|
|
490
|
+
|
|
491
|
+
if (Object.keys(payload).length <= 1) {
|
|
492
|
+
throw new UsageError(
|
|
493
|
+
'没有要改的字段:至少给一个(--name / --slug / --status / --sort / --seo-description / --body …)',
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const one = await ctx.cmsCall('categorySave', payload);
|
|
498
|
+
const data = { site_id: siteIdOf(site), category_id: String(row.id), changed: Object.keys(payload).filter((k) => k !== 'id'), category: one };
|
|
499
|
+
if (!out.json) {
|
|
500
|
+
out.ok(`栏目已更新:${(one && one.name) || row.name}(${data.changed.join(', ')})`);
|
|
501
|
+
out.line(` 栏目 ID:${row.id}`);
|
|
502
|
+
}
|
|
503
|
+
ctx.done(data);
|
|
504
|
+
return data;
|
|
505
|
+
},
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
export const categoriesDeleteCommand = {
|
|
509
|
+
name: 'categories delete',
|
|
510
|
+
summary: '删除栏目(系统栏目 / 还有子栏目或内容时服务端会拒绝)',
|
|
511
|
+
usage: 'wkgeo categories delete <栏目ID|slug|path> [--site <站点ID>]',
|
|
512
|
+
args: [{ name: 'category', required: true, desc: '栏目 ID / slug / path / 栏目名' }],
|
|
513
|
+
flags: { ...SITE_FLAG },
|
|
514
|
+
examples: ['wkgeo categories delete /old/', 'wkgeo categories delete 8f3c1a2e-… --json'],
|
|
515
|
+
notes: [
|
|
516
|
+
'系统预置栏目**只能隐藏不能删**;下面还有子栏目 / 普通内容时也会被拒(先把它们挪走或删掉)。',
|
|
517
|
+
'单页栏目自己的那一页内容会跟着栏目一起删(那是唯一正规的做法)。',
|
|
518
|
+
],
|
|
519
|
+
async run(ctx) {
|
|
520
|
+
const { out } = ctx;
|
|
521
|
+
const site = ctx.cmsSite();
|
|
522
|
+
const row = await resolveCategory(ctx, ctx.positional.category);
|
|
523
|
+
const res = await ctx.cmsCall('categoryDelete', { id: String(row.id) });
|
|
524
|
+
const data = { site_id: siteIdOf(site), id: String(row.id), name: row.name || '', deleted: !!(res && res.deleted) };
|
|
525
|
+
if (!out.json) {
|
|
526
|
+
out.ok(`栏目已删除:${data.name || data.id}`);
|
|
527
|
+
}
|
|
528
|
+
ctx.done(data);
|
|
529
|
+
return data;
|
|
530
|
+
},
|
|
531
|
+
};
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* 栏目绑定模板文件(`categorySave.template_path`)。
|
|
535
|
+
*
|
|
536
|
+
* 为什么单独一条命令:后台最常做的一件事是"把这一栏换成另一套版式",
|
|
537
|
+
* 而 `template_path` 藏在保存接口里 —— 让 AI 为改一个字段去拼整个栏目表单没必要,
|
|
538
|
+
* 也容易把别的字段(描述 / SEO)顺手清掉。
|
|
539
|
+
*/
|
|
540
|
+
export const categoriesBindTemplateCommand = {
|
|
541
|
+
name: 'categories bind-template',
|
|
542
|
+
summary: '给栏目绑定模板文件(不给 <模板文件> + --clear = 解绑,回到"按栏目类型自动选")',
|
|
543
|
+
usage: 'wkgeo categories bind-template <栏目ID|slug|path> <模板文件名>\nwkgeo categories bind-template <栏目> --clear',
|
|
544
|
+
args: [
|
|
545
|
+
{ name: 'category', required: true, desc: '栏目 ID / slug / path / 栏目名' },
|
|
546
|
+
{ name: 'template', required: false, desc: '模板文件名,如 about.html(必须是本站模板里已有的文件)' },
|
|
547
|
+
],
|
|
548
|
+
flags: {
|
|
549
|
+
clear: { type: 'boolean', desc: '解绑(清空 template_path,回到按栏目类型自动选模板)' },
|
|
550
|
+
...SITE_FLAG,
|
|
551
|
+
},
|
|
552
|
+
examples: [
|
|
553
|
+
'wkgeo categories bind-template /about/ about.html',
|
|
554
|
+
'wkgeo categories bind-template 新闻 news-list.html',
|
|
555
|
+
'wkgeo categories bind-template /about/ --clear',
|
|
556
|
+
'wkgeo templates list # 先看本站有哪些 html',
|
|
557
|
+
],
|
|
558
|
+
notes: [
|
|
559
|
+
'模板文件白名单:`字母/数字/中划线/下划线 + .html`;文件不存在时服务端会拒绝 —— 这里先查一次并列出可选文件。',
|
|
560
|
+
'不绑定时按栏目类型自动选:频道页 index.html、单页 about.html、列表页/内容页 content.html。',
|
|
561
|
+
],
|
|
562
|
+
async run(ctx) {
|
|
563
|
+
const { out, flags } = ctx;
|
|
564
|
+
const site = ctx.cmsSite();
|
|
565
|
+
const row = await resolveCategory(ctx, ctx.positional.category);
|
|
566
|
+
const clear = flags.clear === true;
|
|
567
|
+
const wanted = String(ctx.positional.template || '').trim();
|
|
568
|
+
|
|
569
|
+
if (!clear && !wanted) {
|
|
570
|
+
throw new UsageError('缺少参数 <模板文件>(要解绑请加 --clear)\n用法:wkgeo categories bind-template <栏目> <模板文件名>');
|
|
571
|
+
}
|
|
572
|
+
if (clear && wanted) {
|
|
573
|
+
throw new UsageError('--clear 与 <模板文件> 只能给一个(要绑定就写文件名,要解绑就只给 --clear)');
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
let templatePath = '';
|
|
577
|
+
if (!clear) {
|
|
578
|
+
if (!/^[A-Za-z0-9_-]+\.html$/.test(wanted)) {
|
|
579
|
+
throw new UsageError(`模板文件名不合法:${wanted}\n只允许 字母/数字/中划线/下划线 + .html,例如 about.html`);
|
|
580
|
+
}
|
|
581
|
+
/** 先查一次本站模板,把"没有这个文件"变成一句带候选的错误(服务端也会查,但它的报错没有候选列表) */
|
|
582
|
+
const tpl = await ctx.cmsCall('templateGet', {});
|
|
583
|
+
const files = (tpl && Array.isArray(tpl.files) ? tpl.files : []).map((f) => String((f && f.path) || ''));
|
|
584
|
+
if (!files.includes(wanted)) {
|
|
585
|
+
throw new CliError(`本站模板里没有「${wanted}」`, {
|
|
586
|
+
kind: 'not_found',
|
|
587
|
+
code: 404,
|
|
588
|
+
hint: files.length
|
|
589
|
+
? `现有模板文件:${files.join(' / ')};要新建它用 \`wkgeo templates save ${wanted} --file <本地文件>\``
|
|
590
|
+
: '本站还没有模板文件:先 `wkgeo templates save index.html --file <本地文件>`(或先跑 `wkgeo templates list` 让服务端 seed 默认模板)',
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
templatePath = wanted;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const one = await ctx.cmsCall('categorySave', { id: String(row.id), template_path: templatePath });
|
|
597
|
+
const data = {
|
|
598
|
+
site_id: siteIdOf(site),
|
|
599
|
+
category_id: String(row.id),
|
|
600
|
+
category_name: row.name || '',
|
|
601
|
+
template_path: templatePath || null,
|
|
602
|
+
cleared: clear,
|
|
603
|
+
category: one,
|
|
604
|
+
};
|
|
605
|
+
if (!out.json) {
|
|
606
|
+
if (clear) out.ok(`已解绑模板:${row.name || data.category_id}(回到按栏目类型自动选)`);
|
|
607
|
+
else out.ok(`已绑定模板:${row.name || data.category_id} → ${templatePath}`);
|
|
608
|
+
}
|
|
609
|
+
ctx.done(data);
|
|
610
|
+
return data;
|
|
611
|
+
},
|
|
612
|
+
};
|
|
613
|
+
|
|
614
|
+
/* ------------------------------------------------------------------ 内容 */
|
|
615
|
+
|
|
616
|
+
export const contentsListCommand = {
|
|
617
|
+
name: 'contents list',
|
|
618
|
+
summary: '内容列表(可按栏目 / 状态 / 关键词筛;不含正文)',
|
|
619
|
+
usage: 'wkgeo contents list [--category <栏目>] [--status draft|published|hidden] [--keyword 词] [--page 1] [--page-size 20]',
|
|
620
|
+
flags: {
|
|
621
|
+
category: { type: 'string', desc: '只看这个栏目(ID / slug / path / 栏目名)' },
|
|
622
|
+
status: { type: 'string', desc: '状态:draft 草稿 / published 已发布 / hidden 隐藏' },
|
|
623
|
+
keyword: { type: 'string', desc: '标题关键词' },
|
|
624
|
+
page: { type: 'string', desc: '页码(默认 1)' },
|
|
625
|
+
'page-size': { type: 'string', desc: '每页条数(默认 20,最多 100)' },
|
|
626
|
+
sort: { type: 'string', desc: '排序:views_desc 访问量降序 / views_asc 升序(默认置顶→排序→ID)' },
|
|
627
|
+
'include-children': { type: 'boolean', desc: '连下级栏目的内容一起列' },
|
|
628
|
+
'owner-page': { type: 'boolean', desc: '只看"单页栏目那一页"(单页栏目专用)' },
|
|
629
|
+
...SITE_FLAG,
|
|
630
|
+
},
|
|
631
|
+
examples: [
|
|
632
|
+
'wkgeo contents list --status draft',
|
|
633
|
+
'wkgeo contents list --category 新闻 --page-size 50',
|
|
634
|
+
'wkgeo contents list --keyword 展会 --include-children --json',
|
|
635
|
+
],
|
|
636
|
+
async run(ctx) {
|
|
637
|
+
const { out, flags } = ctx;
|
|
638
|
+
const site = ctx.cmsSite();
|
|
639
|
+
const payload = {};
|
|
640
|
+
if (flags.category !== undefined) payload.categoryId = String((await resolveCategory(ctx, flags.category)).id);
|
|
641
|
+
if (flags.status !== undefined) payload.status = String(flags.status);
|
|
642
|
+
if (flags.keyword !== undefined) payload.keyword = String(flags.keyword);
|
|
643
|
+
if (flags.page !== undefined) payload.page = Number(flags.page);
|
|
644
|
+
if (flags.pageSize !== undefined) payload.pageSize = Number(flags.pageSize);
|
|
645
|
+
if (flags.sort !== undefined) payload.sort = String(flags.sort);
|
|
646
|
+
if (flags.includeChildren !== undefined) payload.includeChildren = flags.includeChildren === true;
|
|
647
|
+
if (flags.ownerPage !== undefined) payload.ownerPage = flags.ownerPage === true;
|
|
648
|
+
|
|
649
|
+
const res = await ctx.cmsCall('contentList', payload);
|
|
650
|
+
const rows = (res && Array.isArray(res.rows) ? res.rows : []).map((r) => r || {});
|
|
651
|
+
const data = {
|
|
652
|
+
site_id: siteIdOf(site),
|
|
653
|
+
rows,
|
|
654
|
+
total: (res && res.total) || rows.length,
|
|
655
|
+
page: (res && res.page) || payload.page || 1,
|
|
656
|
+
page_size: (res && res.pageSize) || payload.pageSize || 20,
|
|
657
|
+
};
|
|
658
|
+
|
|
659
|
+
if (!out.json) {
|
|
660
|
+
out.head(`内容列表(共 ${data.total} 条 · 第 ${data.page} 页 ${rows.length} 条 · 站点 ${siteText(site)})`);
|
|
661
|
+
out.table(
|
|
662
|
+
[
|
|
663
|
+
{ title: '标题', key: 'title', width: 34 },
|
|
664
|
+
{ title: '栏目', key: 'category_name', width: 14 },
|
|
665
|
+
{ title: '状态', key: 'status', width: 8, format: (v) => CONTENT_STATUS_LABEL[v] || v || '-' },
|
|
666
|
+
{ title: '更新时间', key: 'updated_at', format: (v) => formatTime(v), width: 20 },
|
|
667
|
+
{ title: '内容 ID', key: 'id', width: 38 },
|
|
668
|
+
],
|
|
669
|
+
rows,
|
|
670
|
+
);
|
|
671
|
+
if (data.total > data.page * data.page_size) {
|
|
672
|
+
out.info(` 下一页:--page ${data.page + 1}`);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
ctx.done(data);
|
|
676
|
+
return data;
|
|
677
|
+
},
|
|
678
|
+
};
|
|
679
|
+
|
|
680
|
+
export const contentsGetCommand = {
|
|
681
|
+
name: 'contents get',
|
|
682
|
+
summary: '单条内容详情(含正文、附件、SEO、自定义字段)',
|
|
683
|
+
usage: 'wkgeo contents get <内容ID> [--site <站点ID>]',
|
|
684
|
+
args: [{ name: 'id', required: true, desc: '内容 ID(uuid)' }],
|
|
685
|
+
flags: { ...SITE_FLAG },
|
|
686
|
+
examples: ['wkgeo contents get 8f3c1a2e-…', 'wkgeo contents get 8f3c1a2e-… --json'],
|
|
687
|
+
async run(ctx) {
|
|
688
|
+
const { out } = ctx;
|
|
689
|
+
const site = ctx.cmsSite();
|
|
690
|
+
const id = String(ctx.positional.id || '').trim();
|
|
691
|
+
if (!looksLikeUuid(id)) {
|
|
692
|
+
throw new UsageError(`内容 ID 应该是 uuid,收到:${id}\n先 'wkgeo contents list' 找到它`);
|
|
693
|
+
}
|
|
694
|
+
const one = await ctx.cmsCall('contentGet', { id });
|
|
695
|
+
const data = { site_id: siteIdOf(site), content: one };
|
|
696
|
+
if (!out.json) {
|
|
697
|
+
out.head(`内容:${one.title || ''}`);
|
|
698
|
+
const ext = (one && one.ext) || {};
|
|
699
|
+
const lines = [
|
|
700
|
+
['内容 ID', one.id],
|
|
701
|
+
['栏目', one.category_name || one.category_id],
|
|
702
|
+
['slug', one.slug],
|
|
703
|
+
['状态', CONTENT_STATUS_LABEL[one.status] || one.status],
|
|
704
|
+
['置顶', one.pinned ? '是' : '否'],
|
|
705
|
+
['访问量', one.views],
|
|
706
|
+
['关键词', one.keywords || ext.keywords || '-'],
|
|
707
|
+
['封面', one.cover_url || ext.cover_asset_id || '-'],
|
|
708
|
+
['更新时间', formatTime(one.updated_at)],
|
|
709
|
+
];
|
|
710
|
+
for (const [k, v] of lines) out.line(` ${k}:${v === undefined || v === null || v === '' ? '-' : v}`);
|
|
711
|
+
if (one.body) {
|
|
712
|
+
out.line(' 正文:');
|
|
713
|
+
out.line(String(one.body));
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
ctx.done(data);
|
|
717
|
+
return data;
|
|
718
|
+
},
|
|
719
|
+
};
|
|
720
|
+
|
|
721
|
+
/** `contentSave` 支持的字段(flag → body 键) */
|
|
722
|
+
const CONTENT_FIELDS = [
|
|
723
|
+
['slug', 'slug', 'str'],
|
|
724
|
+
['status', 'status', 'str'],
|
|
725
|
+
['description', 'description', 'str'],
|
|
726
|
+
['keywords', 'keywords', 'str'],
|
|
727
|
+
['sub-title', 'sub_title', 'str'],
|
|
728
|
+
['cover', 'cover_asset_id', 'str'],
|
|
729
|
+
['video', 'video_asset_id', 'str'],
|
|
730
|
+
['usage', 'usage', 'list'],
|
|
731
|
+
['sort', 'sort', 'num'],
|
|
732
|
+
['pinned', 'pinned', 'bool'],
|
|
733
|
+
['attachments', 'attachments', 'list'],
|
|
734
|
+
];
|
|
735
|
+
|
|
736
|
+
/** 内容命令共用的字段参数 */
|
|
737
|
+
const CONTENT_FIELD_FLAGS = {
|
|
738
|
+
slug: { type: 'string', desc: 'URL 片段(不传由服务端生成)' },
|
|
739
|
+
status: { type: 'string', desc: '状态:draft 草稿(默认)/ published 已发布 / hidden 隐藏' },
|
|
740
|
+
description: { type: 'string', desc: '描述(列表页简介 + SEO description,≤500 字)' },
|
|
741
|
+
keywords: { type: 'string', desc: '关键词(≤300 字)' },
|
|
742
|
+
'sub-title': { type: 'string', desc: '副标题' },
|
|
743
|
+
cover: { type: 'string', desc: '封面素材 ID(先用 `wkgeo assets upload` 传图拿 id)' },
|
|
744
|
+
video: { type: 'string', desc: '视频素材 ID' },
|
|
745
|
+
usage: { type: 'string', desc: '用途/位置,逗号分隔:home 首页、list 列表、featured 推荐' },
|
|
746
|
+
sort: { type: 'string', desc: '排序(小的在前)' },
|
|
747
|
+
pinned: { type: 'boolean', desc: '置顶' },
|
|
748
|
+
attachments: { type: 'string', desc: '多图:素材 ID 逗号分隔(整体替换)' },
|
|
749
|
+
ext: { type: 'string', desc: '自定义字段(JSON 对象,如 {"型号":"X1"};与内置字段同名时以 --ext 为准)' },
|
|
750
|
+
body: { type: 'string', desc: '正文(富文本 HTML)' },
|
|
751
|
+
'body-file': { type: 'string', desc: '从本地文件读正文(与 --body 二选一)' },
|
|
752
|
+
};
|
|
753
|
+
|
|
754
|
+
/** 把内容相关的 flags 收成 `contentSave` 的请求体 */
|
|
755
|
+
function buildContentPayload(ctx, base) {
|
|
756
|
+
const payload = pickFields(ctx.flags, CONTENT_FIELDS, { ...base });
|
|
757
|
+
const body = resolveBodyFlag(ctx);
|
|
758
|
+
if (body !== undefined) payload.body = body;
|
|
759
|
+
if (ctx.flags.ext !== undefined) payload.ext = parseJsonObjectFlag('--ext', ctx.flags.ext);
|
|
760
|
+
return payload;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
export const contentsCreateCommand = {
|
|
764
|
+
name: 'contents create',
|
|
765
|
+
summary: '新建内容(默认草稿;状态给 published 就立刻在前台可见)',
|
|
766
|
+
usage: 'wkgeo contents create --category <栏目> --title <标题> [--body …|--body-file 文件] [--status draft|published]',
|
|
767
|
+
flags: {
|
|
768
|
+
title: { type: 'string', required: true, desc: '标题(≤200 字)' },
|
|
769
|
+
category: { type: 'string', required: true, desc: '所属栏目(ID / slug / path / 栏目名)' },
|
|
770
|
+
...CONTENT_FIELD_FLAGS,
|
|
771
|
+
...SITE_FLAG,
|
|
772
|
+
},
|
|
773
|
+
examples: [
|
|
774
|
+
'wkgeo contents create --category 新闻 --title "公司参展 2026" --status published --body "<p>正文…</p>"',
|
|
775
|
+
'wkgeo contents create --category /news/ --title 新品发布 --body-file ./article.html --cover <素材ID>',
|
|
776
|
+
'wkgeo contents create --category 产品 --title X1 --ext \'{"型号":"X1","价格":"999"}\' --json',
|
|
777
|
+
],
|
|
778
|
+
notes: [
|
|
779
|
+
'「单页」栏目(含它下面的子栏目)**不能新建内容** —— 那是栏目自带的一页,只能 `contents update` 改。',
|
|
780
|
+
'前台是服务端按需渲染:`--status published` 保存即上线,不需要额外的发布步骤(见 `wkgeo publish`)。',
|
|
781
|
+
],
|
|
782
|
+
async run(ctx) {
|
|
783
|
+
const { out, flags } = ctx;
|
|
784
|
+
const site = ctx.cmsSite();
|
|
785
|
+
const category = await resolveCategory(ctx, flags.category);
|
|
786
|
+
const payload = buildContentPayload(ctx, {
|
|
787
|
+
title: String(flags.title),
|
|
788
|
+
category_id: String(category.id),
|
|
789
|
+
});
|
|
790
|
+
const one = await ctx.cmsCall('contentSave', payload);
|
|
791
|
+
const data = { site_id: siteIdOf(site), content: one };
|
|
792
|
+
if (!out.json) {
|
|
793
|
+
out.ok(`内容已保存:${(one && one.title) || payload.title}`);
|
|
794
|
+
out.line(` 内容 ID:${(one && one.id) || '-'}`);
|
|
795
|
+
out.line(` 栏目:${category.name || category.path || category.id}`);
|
|
796
|
+
out.line(` 状态:${CONTENT_STATUS_LABEL[(one && one.status) || payload.status] || (one && one.status) || payload.status || 'draft'}`);
|
|
797
|
+
if ((one && one.status) === 'published') out.info(' 已发布:前台(服务端按需渲染)已经能打开这一页。');
|
|
798
|
+
}
|
|
799
|
+
ctx.done(data);
|
|
800
|
+
return data;
|
|
801
|
+
},
|
|
802
|
+
};
|
|
803
|
+
|
|
804
|
+
export const contentsUpdateCommand = {
|
|
805
|
+
name: 'contents update',
|
|
806
|
+
summary: '修改内容(只改给了的字段,其余保持原样)',
|
|
807
|
+
usage: 'wkgeo contents update <内容ID> [--title …] [--status …] [--body …] [其他字段…]',
|
|
808
|
+
args: [{ name: 'id', required: true, desc: '内容 ID(uuid)' }],
|
|
809
|
+
flags: {
|
|
810
|
+
title: { type: 'string', desc: '新标题(≤200 字)' },
|
|
811
|
+
category: { type: 'string', desc: '换栏目(单页那一页不能搬家,服务端会按归属栏目写)' },
|
|
812
|
+
...CONTENT_FIELD_FLAGS,
|
|
813
|
+
...SITE_FLAG,
|
|
814
|
+
},
|
|
815
|
+
examples: [
|
|
816
|
+
'wkgeo contents update 8f3c1a2e-… --status published',
|
|
817
|
+
'wkgeo contents update 8f3c1a2e-… --title "新标题" --body-file ./v2.html',
|
|
818
|
+
],
|
|
819
|
+
notes: ['服务端口径是"没传的字段保持原样";要清空某字段请显式传空串(如 --description "")。'],
|
|
820
|
+
async run(ctx) {
|
|
821
|
+
const { out, flags } = ctx;
|
|
822
|
+
const site = ctx.cmsSite();
|
|
823
|
+
const id = String(ctx.positional.id || '').trim();
|
|
824
|
+
if (!looksLikeUuid(id)) {
|
|
825
|
+
throw new UsageError(`内容 ID 应该是 uuid,收到:${id}\n先 'wkgeo contents list' 找到它`);
|
|
826
|
+
}
|
|
827
|
+
const base = { id };
|
|
828
|
+
if (flags.title !== undefined) base.title = String(flags.title);
|
|
829
|
+
if (flags.category !== undefined) base.category_id = String((await resolveCategory(ctx, flags.category)).id);
|
|
830
|
+
const payload = buildContentPayload(ctx, base);
|
|
831
|
+
|
|
832
|
+
if (Object.keys(payload).length <= 1) {
|
|
833
|
+
throw new UsageError('没有要改的字段:至少给一个(--title / --status / --body / --description / --ext …)');
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
const one = await ctx.cmsCall('contentSave', payload);
|
|
837
|
+
const data = {
|
|
838
|
+
site_id: siteIdOf(site),
|
|
839
|
+
content_id: id,
|
|
840
|
+
changed: Object.keys(payload).filter((k) => k !== 'id'),
|
|
841
|
+
content: one,
|
|
842
|
+
};
|
|
843
|
+
if (!out.json) {
|
|
844
|
+
out.ok(`内容已更新:${(one && one.title) || id}(${data.changed.join(', ')})`);
|
|
845
|
+
out.line(` 状态:${CONTENT_STATUS_LABEL[(one && one.status) || ''] || (one && one.status) || '-'}`);
|
|
846
|
+
}
|
|
847
|
+
ctx.done(data);
|
|
848
|
+
return data;
|
|
849
|
+
},
|
|
850
|
+
};
|
|
851
|
+
|
|
852
|
+
export const contentsDeleteCommand = {
|
|
853
|
+
name: 'contents delete',
|
|
854
|
+
summary: '删除内容(「单页」那一页删不掉 —— 要去栏目管理删栏目)',
|
|
855
|
+
usage: 'wkgeo contents delete <内容ID> [--site <站点ID>]',
|
|
856
|
+
args: [{ name: 'id', required: true, desc: '内容 ID(uuid)' }],
|
|
857
|
+
flags: { ...SITE_FLAG },
|
|
858
|
+
examples: ['wkgeo contents delete 8f3c1a2e-…', 'wkgeo contents delete 8f3c1a2e-… --json'],
|
|
859
|
+
async run(ctx) {
|
|
860
|
+
const { out } = ctx;
|
|
861
|
+
const site = ctx.cmsSite();
|
|
862
|
+
const id = String(ctx.positional.id || '').trim();
|
|
863
|
+
if (!looksLikeUuid(id)) throw new UsageError(`内容 ID 应该是 uuid,收到:${id}`);
|
|
864
|
+
const res = await ctx.cmsCall('contentDelete', { id });
|
|
865
|
+
const data = { site_id: siteIdOf(site), id, deleted: !!(res && res.deleted) };
|
|
866
|
+
if (!out.json) out.ok(`内容已删除:${id}`);
|
|
867
|
+
ctx.done(data);
|
|
868
|
+
return data;
|
|
869
|
+
},
|
|
870
|
+
};
|
|
871
|
+
|
|
872
|
+
/* ------------------------------------------------------------------ 素材 */
|
|
873
|
+
|
|
874
|
+
export const assetsListCommand = {
|
|
875
|
+
name: 'assets list',
|
|
876
|
+
summary: '素材列表(默认只列图片;可看回收站)',
|
|
877
|
+
usage: 'wkgeo assets list [--kind 图片|视频|文档|PDF|压缩包|""] [--keyword 词] [--status normal|deleted] [--page 1] [--page-size 60]',
|
|
878
|
+
flags: {
|
|
879
|
+
kind: { type: 'string', desc: '类型:图片(默认)/ 视频 / 文档 / PDF / 压缩包;给空串 "" = 全部类型' },
|
|
880
|
+
keyword: { type: 'string', desc: '按名称 / 文件路径搜' },
|
|
881
|
+
status: { type: 'string', desc: 'normal 在用(默认)/ deleted 回收站' },
|
|
882
|
+
sort: { type: 'string', desc: '排序:date_desc(默认)/ date_asc / size_desc / size_asc / name_asc / name_desc' },
|
|
883
|
+
page: { type: 'string', desc: '页码(默认 1)' },
|
|
884
|
+
'page-size': { type: 'string', desc: '每页条数(默认 60,最多 200)' },
|
|
885
|
+
...SITE_FLAG,
|
|
886
|
+
},
|
|
887
|
+
examples: [
|
|
888
|
+
'wkgeo assets list',
|
|
889
|
+
'wkgeo assets list --kind 视频 --keyword 宣传',
|
|
890
|
+
'wkgeo assets list --status deleted',
|
|
891
|
+
'wkgeo assets list --kind "" --page-size 200 --json',
|
|
892
|
+
],
|
|
893
|
+
async run(ctx) {
|
|
894
|
+
const { out, flags } = ctx;
|
|
895
|
+
const site = ctx.cmsSite();
|
|
896
|
+
const payload = {};
|
|
897
|
+
if (flags.kind !== undefined) payload.kind = String(flags.kind);
|
|
898
|
+
if (flags.keyword !== undefined) payload.keyword = String(flags.keyword);
|
|
899
|
+
if (flags.status !== undefined) payload.status = String(flags.status);
|
|
900
|
+
if (flags.sort !== undefined) payload.sort = String(flags.sort);
|
|
901
|
+
if (flags.page !== undefined) payload.page = Number(flags.page);
|
|
902
|
+
if (flags.pageSize !== undefined) payload.pageSize = Number(flags.pageSize);
|
|
903
|
+
|
|
904
|
+
const res = await ctx.cmsCall('assetList', payload);
|
|
905
|
+
const rows = (res && Array.isArray(res.rows) ? res.rows : []).map((r) => r || {});
|
|
906
|
+
const data = {
|
|
907
|
+
site_id: siteIdOf(site),
|
|
908
|
+
rows,
|
|
909
|
+
total: (res && res.total) || rows.length,
|
|
910
|
+
page: (res && res.page) || payload.page || 1,
|
|
911
|
+
page_size: (res && res.pageSize) || payload.pageSize || 60,
|
|
912
|
+
asset_host: (res && res.assetHost) || '',
|
|
913
|
+
kind_counts: (res && res.kindCounts) || [],
|
|
914
|
+
trash_count: res && res.trashCount !== undefined ? res.trashCount : null,
|
|
915
|
+
};
|
|
916
|
+
|
|
917
|
+
if (!out.json) {
|
|
918
|
+
out.head(`素材列表(共 ${data.total} 条 · 第 ${data.page} 页 ${rows.length} 条 · 站点 ${siteText(site)})`);
|
|
919
|
+
out.table(
|
|
920
|
+
[
|
|
921
|
+
{ title: '名称', key: 'name', width: 28 },
|
|
922
|
+
{ title: '类型', key: 'kind', width: 8 },
|
|
923
|
+
{ title: '大小', key: 'size', width: 10, format: (v) => formatBytes(v) },
|
|
924
|
+
{ title: '尺寸', key: 'width', width: 12, format: (v, row) => (row.width && row.height ? `${row.width}×${row.height}` : '-') },
|
|
925
|
+
{ title: '身份ID', key: 'slug', width: 12 },
|
|
926
|
+
{ title: '上传时间', key: 'created_at', format: (v) => formatTime(v), width: 20 },
|
|
927
|
+
{ title: '素材 ID', key: 'id', width: 38 },
|
|
928
|
+
],
|
|
929
|
+
rows,
|
|
930
|
+
);
|
|
931
|
+
if (data.trash_count) out.info(` 回收站里还有 ${data.trash_count} 个(--status deleted 查看)。`);
|
|
932
|
+
if (data.total > data.page * data.page_size) out.info(` 下一页:--page ${data.page + 1}`);
|
|
933
|
+
}
|
|
934
|
+
ctx.done(data);
|
|
935
|
+
return data;
|
|
936
|
+
},
|
|
937
|
+
};
|
|
938
|
+
|
|
939
|
+
/** 服务端单文件上限(见 `tencent/functions/cms` 的 allowedExtensions 表:最大 200MB 的视频/压缩包) */
|
|
940
|
+
const MAX_UPLOAD_BYTES = 200 * 1024 * 1024;
|
|
941
|
+
|
|
942
|
+
export const assetsUploadCommand = {
|
|
943
|
+
name: 'assets upload',
|
|
944
|
+
summary: '上传素材(签名 → 直传 OSS → 登记;CLI 不碰 AK,二进制不进云函数)',
|
|
945
|
+
usage: 'wkgeo assets upload <本地文件> [--name 显示名] [--site <站点ID>]',
|
|
946
|
+
args: [{ name: 'file', required: true, desc: '本地文件路径(图片 / 视频 / 文档 / PDF / 压缩包)' }],
|
|
947
|
+
flags: {
|
|
948
|
+
name: { type: 'string', desc: '素材显示名(默认取文件名)' },
|
|
949
|
+
...SITE_FLAG,
|
|
950
|
+
},
|
|
951
|
+
examples: [
|
|
952
|
+
'wkgeo assets upload ./logo.png',
|
|
953
|
+
'wkgeo assets upload "D:\\图\\展会.jpg" --name 展会现场',
|
|
954
|
+
'wkgeo assets upload ./brochure.pdf --json # 拿 id 去挂内容:contents update <id> --attachments <素材ID>',
|
|
955
|
+
],
|
|
956
|
+
notes: [
|
|
957
|
+
'三步走:`assetSign` 取票据(锁 key/大小,10 分钟)→ 用票据把文件 POST 到 OSS → `assetRegister` 落库并回读校验。',
|
|
958
|
+
'CLI 只做搬运:既不保存也不打印 OSS 的 AccessKey(AK 只在云函数里)。',
|
|
959
|
+
'支持的扩展名与上限由服务端决定(图片 10MB / PDF 50MB / 视频 200MB …)。',
|
|
960
|
+
],
|
|
961
|
+
async run(ctx) {
|
|
962
|
+
const { out, flags } = ctx;
|
|
963
|
+
const site = ctx.cmsSite();
|
|
964
|
+
const filePath = path.resolve(String(ctx.positional.file || ''));
|
|
965
|
+
let stat;
|
|
966
|
+
try {
|
|
967
|
+
stat = fs.statSync(filePath);
|
|
968
|
+
} catch (e) {
|
|
969
|
+
throw new CliError(`读不到文件:${filePath}`, {
|
|
970
|
+
kind: 'io',
|
|
971
|
+
hint: `检查路径是否正确(${(e && e.code) || (e && e.message) || '未知原因'})`,
|
|
972
|
+
});
|
|
973
|
+
}
|
|
974
|
+
if (!stat.isFile()) throw new UsageError(`不是普通文件:${filePath}`);
|
|
975
|
+
if (stat.size <= 0) throw new UsageError(`文件是空的:${filePath}`);
|
|
976
|
+
if (stat.size > MAX_UPLOAD_BYTES) {
|
|
977
|
+
throw new UsageError(`文件 ${formatBytes(stat.size)} 超过服务端单文件上限 ${formatBytes(MAX_UPLOAD_BYTES)}`);
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
const fileName = path.basename(filePath);
|
|
981
|
+
const displayName = String(flags.name || fileName).trim() || fileName;
|
|
982
|
+
|
|
983
|
+
/** ① 取直传票据(fileName 决定 kind / mime,bytes 让服务端提前挡掉超限) */
|
|
984
|
+
const ticket = await ctx.cmsCall('assetSign', { fileName, bytes: stat.size });
|
|
985
|
+
if (!ticket || !ticket.key || !ticket.uploadUrl) {
|
|
986
|
+
throw new CliError('服务端没有返回直传票据(assetSign)', {
|
|
987
|
+
kind: 'bad_response',
|
|
988
|
+
hint: '检查云函数 `cms` 的素材库配置(OSS_ACCESS_KEY_ID / OSS_ACCESS_KEY_SECRET / 桶与地域)',
|
|
989
|
+
});
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
/** ② 直传 OSS(CLI 只拿票据,不持 AK) */
|
|
993
|
+
const buffer = fs.readFileSync(filePath);
|
|
994
|
+
await uploadToOss({ ticket, buffer, fileName, fetchImpl: ctx.fetchImpl });
|
|
995
|
+
|
|
996
|
+
/** ③ 登记落库(服务端会回读一次 OSS,确认文件真的在) */
|
|
997
|
+
const asset = await ctx.cmsCall('assetRegister', { key: ticket.key, name: displayName });
|
|
998
|
+
const data = {
|
|
999
|
+
site_id: siteIdOf(site),
|
|
1000
|
+
asset,
|
|
1001
|
+
key: ticket.key,
|
|
1002
|
+
url: (asset && asset.url) || ticket.url || '',
|
|
1003
|
+
};
|
|
1004
|
+
|
|
1005
|
+
if (!out.json) {
|
|
1006
|
+
out.ok(`素材已上传:${(asset && asset.name) || displayName}`);
|
|
1007
|
+
out.line(` 素材 ID:${(asset && asset.id) || '-'}`);
|
|
1008
|
+
out.line(` 类型:${(asset && asset.kind) || ticket.kind || '-'}(${formatBytes(stat.size)})`);
|
|
1009
|
+
out.line(` 地址:${data.url || '-'}`);
|
|
1010
|
+
out.info(` 引用它:\`wkgeo contents update <内容ID> --cover ${(asset && asset.id) || '<素材ID>'}\``);
|
|
1011
|
+
}
|
|
1012
|
+
ctx.done(data);
|
|
1013
|
+
return data;
|
|
1014
|
+
},
|
|
1015
|
+
};
|
|
1016
|
+
|
|
1017
|
+
/* ------------------------------------------------------------------ 模板 */
|
|
1018
|
+
|
|
1019
|
+
/** 取默认模板(含全部文件源码)—— `templateGet` 不传 templateId 就是默认模板 */
|
|
1020
|
+
async function fetchDefaultTemplate(ctx) {
|
|
1021
|
+
return ctx.cmsCall('templateGet', {});
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
export const templatesListCommand = {
|
|
1025
|
+
name: 'templates list',
|
|
1026
|
+
summary: '模板列表(默认模板 + 每个 html 的字符数)',
|
|
1027
|
+
usage: 'wkgeo templates list [--site <站点ID>]',
|
|
1028
|
+
flags: { ...SITE_FLAG },
|
|
1029
|
+
examples: ['wkgeo templates list', 'wkgeo templates list --json'],
|
|
1030
|
+
notes: [
|
|
1031
|
+
'一个站的模板就是一套 html(index.html / about.html / content.html…),第一次调用时服务端会自动 seed 一套,所以不会"没有模板"。',
|
|
1032
|
+
'要看源码用 `wkgeo templates get <文件名>`;要改/新建用 `wkgeo templates save <文件名> --file <本地文件>`。',
|
|
1033
|
+
],
|
|
1034
|
+
async run(ctx) {
|
|
1035
|
+
const { out } = ctx;
|
|
1036
|
+
const site = ctx.cmsSite();
|
|
1037
|
+
const res = (await ctx.cmsCall('templateList')) || {};
|
|
1038
|
+
const templates = Array.isArray(res.templates) ? res.templates : [];
|
|
1039
|
+
const files = Array.isArray(res.files) ? res.files : [];
|
|
1040
|
+
const data = {
|
|
1041
|
+
site_id: siteIdOf(site),
|
|
1042
|
+
template_id: res.templateId || null,
|
|
1043
|
+
templates,
|
|
1044
|
+
files: files.map((f) => ({ id: f.id, path: f.path, chars: String(f.content || '').length, updated_at: f.updatedAt || null })),
|
|
1045
|
+
auto_files: res.autoFiles || {},
|
|
1046
|
+
};
|
|
1047
|
+
|
|
1048
|
+
if (!out.json) {
|
|
1049
|
+
out.head(`模板(站点 ${siteText(site)})`);
|
|
1050
|
+
out.line(` 默认模板:${(templates.find((t) => t.isDefault) || templates[0] || {}).name || '-'}(id ${data.template_id || '-'})`);
|
|
1051
|
+
out.table(
|
|
1052
|
+
[
|
|
1053
|
+
{ title: '模板文件', key: 'path', width: 24 },
|
|
1054
|
+
{ title: '字符数', key: 'chars', width: 10 },
|
|
1055
|
+
{ title: '更新时间', key: 'updated_at', format: (v) => formatTime(v), width: 20 },
|
|
1056
|
+
{ title: '文件 ID', key: 'id', width: 38 },
|
|
1057
|
+
],
|
|
1058
|
+
data.files,
|
|
1059
|
+
);
|
|
1060
|
+
const auto = Object.entries(data.auto_files || {});
|
|
1061
|
+
if (auto.length) {
|
|
1062
|
+
out.info(` 自动档(栏目没绑模板时按类型选):${auto.map(([k, v]) => `${CATEGORY_KIND_LABEL[k] || k}→${v}`).join('、')}`);
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
ctx.done(data);
|
|
1066
|
+
return data;
|
|
1067
|
+
},
|
|
1068
|
+
};
|
|
1069
|
+
|
|
1070
|
+
export const templatesGetCommand = {
|
|
1071
|
+
name: 'templates get',
|
|
1072
|
+
summary: '看模板文件源码(不给文件名 = 只列文件清单)',
|
|
1073
|
+
usage: 'wkgeo templates get [模板文件名] [--site <站点ID>]',
|
|
1074
|
+
args: [{ name: 'path', required: false, desc: '模板文件名,如 index.html;不给就只列清单' }],
|
|
1075
|
+
flags: { ...SITE_FLAG },
|
|
1076
|
+
examples: [
|
|
1077
|
+
'wkgeo templates get',
|
|
1078
|
+
'wkgeo templates get index.html',
|
|
1079
|
+
'wkgeo templates get about.html > about.html',
|
|
1080
|
+
],
|
|
1081
|
+
notes: [
|
|
1082
|
+
'给了文件名时,**人类可读模式输出的是源码原文**(可以直接 `> 文件` 存下来),不受"非终端自动 JSON"影响;',
|
|
1083
|
+
'`--json` 下仍然是一个标准信封(`data.content` 里是源码)。模板语法见 docs/功能-模板.md。',
|
|
1084
|
+
],
|
|
1085
|
+
async run(ctx) {
|
|
1086
|
+
const { out } = ctx;
|
|
1087
|
+
const site = ctx.cmsSite();
|
|
1088
|
+
const wanted = String(ctx.positional.path || '').trim();
|
|
1089
|
+
const tpl = (await fetchDefaultTemplate(ctx)) || {};
|
|
1090
|
+
const files = Array.isArray(tpl.files) ? tpl.files : [];
|
|
1091
|
+
|
|
1092
|
+
if (!wanted) {
|
|
1093
|
+
const data = {
|
|
1094
|
+
site_id: siteIdOf(site),
|
|
1095
|
+
template: { id: tpl.id || null, name: tpl.name || '', is_default: !!tpl.isDefault },
|
|
1096
|
+
files: files.map((f) => ({ id: f.id, path: f.path, chars: String(f.content || '').length, updated_at: f.updatedAt || null })),
|
|
1097
|
+
};
|
|
1098
|
+
if (!out.json) {
|
|
1099
|
+
out.head(`模板文件(${data.template.name || '默认模板'} · 站点 ${siteText(site)})`);
|
|
1100
|
+
out.table(
|
|
1101
|
+
[
|
|
1102
|
+
{ title: '模板文件', key: 'path', width: 24 },
|
|
1103
|
+
{ title: '字符数', key: 'chars', width: 10 },
|
|
1104
|
+
{ title: '更新时间', key: 'updated_at', format: (v) => formatTime(v), width: 20 },
|
|
1105
|
+
],
|
|
1106
|
+
data.files,
|
|
1107
|
+
);
|
|
1108
|
+
out.info(' 看源码:`wkgeo templates get <文件名>`');
|
|
1109
|
+
}
|
|
1110
|
+
ctx.done(data);
|
|
1111
|
+
return data;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
const hit = files.find((f) => String(f.path || '') === wanted);
|
|
1115
|
+
if (!hit) {
|
|
1116
|
+
throw new CliError(`模板里没有「${wanted}」`, {
|
|
1117
|
+
kind: 'not_found',
|
|
1118
|
+
code: 404,
|
|
1119
|
+
hint: files.length
|
|
1120
|
+
? `现有模板文件:${files.map((f) => f.path).join(' / ')}(新建用 \`wkgeo templates save ${wanted} --file <本地文件>\`)`
|
|
1121
|
+
: '这个站还没有任何模板文件(先跑 `wkgeo templates list` 触发服务端 seed 默认模板)',
|
|
1122
|
+
});
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
const data = {
|
|
1126
|
+
site_id: siteIdOf(site),
|
|
1127
|
+
template: { id: tpl.id || null, name: tpl.name || '', is_default: !!tpl.isDefault },
|
|
1128
|
+
path: hit.path,
|
|
1129
|
+
chars: String(hit.content || '').length,
|
|
1130
|
+
updated_at: hit.updatedAt || null,
|
|
1131
|
+
content: String(hit.content || ''),
|
|
1132
|
+
};
|
|
1133
|
+
if (!out.json) {
|
|
1134
|
+
/** 原文直出(可重定向);不加任何表头,避免污染模板源码 */
|
|
1135
|
+
out.plain(data.content.endsWith('\n') ? data.content : data.content + '\n');
|
|
1136
|
+
} else {
|
|
1137
|
+
ctx.done(data);
|
|
1138
|
+
}
|
|
1139
|
+
return data;
|
|
1140
|
+
},
|
|
1141
|
+
};
|
|
1142
|
+
|
|
1143
|
+
/** 服务端上限:`templateMaxBytes = 400 * 1024`(按**字符**算) */
|
|
1144
|
+
const TEMPLATE_MAX_CHARS = 400 * 1024;
|
|
1145
|
+
|
|
1146
|
+
export const templatesSaveCommand = {
|
|
1147
|
+
name: 'templates save',
|
|
1148
|
+
summary: '新建 / 覆盖一个模板 html(≤40 万字符,写操作会记系统日志)',
|
|
1149
|
+
usage: 'wkgeo templates save <模板文件名.html> (--file <本地文件> | --content <源码>) [--site <站点ID>]',
|
|
1150
|
+
args: [{ name: 'path', required: true, desc: '模板文件名,如 index.html(字母/数字/中划线/下划线 + .html)' }],
|
|
1151
|
+
flags: {
|
|
1152
|
+
file: { type: 'string', desc: '从本地文件读源码' },
|
|
1153
|
+
content: { type: 'string', desc: '直接给源码(与 --file 二选一;适合短模板)' },
|
|
1154
|
+
...SITE_FLAG,
|
|
1155
|
+
},
|
|
1156
|
+
examples: [
|
|
1157
|
+
'wkgeo templates save index.html --file ./index.html',
|
|
1158
|
+
'wkgeo templates save about.html --content "<!doctype html><h1>{{ category.name }}</h1>"',
|
|
1159
|
+
],
|
|
1160
|
+
notes: [
|
|
1161
|
+
'同名文件直接覆盖(服务端 `on conflict do update`);改坏了用 `wkgeo templates get <文件名>` 先取回旧源码备份。',
|
|
1162
|
+
'模板语法与可用变量见 docs/功能-模板.md(`{{ category.name }}` / `{{#each contents}}` 等)。',
|
|
1163
|
+
],
|
|
1164
|
+
async run(ctx) {
|
|
1165
|
+
const { out, flags } = ctx;
|
|
1166
|
+
const site = ctx.cmsSite();
|
|
1167
|
+
const filePath = String(ctx.positional.path || '').trim();
|
|
1168
|
+
if (!/^[A-Za-z0-9_-]+\.html$/.test(filePath)) {
|
|
1169
|
+
throw new UsageError(
|
|
1170
|
+
`模板文件名不合法:${filePath || '(空)'}\n只允许 字母/数字/中划线/下划线 + .html,例如 index.html / news-list.html`,
|
|
1171
|
+
);
|
|
1172
|
+
}
|
|
1173
|
+
const hasFile = flags.file !== undefined;
|
|
1174
|
+
const hasContent = flags.content !== undefined;
|
|
1175
|
+
if (hasFile === hasContent) {
|
|
1176
|
+
throw new UsageError('--file 与 --content 必须且只能给一个\n用法:wkgeo templates save index.html --file ./index.html');
|
|
1177
|
+
}
|
|
1178
|
+
const content = hasFile ? readTextFile('--file', flags.file) : String(flags.content);
|
|
1179
|
+
if (content.length > TEMPLATE_MAX_CHARS) {
|
|
1180
|
+
throw new UsageError(`模板太长了:${content.length} 字符,服务端上限 ${TEMPLATE_MAX_CHARS} 字符`);
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
const saved = await ctx.cmsCall('templateFileSave', { path: filePath, content });
|
|
1184
|
+
const data = {
|
|
1185
|
+
site_id: siteIdOf(site),
|
|
1186
|
+
path: (saved && saved.path) || filePath,
|
|
1187
|
+
file_id: (saved && saved.id) || null,
|
|
1188
|
+
chars: content.length,
|
|
1189
|
+
updated_at: (saved && saved.updatedAt) || null,
|
|
1190
|
+
};
|
|
1191
|
+
if (!out.json) {
|
|
1192
|
+
out.ok(`模板已保存:${data.path}(${data.chars} 字符)`);
|
|
1193
|
+
out.line(` 文件 ID:${data.file_id || '-'}`);
|
|
1194
|
+
if (data.updated_at) out.line(` 更新时间:${formatTime(data.updated_at)}`);
|
|
1195
|
+
out.info(` 让栏目用它:\`wkgeo categories bind-template <栏目> ${data.path}\``);
|
|
1196
|
+
}
|
|
1197
|
+
ctx.done(data);
|
|
1198
|
+
return data;
|
|
1199
|
+
},
|
|
1200
|
+
};
|
|
1201
|
+
|
|
1202
|
+
/* ------------------------------------------------------------------ 发布 */
|
|
1203
|
+
|
|
1204
|
+
/**
|
|
1205
|
+
* `publish` —— **不硬造静态发布**。
|
|
1206
|
+
*
|
|
1207
|
+
* 事实(2026-10-18 复核):P4 的前台是**服务端按需渲染**(`pageRender`),
|
|
1208
|
+
* 内容状态改成 `published` 就已经上线了,**没有"发布"这一步**。
|
|
1209
|
+
* 真正还需要"发布"的是另一件事:**静态化 + 推 OSS/CDN + 客户自有域名**,
|
|
1210
|
+
* 那需要一个"输入站点 ID、一次幂等跑完"的后端动作(还没做)。
|
|
1211
|
+
*
|
|
1212
|
+
* 所以这条命令现在做两件诚实事:
|
|
1213
|
+
* 1. 用 `overview`(只读)把"现在线上是什么状态"报清楚(多少已发布 / 多少草稿);
|
|
1214
|
+
* 2. 明确告诉用户"为什么不需要发布",以及静态发布缺什么 —— 不假装成功。
|
|
1215
|
+
*/
|
|
1216
|
+
export const publishCommand = {
|
|
1217
|
+
name: 'publish',
|
|
1218
|
+
summary: '看发布状态(前台是按需渲染:内容 published 即上线,没有额外的发布步骤)',
|
|
1219
|
+
usage: 'wkgeo publish [--site <站点ID>]',
|
|
1220
|
+
flags: { ...SITE_FLAG },
|
|
1221
|
+
examples: ['wkgeo publish', 'wkgeo publish --json'],
|
|
1222
|
+
notes: [
|
|
1223
|
+
'**不会**做静态化 / 推 CDN:那需要一个"输入站点 ID、一次幂等跑完"的后端动作(尚未实现)。',
|
|
1224
|
+
'想让内容上线:`wkgeo contents update <内容ID> --status published`(保存即生效)。',
|
|
1225
|
+
],
|
|
1226
|
+
async run(ctx) {
|
|
1227
|
+
const { out } = ctx;
|
|
1228
|
+
const site = ctx.cmsSite();
|
|
1229
|
+
const res = (await ctx.cmsCall('overview')) || {};
|
|
1230
|
+
const data = {
|
|
1231
|
+
site_id: siteIdOf(site),
|
|
1232
|
+
site_name: res.siteName || site.label || '',
|
|
1233
|
+
render_mode: 'on-demand',
|
|
1234
|
+
effective_immediately: true,
|
|
1235
|
+
counts: {
|
|
1236
|
+
categories: res.categories || 0,
|
|
1237
|
+
contents: res.contents || 0,
|
|
1238
|
+
published: res.published || 0,
|
|
1239
|
+
drafts: res.drafts || 0,
|
|
1240
|
+
assets: res.assets || 0,
|
|
1241
|
+
},
|
|
1242
|
+
static_publish: {
|
|
1243
|
+
available: false,
|
|
1244
|
+
missing_backend_action: '按站点 ID 幂等跑完的"静态化 + 推 OSS/CDN"动作(规划-Agent与开放接口.md 第 4 条)',
|
|
1245
|
+
hint: '需要静态化 / 绑客户自有域名时,先请后端补这个动作;CLI 侧不硬造。',
|
|
1246
|
+
},
|
|
1247
|
+
message:
|
|
1248
|
+
'本站前台是服务端按需渲染(pageRender):内容状态 = published 就已经上线,不需要额外的发布步骤。' +
|
|
1249
|
+
'静态化与客户自有域名需要后端补一个幂等发布动作,当前 CLI 不做。',
|
|
1250
|
+
};
|
|
1251
|
+
|
|
1252
|
+
if (!out.json) {
|
|
1253
|
+
out.head(`发布状态(站点 ${data.site_name ? `${data.site_name}(${site.id})` : site.id})`);
|
|
1254
|
+
out.line(` 栏目 ${data.counts.categories} 个 | 内容 ${data.counts.contents} 条(已发布 ${data.counts.published} / 草稿 ${data.counts.drafts})| 素材 ${data.counts.assets} 个`);
|
|
1255
|
+
out.line('');
|
|
1256
|
+
out.line(' 渲染方式:服务端按需渲染(pageRender)—— 内容改为 published **即刻生效**,没有"发布"这一步。');
|
|
1257
|
+
out.line(' 所以这条命令只报状态,不会推送任何东西。');
|
|
1258
|
+
out.line('');
|
|
1259
|
+
out.line(' 要让内容上线:wkgeo contents update <内容ID> --status published');
|
|
1260
|
+
out.line(' 静态化 / 推 CDN / 客户自有域名:需要后端补"按站点幂等发布"的动作,目前没有(CLI 不硬造)。');
|
|
1261
|
+
}
|
|
1262
|
+
ctx.done(data);
|
|
1263
|
+
return data;
|
|
1264
|
+
},
|
|
1265
|
+
};
|