@xqyz/xq-cli 0.3.4 → 0.3.6
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/CHANGELOG.md +91 -0
- package/package.json +1 -1
- package/src/cli.mjs +281 -9
- package/src/task-safety.mjs +127 -0
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,97 @@
|
|
|
10
10
|
|
|
11
11
|
## [Unreleased]
|
|
12
12
|
|
|
13
|
+
### 正文阶段安全闸门复核
|
|
14
|
+
|
|
15
|
+
- 大纲和目录实际保存前强制读取喜鹊后台权威任务状态,不再因为本机没有任务缓存而绕过“正文已开始/已完成”保护。
|
|
16
|
+
- 状态校验放在 `--dry-run` 和用户确认之后、写接口之前,保留离线差异预览能力,同时覆盖确认后发生的并发状态变化。
|
|
17
|
+
- 新增回归测试:无本地缓存的已完成任务不会调用大纲或目录保存接口;纯预览不会额外依赖任务详情接口。
|
|
18
|
+
- 该修复当前属于未发布改动;npm 上的 `@xqyz/xq-cli@0.3.5` 和已发布 WorkBuddy Plugin `0.10.14` 尚未包含它,发布前必须递增 xq-cli 补丁版本并同步 Plugin 依赖。
|
|
19
|
+
|
|
20
|
+
### WorkBuddy Plugin 0.10.14 / SkillHub 1.0.11 发布记录
|
|
21
|
+
|
|
22
|
+
- 安装器不再把“`mcp.json` 已写入”误报为已升级:会固定写入 `0.10.14`,启动同一版本 MCP 完成 `initialize` 烟囱验证;验证失败会恢复原配置并输出 Node/npm 与 MCP 错误。
|
|
23
|
+
- SkillHub 更新规则明确区分“Skill 已复制”和“WorkBuddy 已加载”:旧 Plugin、旧 xq-cli、缺失版本或 MCP 启动失败都必须执行升级,完全重启 WorkBuddy 后再次核对实际 `pluginVersion`、`cliVersion` 和登录状态。
|
|
24
|
+
- macOS/Linux 安装路径改用兼容 BSD/macOS 的版本选择逻辑,去除 `sort -V` 和 npx 参数分隔符依赖,统一使用 `--yes --package <包> xique-workbuddy-mcp`;MCP 启动超时提高到 180 秒。
|
|
25
|
+
- Windows MCP 启动验证会正确引用带空格的 `npx.cmd` / `*.bat` 路径,系统 Node 位于 `C:\Program Files` 时也能完成握手。
|
|
26
|
+
- Connector、插件市场清单、SkillHub Skill 和安装脚本统一指向 WorkBuddy Plugin `0.10.14`;xq-cli 继续固定 `0.3.5`。
|
|
27
|
+
- `@xqyz/workbuddy-plugin-xique@0.10.14` 已发布到 npm,`latest` 已指向 `0.10.14`;已生成 `output/skillhub-xique-bid-1.0.11.zip`,SkillHub 仍待手动上传。
|
|
28
|
+
|
|
29
|
+
### WorkBuddy Plugin 0.10.13 / SkillHub 1.0.10 发布记录
|
|
30
|
+
|
|
31
|
+
- 图片设置分组默认展开,用户打开配置卡即可选择图片来源和流程图样式;图片尺寸、页边距仍默认收起。
|
|
32
|
+
- Plugin、Connector、SkillHub Skill 和本机 WorkBuddy Skill 已同步到本轮版本基线。
|
|
33
|
+
- npm Plugin 已发布到 `latest`;SkillHub 上传包只在本机生成,不通过浏览器代发布,由发布人手动上传生成的 zip。
|
|
34
|
+
|
|
35
|
+
### WorkBuddy 旧版本升级校验
|
|
36
|
+
|
|
37
|
+
- `xique_runtime_status` 新增实际加载的 `pluginVersion`,用于区分“配置文件已写入”和“WorkBuddy 已加载新 MCP”。
|
|
38
|
+
- SkillHub 更新时检测到旧版 Plugin 会执行固定版本升级;普通生成请求不会静默修改本机环境。
|
|
39
|
+
- 安装后必须完全重启 WorkBuddy,并复核 `pluginVersion`、`cliVersion`、运行状态和登录状态,未通过复核不得报告安装成功。
|
|
40
|
+
- 增加旧 `0.10.11` 配置升级到 `0.10.12` 的回归测试,保留其他 MCP、环境变量和备份文件。
|
|
41
|
+
|
|
42
|
+
### SkillHub 1.0.9 上传包准备
|
|
43
|
+
|
|
44
|
+
- 保持既有 Skill slug `xique-bid` 和显示名称“喜鹊标书”,不创建第二个同功能 Skill。
|
|
45
|
+
- 上传内容同步旧 Plugin 版本探测、升级授权、重启后版本复核和失败停止重试规则。
|
|
46
|
+
- 发布前必须确认 npm 已存在 `@xqyz/workbuddy-plugin-xique@0.10.12`,再上传 SkillHub `1.0.9`。
|
|
47
|
+
|
|
48
|
+
### WorkBuddy 配置卡布局优化
|
|
49
|
+
|
|
50
|
+
- 图片设置(来源、流程图样式和图片尺寸)与页边距改为默认收起的分组;默认值继续随配置提交,用户只有需要自定义排版时才展开。
|
|
51
|
+
- 模板5的编号起始值和完整编号规则紧跟导出模板显示,选择模板5后自动展开,避免用户误以为是无关的后置导出参数。
|
|
52
|
+
- MCP App 调整分组卡片、响应式网格和标题层级,减少长页面中的重复边框并改善窄屏可读性。
|
|
53
|
+
- 增加配置分组、排序和默认收起状态的回归测试。
|
|
54
|
+
|
|
55
|
+
### SkillHub 1.0.8 上传包准备
|
|
56
|
+
|
|
57
|
+
- 已生成 `output/skillhub-xique-bid-1.0.8.zip`,保持既有 Skill slug `xique-bid` 和显示名称“喜鹊标书”。
|
|
58
|
+
- 上传包同步本轮折叠分组、模板5编号紧邻展开和布局优化规则;正式发布前必须先发布 npm Plugin `0.10.12`。
|
|
59
|
+
|
|
60
|
+
### WorkBuddy 配置卡补齐导出设置
|
|
61
|
+
|
|
62
|
+
- 首次“喜鹊标书配置”卡增加图片来源、页边距和图片尺寸字段,默认值与喜鹊前端一致,提交后会随导出配置保存。
|
|
63
|
+
- 选择“模板5|自定义编号”后动态展开完整可视化编号设置:五级标题的编号格式、目录级别、字体、字号、对齐、加粗、分页/换行,正文四级编号及正文样式、起始编号。
|
|
64
|
+
- 模板1-4 不显示也不要求填写自定义编号字段;模板5生成的结构化配置继续通过 `customNumbering` 传递,不要求用户输入 JSON。
|
|
65
|
+
- 增加配置卡字段、模板切换显隐、默认值和完整配置数量回归测试。
|
|
66
|
+
|
|
67
|
+
### WorkBuddy 配置卡稳定性修复
|
|
68
|
+
|
|
69
|
+
- `xique_generation_status` 在解析完成进入 `bid-config` 或旧任务 `special-config` 时只返回权威阶段状态,不再直接返回没有 MCP App 元数据的配置卡结果。
|
|
70
|
+
- Skill 会在状态进入配置阶段后立即显式调用带 UI 元数据的 `xique_bid_configuration` / `xique_special_project_selection`,避免 WorkBuddy 先显示普通文字、用户追问后才出现卡片。
|
|
71
|
+
- 增加回归测试,确认同一 `runId` 下真实多标包仍能在显式配置卡中正常展示。
|
|
72
|
+
|
|
73
|
+
## [0.3.5 / WorkBuddy Plugin 0.10.12] - 2026-08-20(npm 已发布)
|
|
74
|
+
|
|
75
|
+
### 配置卡首次渲染修复
|
|
76
|
+
|
|
77
|
+
- `xique_generation_status` 进入 `bid-config` 或旧任务 `special-config` 时只返回权威状态,不再直接返回没有 MCP App UI 元数据的 `selectionUi`。
|
|
78
|
+
- Skill、命令和 Connector 指引改为在状态进入配置阶段后立即显式调用带 UI 元数据的配置工具,避免 WorkBuddy 首次只显示状态文本、用户追问后才出现配置卡。
|
|
79
|
+
- 新增回归测试验证状态查询不产生卡片,但显式 `xique_bid_configuration` 仍能展示同一解析任务的真实多标包信息。
|
|
80
|
+
- `xique_runtime_status` 返回实际加载的 `pluginVersion`;旧版本升级必须在重启 WorkBuddy 后复核版本、CLI 和登录状态。
|
|
81
|
+
- 已发布 `@xqyz/workbuddy-plugin-xique@0.10.12`,npm `latest` 已指向该版本。
|
|
82
|
+
|
|
83
|
+
## [0.3.5 / WorkBuddy Plugin 0.10.11] - 2026-08-20(待发布)
|
|
84
|
+
|
|
85
|
+
### 文件解析与多标包统一配置
|
|
86
|
+
|
|
87
|
+
- 修复 `xq-cli init --wait` 未识别 Web 端 `success=2`(解析完成、进入标书设置)的错误,避免 WorkBuddy 在文件已经解析完成后仍反复查询并最终超时。
|
|
88
|
+
- WorkBuddy 新任务改为先调用一次 `xique_prepare_source`,解析完成后在同一配置页展示后台真实返回的多标包 / EPC 选项和全部普通配置。
|
|
89
|
+
- 用户提交配置后复用原 `runId`、`cid` 和 `uuid` 启动大纲,不再重复上传文件、重复创建任务或二次选择标包。
|
|
90
|
+
- 普通文件不显示多标包 / EPC 占位字段;提交的标包和 EPC 值必须来自本次解析结果,禁止手工构造 ID。
|
|
91
|
+
|
|
92
|
+
### 暗标生成方式
|
|
93
|
+
|
|
94
|
+
- xq-cli 新增 `--blind-generation-mode normal|blind`,并保留 `--anonymous-bid` 兼容别名。
|
|
95
|
+
- WorkBuddy 配置页新增“按通用方式生成 / 按暗标生成”,默认通用方式;选择会真实写入大纲请求的 `blindBidConfirm`,不再只停留在界面字段。
|
|
96
|
+
- 新增回归测试覆盖 `success=2`、真实多标包统一配置、同一任务复用、重复提交拦截和暗标请求体。
|
|
97
|
+
|
|
98
|
+
### 导出格式二次设置
|
|
99
|
+
|
|
100
|
+
- xq-cli 导出新增 `imageSizeConfig`,支持横向/纵向图片宽度和最大高度四项厘米参数,并与页边距一起写入 `/c/downWithStyle`。
|
|
101
|
+
- WorkBuddy 新增 `xique_export_configuration` 二次导出设置卡:正文完成后可视化设置标题/正文编号、字体、对齐、起始编号、页边距和图片尺寸,再经确认重新下载,不会重新生成任务。
|
|
102
|
+
- 模板5不再在 WorkBuddy 中禁用;编号设置由卡片生成结构化 `customNumbering`,不要求用户填写高级样式 JSON。
|
|
103
|
+
|
|
13
104
|
## [0.3.4 / WorkBuddy Plugin 0.10.10] - 2026-08-19
|
|
14
105
|
|
|
15
106
|
### 快速模式新任务生成链路修复
|
package/package.json
CHANGED
package/src/cli.mjs
CHANGED
|
@@ -23,6 +23,11 @@ import {
|
|
|
23
23
|
buildDirectoryViewDocument,
|
|
24
24
|
formatDirectoryLines,
|
|
25
25
|
} from './directory-view.mjs';
|
|
26
|
+
import {
|
|
27
|
+
assertContentCompleted,
|
|
28
|
+
assertContentGenerationAllowed,
|
|
29
|
+
assertPreContentMutationAllowed,
|
|
30
|
+
} from './task-safety.mjs';
|
|
26
31
|
|
|
27
32
|
const APP_DIR = path.join(os.homedir(), '.xq-opencli');
|
|
28
33
|
const CONFIG_FILE = path.join(APP_DIR, 'config.json');
|
|
@@ -127,6 +132,12 @@ const DEFAULT_PAGE_MARGIN_SETTING = {
|
|
|
127
132
|
leftMargin: 3.18,
|
|
128
133
|
rightMargin: 3.18,
|
|
129
134
|
};
|
|
135
|
+
const DEFAULT_IMAGE_SIZE_CONFIG = {
|
|
136
|
+
landscapeWidth: 10.58,
|
|
137
|
+
landscapeMaxHeight: 8,
|
|
138
|
+
portraitWidth: 5.5,
|
|
139
|
+
portraitMaxHeight: 8,
|
|
140
|
+
};
|
|
130
141
|
const OUTLINE_REFERENCE_OPTIONS = [
|
|
131
142
|
{
|
|
132
143
|
value: 0,
|
|
@@ -205,6 +216,10 @@ const PLAN_MODE_OPTIONS = [
|
|
|
205
216
|
{ value: 0, label: 'quick', description: 'quick mode', aliases: ['0', 'quick'] },
|
|
206
217
|
{ value: 1, label: 'planning', description: 'planning mode', aliases: ['1', 'planning', 'plan'] },
|
|
207
218
|
];
|
|
219
|
+
const BLIND_GENERATION_MODE_OPTIONS = [
|
|
220
|
+
{ value: 'normal', label: 'normal', description: 'general bid generation', aliases: ['normal', 'general', '0', 'open'] },
|
|
221
|
+
{ value: 'blind', label: 'blind', description: 'blind-bid protected generation', aliases: ['blind', 'anonymous', '1'] },
|
|
222
|
+
];
|
|
208
223
|
const PLAN_MODE_PHASE_LABELS = {
|
|
209
224
|
0: 'analysis',
|
|
210
225
|
1: 'outline',
|
|
@@ -238,6 +253,12 @@ const PAGE_MARGIN_FIELDS = [
|
|
|
238
253
|
{ key: 'leftMargin', optionKeys: ['leftMargin', 'marginLeft'] },
|
|
239
254
|
{ key: 'rightMargin', optionKeys: ['rightMargin', 'marginRight'] },
|
|
240
255
|
];
|
|
256
|
+
const IMAGE_SIZE_FIELDS = [
|
|
257
|
+
{ key: 'landscapeWidth', optionKeys: ['landscapeWidth', 'imageLandscapeWidth'] },
|
|
258
|
+
{ key: 'landscapeMaxHeight', optionKeys: ['landscapeMaxHeight', 'imageLandscapeMaxHeight'] },
|
|
259
|
+
{ key: 'portraitWidth', optionKeys: ['portraitWidth', 'imagePortraitWidth'] },
|
|
260
|
+
{ key: 'portraitMaxHeight', optionKeys: ['portraitMaxHeight', 'imagePortraitMaxHeight'] },
|
|
261
|
+
];
|
|
241
262
|
const EXPORT_TEMPLATE_OPTIONS = templateData.map((template, index) => ({
|
|
242
263
|
value: index + 1,
|
|
243
264
|
label: `template-${index + 1}`,
|
|
@@ -514,13 +535,16 @@ Outline config
|
|
|
514
535
|
--knowledge-img <0|1> knowledge-base image toggle
|
|
515
536
|
--mermaid-style <1|2|3|4|5|6|gray|purple|blue|green|orange|red>
|
|
516
537
|
--plan-mode <0|1|quick|planning>
|
|
538
|
+
--blind-generation-mode <normal|blind> Bid body generation mode; default normal
|
|
539
|
+
--anonymous-bid <0|1> Deprecated alias of --blind-generation-mode
|
|
517
540
|
--multi-bid-id <id> override the selected bid package when needed
|
|
518
541
|
--multi-bid-type <text> persist the selected bid package type
|
|
519
542
|
--epc-engineer-type <text> accepts raw backend value or aliases: all, construction, design, not-epc
|
|
520
543
|
|
|
521
544
|
Export style
|
|
522
545
|
--template <1-${templateData.length}> Same template order as the frontend download drawer
|
|
523
|
-
--style-json <file>
|
|
546
|
+
--style-json <file> Export style JSON file (CLI/automation compatibility)
|
|
547
|
+
--style-json-data <json> Structured export style payload (automation; no user file required)
|
|
524
548
|
--start-index <n> Override the template heading numbering start index
|
|
525
549
|
--auto-number <0|1> Toggle Word auto numbering
|
|
526
550
|
--add-mark <0|1> Add suggest-modify marks
|
|
@@ -529,6 +553,10 @@ Export style
|
|
|
529
553
|
--bottom-margin <cm> Page bottom margin
|
|
530
554
|
--left-margin <cm> Page left margin
|
|
531
555
|
--right-margin <cm> Page right margin
|
|
556
|
+
--landscape-width <cm> Landscape image fixed width
|
|
557
|
+
--landscape-max-height <cm> Landscape image maximum height
|
|
558
|
+
--portrait-width <cm> Portrait image fixed width
|
|
559
|
+
--portrait-max-height <cm> Portrait image maximum height
|
|
532
560
|
--skeleton-color <hex> Alias of --table-bolder-color-of-skeleton
|
|
533
561
|
--img-title-color <hex>
|
|
534
562
|
--heading4-color <hex>
|
|
@@ -793,6 +821,8 @@ async function handlePeriodStatus(args, state) {
|
|
|
793
821
|
async function handlePeriodConfirm(args, state) {
|
|
794
822
|
const client = createAuthorizedClient(args, state);
|
|
795
823
|
const cid = resolveCid(args, state);
|
|
824
|
+
const authoritativeState = await fetchAuthoritativeTaskState(client, cid);
|
|
825
|
+
assertPreContentMutationAllowed(authoritativeState, '确认工期');
|
|
796
826
|
const contractPeriod = numberOption(args, 'contractPeriod');
|
|
797
827
|
if (!Number.isInteger(contractPeriod) || contractPeriod <= 0) {
|
|
798
828
|
throw new Error('Option --contract-period expects a positive integer.');
|
|
@@ -916,6 +946,16 @@ async function handleWizard(args, state) {
|
|
|
916
946
|
}
|
|
917
947
|
|
|
918
948
|
async function handlePlan(args, state) {
|
|
949
|
+
const action = String(args?._?.[1] || 'status').trim().toLowerCase();
|
|
950
|
+
const readOnly = new Set(['status', 'show', 'outline-view', 'directory-view']);
|
|
951
|
+
if (!readOnly.has(action)) {
|
|
952
|
+
const cid = resolveCid(args, state);
|
|
953
|
+
return withGenerationLock(cid, 'generation', () => handlePlanUnlocked(args, state));
|
|
954
|
+
}
|
|
955
|
+
return handlePlanUnlocked(args, state);
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
async function handlePlanUnlocked(args, state) {
|
|
919
959
|
const action = String(args?._?.[1] || 'status').trim().toLowerCase();
|
|
920
960
|
switch (action) {
|
|
921
961
|
case 'status':
|
|
@@ -1058,6 +1098,8 @@ async function handlePlanFeedback(args, state) {
|
|
|
1058
1098
|
async function handlePlanReferenceSelection(args, state) {
|
|
1059
1099
|
const client = createAuthorizedClient(args, state);
|
|
1060
1100
|
const cid = resolveCid(args, state);
|
|
1101
|
+
const authoritativeState = await fetchAuthoritativeTaskState(client, cid);
|
|
1102
|
+
assertPreContentMutationAllowed(authoritativeState, '提交响应依据');
|
|
1061
1103
|
const referenceType = parseOutlineReferenceType(stringOption(args, 'referenceType'));
|
|
1062
1104
|
if (referenceType === null) {
|
|
1063
1105
|
throw new Error('plan reference-select requires --reference-type 0, 1, 2, merge, format, or score.');
|
|
@@ -1117,6 +1159,8 @@ async function handlePlanReferenceSelection(args, state) {
|
|
|
1117
1159
|
async function handlePlanOutlineConfirmation(args, state) {
|
|
1118
1160
|
const client = createAuthorizedClient(args, state);
|
|
1119
1161
|
const cid = resolveCid(args, state);
|
|
1162
|
+
const authoritativeState = await fetchAuthoritativeTaskState(client, cid);
|
|
1163
|
+
assertPreContentMutationAllowed(authoritativeState, '确认或重新生成大纲');
|
|
1120
1164
|
const snapshot = await hydrateTaskSnapshot(client, state, cid);
|
|
1121
1165
|
const params = buildOutlineParams(snapshot, { ...args, planMode: 'planning' });
|
|
1122
1166
|
const regenerating = booleanOption(args, 'regenerate') || normalizePlanModePhase(snapshot.plan_mode_phase) >= 1;
|
|
@@ -1172,6 +1216,8 @@ async function handlePlanOutlineConfirmation(args, state) {
|
|
|
1172
1216
|
async function handlePlanAiOutlineAdjust(args, state) {
|
|
1173
1217
|
const client = createAuthorizedClient(args, state);
|
|
1174
1218
|
const cid = resolveCid(args, state);
|
|
1219
|
+
const authoritativeState = await fetchAuthoritativeTaskState(client, cid);
|
|
1220
|
+
assertPreContentMutationAllowed(authoritativeState, '请求 AI 调整大纲');
|
|
1175
1221
|
const prompt = requiredOption(args, 'prompt').trim();
|
|
1176
1222
|
if (!prompt) {
|
|
1177
1223
|
throw new Error('Option --prompt cannot be empty.');
|
|
@@ -1256,6 +1302,8 @@ async function handlePlanAiOutlineApply(args, state) {
|
|
|
1256
1302
|
async function handlePlanDirectoryGenerate(args, state, regenerating) {
|
|
1257
1303
|
const client = createAuthorizedClient(args, state);
|
|
1258
1304
|
const cid = resolveCid(args, state);
|
|
1305
|
+
const authoritativeState = await fetchAuthoritativeTaskState(client, cid);
|
|
1306
|
+
assertPreContentMutationAllowed(authoritativeState, regenerating ? '重新生成目录' : '生成目录');
|
|
1259
1307
|
const snapshot = await hydrateTaskSnapshot(client, state, cid);
|
|
1260
1308
|
ensurePlanningTask(snapshot);
|
|
1261
1309
|
const currentPhase = normalizePlanModePhase(snapshot.plan_mode_phase);
|
|
@@ -1319,9 +1367,16 @@ async function handlePlanDirectoryGenerate(args, state, regenerating) {
|
|
|
1319
1367
|
}
|
|
1320
1368
|
|
|
1321
1369
|
async function handleOutline(args, state) {
|
|
1370
|
+
const cid = resolveCid(args, state);
|
|
1371
|
+
return withGenerationLock(cid, 'generation', () => handleOutlineUnlocked(args, state));
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
async function handleOutlineUnlocked(args, state) {
|
|
1322
1375
|
validateOutlineReferenceTypeOption(args);
|
|
1323
1376
|
const client = createAuthorizedClient(args, state);
|
|
1324
1377
|
const cid = resolveCid(args, state);
|
|
1378
|
+
const authoritativeState = await fetchAuthoritativeTaskState(client, cid);
|
|
1379
|
+
assertPreContentMutationAllowed(authoritativeState, '重新生成大纲');
|
|
1325
1380
|
const snapshot = await hydrateTaskSnapshot(client, state, cid);
|
|
1326
1381
|
const outlineSnapshot = {
|
|
1327
1382
|
...snapshot,
|
|
@@ -1513,6 +1568,11 @@ async function handleOutlineUpdate(args, state) {
|
|
|
1513
1568
|
return resultPayload;
|
|
1514
1569
|
}
|
|
1515
1570
|
|
|
1571
|
+
// Re-check the backend immediately before the write. Do not rely on the
|
|
1572
|
+
// local task cache: a fresh machine may have no snapshot, and another
|
|
1573
|
+
// process may have started body generation after the preview was read.
|
|
1574
|
+
const authoritativeState = await fetchAuthoritativeTaskState(client, cid);
|
|
1575
|
+
assertPreContentMutationAllowed(authoritativeState, '修改大纲');
|
|
1516
1576
|
const response = await postJson(client, '/c/updateOutLine', requestData);
|
|
1517
1577
|
saveTaskSnapshot(state, cid, {
|
|
1518
1578
|
outlineUpdatedAt: new Date().toISOString(),
|
|
@@ -1621,6 +1681,10 @@ async function handleDirectoryUpdate(args, state) {
|
|
|
1621
1681
|
outputResult(args, { ...resultPayload, reason: 'cancelled' }, [`directory update cancelled: cid=${cid}`]);
|
|
1622
1682
|
return { ...resultPayload, reason: 'cancelled' };
|
|
1623
1683
|
}
|
|
1684
|
+
// The local state file is only a cache. Always use the backend status for
|
|
1685
|
+
// the final mutation so completed/in-flight content cannot be overwritten.
|
|
1686
|
+
const authoritativeState = await fetchAuthoritativeTaskState(client, cid);
|
|
1687
|
+
assertPreContentMutationAllowed(authoritativeState, '修改目录');
|
|
1624
1688
|
const response = await postJson(client, '/task/updateDirectory', requestPreview);
|
|
1625
1689
|
const payload = { ...resultPayload, applied: true, response };
|
|
1626
1690
|
saveTaskSnapshot(state, cid, { directoryUpdatedAt: new Date().toISOString() });
|
|
@@ -1690,8 +1754,17 @@ function writeDirectoryViewFile(outputOption, cid, document) {
|
|
|
1690
1754
|
}
|
|
1691
1755
|
|
|
1692
1756
|
async function handleWrite(args, state) {
|
|
1757
|
+
const cid = resolveCid(args, state);
|
|
1758
|
+
return withGenerationLock(cid, 'generation', () => handleWriteUnlocked(args, state));
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
async function handleWriteUnlocked(args, state) {
|
|
1693
1762
|
const client = createAuthorizedClient(args, state);
|
|
1694
1763
|
const cid = resolveCid(args, state);
|
|
1764
|
+
// Check before directory polling so a completed/in-flight task cannot
|
|
1765
|
+
// trigger a sequel request while a repeated write is being rejected.
|
|
1766
|
+
const earlyTaskState = await fetchAuthoritativeTaskState(client, cid);
|
|
1767
|
+
assertContentGenerationAllowed(earlyTaskState, '生成正文');
|
|
1695
1768
|
const type = numberOption(args, 'type', 1);
|
|
1696
1769
|
const directoryWaitResult = await ensureDirectoryReadyForWrite(client, state, cid, args);
|
|
1697
1770
|
|
|
@@ -1715,6 +1788,7 @@ async function handleWrite(args, state) {
|
|
|
1715
1788
|
const contentDetail = {
|
|
1716
1789
|
data: directoryWaitResult.contentDetail,
|
|
1717
1790
|
};
|
|
1791
|
+
assertContentGenerationAllowed(contentDetail.data, '生成正文');
|
|
1718
1792
|
saveTaskSnapshot(state, cid, pickTaskSnapshot(contentDetail.data));
|
|
1719
1793
|
|
|
1720
1794
|
const writePreparation = await prepareContentGeneration(client, state, cid, contentDetail, args);
|
|
@@ -1732,6 +1806,17 @@ async function handleWrite(args, state) {
|
|
|
1732
1806
|
if (booleanOption(args, 'wait')) {
|
|
1733
1807
|
status = await pollContentUntilReady(client, state, cid, args);
|
|
1734
1808
|
}
|
|
1809
|
+
if (status && status.task_status !== undefined) {
|
|
1810
|
+
try {
|
|
1811
|
+
assertContentCompleted(status, '确认正文完成');
|
|
1812
|
+
saveTaskSnapshot(state, cid, {
|
|
1813
|
+
contentGenerationCompletedAt: new Date().toISOString(),
|
|
1814
|
+
});
|
|
1815
|
+
} catch {
|
|
1816
|
+
// The poller is the authoritative completion check; do not turn a
|
|
1817
|
+
// successful backend response into a local-only failure marker.
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1735
1820
|
|
|
1736
1821
|
const resultPayload = {
|
|
1737
1822
|
command: 'write',
|
|
@@ -1800,7 +1885,14 @@ async function handleStatus(args, state) {
|
|
|
1800
1885
|
async function handleExport(args, state) {
|
|
1801
1886
|
const client = createAuthorizedClient(args, state);
|
|
1802
1887
|
const cid = resolveCid(args, state);
|
|
1803
|
-
const
|
|
1888
|
+
const cachedSnapshot = await hydrateTaskSnapshot(client, state, cid);
|
|
1889
|
+
const authoritativeState = await fetchAuthoritativeTaskState(client, cid, { content: true });
|
|
1890
|
+
assertContentCompleted(authoritativeState, '导出');
|
|
1891
|
+
const snapshot = {
|
|
1892
|
+
...cachedSnapshot,
|
|
1893
|
+
...pickTaskSnapshot(authoritativeState),
|
|
1894
|
+
};
|
|
1895
|
+
saveTaskSnapshot(state, cid, snapshot);
|
|
1804
1896
|
const effectiveArgs = booleanOption(args, 'interactive')
|
|
1805
1897
|
? await collectExportInteractiveArgs(args, snapshot)
|
|
1806
1898
|
: args;
|
|
@@ -1816,6 +1908,7 @@ async function handleExport(args, state) {
|
|
|
1816
1908
|
const tableStyle = enumOption(effectiveArgs, 'tableStyle', TABLE_STYLE_OPTIONS, snapshot.tableStyle ?? DEFAULT_TABLE_STYLE);
|
|
1817
1909
|
const themeStyleSetting = buildThemeStyleSetting(snapshot, effectiveArgs);
|
|
1818
1910
|
const pageMarginSetting = buildPageMarginSetting(snapshot, effectiveArgs);
|
|
1911
|
+
const imageSizeConfig = buildImageSizeConfig(snapshot, effectiveArgs);
|
|
1819
1912
|
const templatePostData = buildTemplatePostData(template, effectiveArgs);
|
|
1820
1913
|
const requestData = {
|
|
1821
1914
|
cid,
|
|
@@ -1829,17 +1922,24 @@ async function handleExport(args, state) {
|
|
|
1829
1922
|
},
|
|
1830
1923
|
themeStyleSetting,
|
|
1831
1924
|
pageMarginSetting,
|
|
1925
|
+
imageSizeConfig,
|
|
1832
1926
|
t: Date.now(),
|
|
1833
1927
|
...templatePostData,
|
|
1834
1928
|
};
|
|
1835
1929
|
const response = await postBinary(client, '/c/downWithStyle', requestData);
|
|
1836
1930
|
const fileName = parseFileName(response.headers['content-disposition']) || `${taskTitle}.docx`;
|
|
1837
|
-
const savedPath = writeDownloadFile(effectiveArgs.out, fileName, response.data
|
|
1931
|
+
const savedPath = writeDownloadFile(effectiveArgs.out, fileName, response.data, {
|
|
1932
|
+
protectedPaths: [
|
|
1933
|
+
CONFIG_FILE,
|
|
1934
|
+
...(Array.isArray(snapshot.localFiles) ? snapshot.localFiles : []),
|
|
1935
|
+
],
|
|
1936
|
+
});
|
|
1838
1937
|
saveTaskSnapshot(state, cid, {
|
|
1839
1938
|
tableColor,
|
|
1840
1939
|
tableStyle,
|
|
1841
1940
|
themeStyleSetting,
|
|
1842
1941
|
pageMarginSetting,
|
|
1942
|
+
imageSizeConfig,
|
|
1843
1943
|
});
|
|
1844
1944
|
const exportConfig = pickExportConfig(requestData, templateIndex);
|
|
1845
1945
|
|
|
@@ -1879,6 +1979,24 @@ async function hydrateTaskSnapshot(client, state, cid) {
|
|
|
1879
1979
|
return snapshot;
|
|
1880
1980
|
}
|
|
1881
1981
|
|
|
1982
|
+
async function fetchAuthoritativeTaskState(client, cid, options = {}) {
|
|
1983
|
+
const taskResponse = await getJson(
|
|
1984
|
+
client,
|
|
1985
|
+
`/task/getTaskDetail/${encodeURIComponent(cid)}`,
|
|
1986
|
+
{},
|
|
1987
|
+
{ retries: DEFAULT_RETRY_COUNT },
|
|
1988
|
+
);
|
|
1989
|
+
const taskData = taskResponse?.data || {};
|
|
1990
|
+
if (!options.content) {
|
|
1991
|
+
return taskData;
|
|
1992
|
+
}
|
|
1993
|
+
const contentResponse = await fetchContentDetail(client, cid);
|
|
1994
|
+
return {
|
|
1995
|
+
...taskData,
|
|
1996
|
+
...(contentResponse?.data || {}),
|
|
1997
|
+
};
|
|
1998
|
+
}
|
|
1999
|
+
|
|
1882
2000
|
function buildOutlineParams(snapshot, args) {
|
|
1883
2001
|
const pageScopeCode = enumOption(args, ['pageScope', 'pageScopeCode'], PAGE_SCOPE_OPTIONS, snapshot.pageScopeCode ?? DEFAULT_PAGE_SCOPE_CODE);
|
|
1884
2002
|
const base = enumOption(args, 'base', BASE_OPTIONS, snapshot.base ?? DEFAULT_BASE);
|
|
@@ -1887,6 +2005,12 @@ function buildOutlineParams(snapshot, args) {
|
|
|
1887
2005
|
const plan = stringOption(args, 'plan', snapshot.plan || '');
|
|
1888
2006
|
const requirement = stringOption(args, 'requirement', snapshot.requirement || '');
|
|
1889
2007
|
const rating = stringOption(args, 'rating', snapshot.rating || '');
|
|
2008
|
+
const blindGenerationMode = enumOption(
|
|
2009
|
+
args,
|
|
2010
|
+
['blindGenerationMode', 'anonymousBid'],
|
|
2011
|
+
BLIND_GENERATION_MODE_OPTIONS,
|
|
2012
|
+
snapshot.blindGenerationMode ?? snapshot.blindBidConfirm?.blindGenerationMode ?? 'normal',
|
|
2013
|
+
);
|
|
1890
2014
|
|
|
1891
2015
|
if (!uuid) {
|
|
1892
2016
|
throw new Error('Missing uuid for outline generation. Run init first or make sure the task snapshot contains uuid.');
|
|
@@ -1924,6 +2048,11 @@ function buildOutlineParams(snapshot, args) {
|
|
|
1924
2048
|
multiBidType: stringOption(args, 'multiBidType', snapshot.multiBidType),
|
|
1925
2049
|
planMode: enumOption(args, 'planMode', PLAN_MODE_OPTIONS, snapshot.planMode ?? 0),
|
|
1926
2050
|
epcEngineerType: normalizeEpcEngineerType(stringOption(args, 'epcEngineerType', snapshot.epcEngineerType)),
|
|
2051
|
+
blindBidConfirm: {
|
|
2052
|
+
blindGenerationMode,
|
|
2053
|
+
reviewMode: blindGenerationMode === 'blind' ? 'blind_review' : 'open_review',
|
|
2054
|
+
rules: [],
|
|
2055
|
+
},
|
|
1927
2056
|
});
|
|
1928
2057
|
}
|
|
1929
2058
|
|
|
@@ -2282,7 +2411,10 @@ async function ensurePeriodReady(client, state, cid, args) {
|
|
|
2282
2411
|
|
|
2283
2412
|
function isParseReadyForNextStep(success) {
|
|
2284
2413
|
const parsed = Number(success);
|
|
2285
|
-
|
|
2414
|
+
// The web client treats 2 as the completed parse state that opens the
|
|
2415
|
+
// bid-settings page. Waiting past it keeps WorkBuddy polling forever even
|
|
2416
|
+
// though multi-bid/EPC results are already available.
|
|
2417
|
+
return parsed === 1 || parsed === 2 || parsed === 4;
|
|
2286
2418
|
}
|
|
2287
2419
|
|
|
2288
2420
|
async function pollTaskUntil(client, state, cid, args, isDone, formatProgress) {
|
|
@@ -2814,6 +2946,7 @@ async function ensureDirectoryReadyForWrite(client, state, cid, args) {
|
|
|
2814
2946
|
|
|
2815
2947
|
async function prepareContentGeneration(client, state, cid, contentDetailResponse, args) {
|
|
2816
2948
|
const contentDetail = contentDetailResponse?.data || {};
|
|
2949
|
+
assertContentGenerationAllowed(contentDetail, '生成正文');
|
|
2817
2950
|
const viewStep = Number(contentDetail.viewStep);
|
|
2818
2951
|
const taskStatus = Number(contentDetail.task_status);
|
|
2819
2952
|
const contentPortionStatus = Number(contentDetail.content_portion_status);
|
|
@@ -3060,6 +3193,7 @@ function buildChoicesPayload(scope) {
|
|
|
3060
3193
|
imageToggles: ['infoImg', 'sceneImg', 'onlineImg', 'mermaidImg', 'knowledgeImg'],
|
|
3061
3194
|
mermaidStyle: MERMAID_STYLE_OPTIONS,
|
|
3062
3195
|
planMode: PLAN_MODE_OPTIONS,
|
|
3196
|
+
blindGenerationMode: BLIND_GENERATION_MODE_OPTIONS,
|
|
3063
3197
|
multiBid: ['--multi-bid-id', '--multi-bid-type'],
|
|
3064
3198
|
epcEngineerType: {
|
|
3065
3199
|
option: '--epc-engineer-type',
|
|
@@ -3077,9 +3211,11 @@ function buildChoicesPayload(scope) {
|
|
|
3077
3211
|
tableStyle: TABLE_STYLE_OPTIONS,
|
|
3078
3212
|
advancedStyle: {
|
|
3079
3213
|
option: '--style-json',
|
|
3214
|
+
automationOption: '--style-json-data',
|
|
3080
3215
|
fields: ['startIndex', 'directorySettings', 'contentSettings'],
|
|
3081
3216
|
},
|
|
3082
3217
|
pageMargins: PAGE_MARGIN_FIELDS.map(field => `--${toKebabCase(field.optionKeys[0])}`),
|
|
3218
|
+
imageSize: IMAGE_SIZE_FIELDS.map(field => `--${toKebabCase(field.optionKeys[0])}`),
|
|
3083
3219
|
themeColors: THEME_STYLE_SETTING_FIELDS.map(field => field.optionKeys.map(key => `--${toKebabCase(key)}`).join('/')),
|
|
3084
3220
|
};
|
|
3085
3221
|
}
|
|
@@ -3100,6 +3236,7 @@ function renderChoicesLines(payload) {
|
|
|
3100
3236
|
lines.push(` table-style: ${formatChoiceOptions(TABLE_STYLE_OPTIONS)}`);
|
|
3101
3237
|
lines.push(` mermaid-style: ${formatChoiceOptions(MERMAID_STYLE_OPTIONS)}`);
|
|
3102
3238
|
lines.push(` plan-mode: ${formatChoiceOptions(PLAN_MODE_OPTIONS)}`);
|
|
3239
|
+
lines.push(` blind-generation-mode: ${formatChoiceOptions(BLIND_GENERATION_MODE_OPTIONS)}`);
|
|
3103
3240
|
lines.push(' image toggles: --info-img, --scene-img, --online-img, --mermaid-img, --knowledge-img (0|1)');
|
|
3104
3241
|
lines.push(' multi-bid override: --multi-bid-id <id>, --multi-bid-type <text>');
|
|
3105
3242
|
lines.push(` epc-engineer-type: --epc-engineer-type <text>; aliases=${Object.keys(EPC_ENGINEER_TYPE_ALIAS_MAP).join(', ')}`);
|
|
@@ -3111,8 +3248,9 @@ function renderChoicesLines(payload) {
|
|
|
3111
3248
|
lines.push(` table-color: ${formatChoiceOptions(TABLE_COLOR_OPTIONS)}`);
|
|
3112
3249
|
lines.push(` table-style: ${formatChoiceOptions(TABLE_STYLE_OPTIONS)}`);
|
|
3113
3250
|
lines.push(' binary toggles: --auto-number, --add-mark (0|1)');
|
|
3114
|
-
lines.push('
|
|
3251
|
+
lines.push(' custom numbering: --style-json-data <json> (automation) or --style-json <file> (compatibility) with startIndex, directorySettings, contentSettings');
|
|
3115
3252
|
lines.push(` margins: ${PAGE_MARGIN_FIELDS.map(field => `--${toKebabCase(field.optionKeys[0])}`).join(', ')}`);
|
|
3253
|
+
lines.push(` image sizes (cm): ${IMAGE_SIZE_FIELDS.map(field => `--${toKebabCase(field.optionKeys[0])}`).join(', ')}`);
|
|
3116
3254
|
lines.push(` theme colors: ${THEME_STYLE_SETTING_FIELDS.map(field => field.optionKeys.map(key => `--${toKebabCase(key)}`).join('/')).join(', ')}`);
|
|
3117
3255
|
}
|
|
3118
3256
|
return lines;
|
|
@@ -3393,6 +3531,22 @@ async function collectExportInteractiveArgs(args, snapshot, context = null, opti
|
|
|
3393
3531
|
}
|
|
3394
3532
|
}
|
|
3395
3533
|
|
|
3534
|
+
const customizeImageSize = await promptYesNo(
|
|
3535
|
+
promptContext,
|
|
3536
|
+
'是否要自定义图片尺寸',
|
|
3537
|
+
hasAnyOption(args, IMAGE_SIZE_FIELDS.flatMap(field => field.optionKeys)),
|
|
3538
|
+
);
|
|
3539
|
+
if (customizeImageSize) {
|
|
3540
|
+
for (const field of IMAGE_SIZE_FIELDS) {
|
|
3541
|
+
nextArgs[field.optionKeys[0]] = await promptNumber(promptContext, {
|
|
3542
|
+
title: `图片尺寸: ${field.key}`,
|
|
3543
|
+
question: `请输入 ${field.key} (单位 cm)`,
|
|
3544
|
+
defaultValue: numberOptionAny(args, field.optionKeys, defaults.imageSizeConfig?.[field.key] ?? DEFAULT_IMAGE_SIZE_CONFIG[field.key]),
|
|
3545
|
+
min: 0.1,
|
|
3546
|
+
});
|
|
3547
|
+
}
|
|
3548
|
+
}
|
|
3549
|
+
|
|
3396
3550
|
const useStyleJson = await promptYesNo(
|
|
3397
3551
|
promptContext,
|
|
3398
3552
|
'\u662F\u5426\u8981\u6302\u8F7D\u9AD8\u7EA7\u6837\u5F0F JSON',
|
|
@@ -3442,6 +3596,7 @@ function buildInteractiveSnapshotDefaults(source = {}) {
|
|
|
3442
3596
|
multiBidType: source.multiBidType ?? '',
|
|
3443
3597
|
epcEngineerType: source.epcEngineerType ?? '',
|
|
3444
3598
|
pageMarginSetting: source.pageMarginSetting ?? DEFAULT_PAGE_MARGIN_SETTING,
|
|
3599
|
+
imageSizeConfig: source.imageSizeConfig ?? DEFAULT_IMAGE_SIZE_CONFIG,
|
|
3445
3600
|
themeStyleSetting: source.themeStyleSetting ?? DEFAULT_THEME_STYLE_SETTING,
|
|
3446
3601
|
};
|
|
3447
3602
|
}
|
|
@@ -4413,19 +4568,48 @@ function buildPageMarginSetting(snapshot, args) {
|
|
|
4413
4568
|
return pageMarginSetting;
|
|
4414
4569
|
}
|
|
4415
4570
|
|
|
4571
|
+
function buildImageSizeConfig(snapshot, args) {
|
|
4572
|
+
const imageSizeConfig = {
|
|
4573
|
+
...DEFAULT_IMAGE_SIZE_CONFIG,
|
|
4574
|
+
...(snapshot.imageSizeConfig || {}),
|
|
4575
|
+
};
|
|
4576
|
+
for (const field of IMAGE_SIZE_FIELDS) {
|
|
4577
|
+
const value = numberOptionAny(args, field.optionKeys);
|
|
4578
|
+
if (value === undefined) {
|
|
4579
|
+
continue;
|
|
4580
|
+
}
|
|
4581
|
+
if (!Number.isFinite(value) || value <= 0 || value > 50) {
|
|
4582
|
+
throw new Error(`Option --${toKebabCase(field.optionKeys[0])} expects a number greater than 0 and no greater than 50 cm.`);
|
|
4583
|
+
}
|
|
4584
|
+
imageSizeConfig[field.key] = value;
|
|
4585
|
+
}
|
|
4586
|
+
return imageSizeConfig;
|
|
4587
|
+
}
|
|
4588
|
+
|
|
4416
4589
|
function buildTemplatePostData(template, args) {
|
|
4417
4590
|
const styleJsonPath = stringOption(args, 'styleJson');
|
|
4418
4591
|
let styleJson = {};
|
|
4592
|
+
const styleJsonData = stringOption(args, 'styleJsonData');
|
|
4593
|
+
if (styleJsonData) {
|
|
4594
|
+
try {
|
|
4595
|
+
styleJson = JSON.parse(styleJsonData);
|
|
4596
|
+
} catch {
|
|
4597
|
+
throw new Error('Option --style-json-data expects a valid JSON object.');
|
|
4598
|
+
}
|
|
4599
|
+
}
|
|
4419
4600
|
if (styleJsonPath) {
|
|
4420
4601
|
const resolvedPath = path.resolve(styleJsonPath);
|
|
4421
4602
|
ensureFileExists(resolvedPath);
|
|
4422
4603
|
try {
|
|
4423
|
-
styleJson =
|
|
4604
|
+
styleJson = {
|
|
4605
|
+
...styleJson,
|
|
4606
|
+
...JSON.parse(fs.readFileSync(resolvedPath, 'utf8')),
|
|
4607
|
+
};
|
|
4424
4608
|
} catch (error) {
|
|
4425
4609
|
throw new Error(`Failed to parse style JSON: ${resolvedPath}`);
|
|
4426
4610
|
}
|
|
4427
4611
|
if (!styleJson || Array.isArray(styleJson) || typeof styleJson !== 'object') {
|
|
4428
|
-
throw new Error('
|
|
4612
|
+
throw new Error('Export style expects a JSON object.');
|
|
4429
4613
|
}
|
|
4430
4614
|
if (styleJson.directorySettings !== undefined && !Array.isArray(styleJson.directorySettings)) {
|
|
4431
4615
|
throw new Error('Option --style-json expects directorySettings to be an array.');
|
|
@@ -4434,6 +4618,15 @@ function buildTemplatePostData(template, args) {
|
|
|
4434
4618
|
throw new Error('Option --style-json expects contentSettings to be an object.');
|
|
4435
4619
|
}
|
|
4436
4620
|
}
|
|
4621
|
+
if (!styleJson || Array.isArray(styleJson) || typeof styleJson !== 'object') {
|
|
4622
|
+
throw new Error('Export style expects a JSON object.');
|
|
4623
|
+
}
|
|
4624
|
+
if (styleJson.directorySettings !== undefined && !Array.isArray(styleJson.directorySettings)) {
|
|
4625
|
+
throw new Error('Export style expects directorySettings to be an array.');
|
|
4626
|
+
}
|
|
4627
|
+
if (styleJson.contentSettings !== undefined && (Array.isArray(styleJson.contentSettings) || typeof styleJson.contentSettings !== 'object' || styleJson.contentSettings === null)) {
|
|
4628
|
+
throw new Error('Export style expects contentSettings to be an object.');
|
|
4629
|
+
}
|
|
4437
4630
|
const startIndex = numberOption(args, 'startIndex', styleJson.startIndex ?? template.postData.startIndex ?? 1);
|
|
4438
4631
|
if (!Number.isInteger(startIndex) || startIndex < 1) {
|
|
4439
4632
|
throw new Error('Option --start-index expects an integer greater than or equal to 1.');
|
|
@@ -4484,6 +4677,7 @@ function pickOutlineConfig(params) {
|
|
|
4484
4677
|
knowledgeImg: params.knowledgeImg,
|
|
4485
4678
|
mermaidStyle: params.mermaidStyle,
|
|
4486
4679
|
planMode: params.planMode,
|
|
4680
|
+
blindBidConfirm: params.blindBidConfirm,
|
|
4487
4681
|
multiBidId: params.multiBidId,
|
|
4488
4682
|
multiBidType: params.multiBidType,
|
|
4489
4683
|
epcEngineerType: params.epcEngineerType,
|
|
@@ -4499,6 +4693,9 @@ function buildOutlineConfigSummary(params) {
|
|
|
4499
4693
|
params.tableColor !== undefined ? `tableColor=${describeEnumValue(TABLE_COLOR_OPTIONS, params.tableColor)}` : null,
|
|
4500
4694
|
params.tableStyle !== undefined ? `tableStyle=${describeEnumValue(TABLE_STYLE_OPTIONS, params.tableStyle)}` : null,
|
|
4501
4695
|
`planMode=${describeEnumValue(PLAN_MODE_OPTIONS, params.planMode)}`,
|
|
4696
|
+
params.blindBidConfirm?.blindGenerationMode
|
|
4697
|
+
? `blindGenerationMode=${params.blindBidConfirm.blindGenerationMode}`
|
|
4698
|
+
: null,
|
|
4502
4699
|
].filter(Boolean).join(', ');
|
|
4503
4700
|
}
|
|
4504
4701
|
|
|
@@ -4514,6 +4711,7 @@ function pickExportConfig(requestData, templateIndex) {
|
|
|
4514
4711
|
directorySettings: requestData.directorySettings,
|
|
4515
4712
|
contentSettings: requestData.contentSettings,
|
|
4516
4713
|
pageMarginSetting: requestData.pageMarginSetting,
|
|
4714
|
+
imageSizeConfig: requestData.imageSizeConfig,
|
|
4517
4715
|
themeStyleSetting: requestData.themeStyleSetting,
|
|
4518
4716
|
};
|
|
4519
4717
|
}
|
|
@@ -4529,6 +4727,7 @@ function buildExportConfigSummary(config) {
|
|
|
4529
4727
|
`autoNumber=${config.autoNumber}`,
|
|
4530
4728
|
`addMark=${config.addMark}`,
|
|
4531
4729
|
`margins=${margins.topMargin}/${margins.bottomMargin}/${margins.leftMargin}/${margins.rightMargin}`,
|
|
4730
|
+
`images=${config.imageSizeConfig.landscapeWidth}/${config.imageSizeConfig.landscapeMaxHeight}/${config.imageSizeConfig.portraitWidth}/${config.imageSizeConfig.portraitMaxHeight}`,
|
|
4532
4731
|
].join(', ');
|
|
4533
4732
|
}
|
|
4534
4733
|
|
|
@@ -4600,7 +4799,15 @@ function readState() {
|
|
|
4600
4799
|
|
|
4601
4800
|
function writeState(state) {
|
|
4602
4801
|
fs.mkdirSync(APP_DIR, { recursive: true });
|
|
4603
|
-
fs.writeFileSync(CONFIG_FILE, JSON.stringify(state, null, 2),
|
|
4802
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(state, null, 2), {
|
|
4803
|
+
encoding: 'utf8',
|
|
4804
|
+
mode: 0o600,
|
|
4805
|
+
});
|
|
4806
|
+
try {
|
|
4807
|
+
fs.chmodSync(CONFIG_FILE, 0o600);
|
|
4808
|
+
} catch {
|
|
4809
|
+
// Windows ACLs may ignore POSIX mode bits; keep the user-scoped path.
|
|
4810
|
+
}
|
|
4604
4811
|
}
|
|
4605
4812
|
|
|
4606
4813
|
function saveTaskSnapshot(state, cid, patch) {
|
|
@@ -4617,6 +4824,55 @@ function saveTaskSnapshot(state, cid, patch) {
|
|
|
4617
4824
|
return state.tasks[cid];
|
|
4618
4825
|
}
|
|
4619
4826
|
|
|
4827
|
+
async function withGenerationLock(cid, operation, work) {
|
|
4828
|
+
const lockDirectory = path.join(APP_DIR, 'generation-locks');
|
|
4829
|
+
fs.mkdirSync(lockDirectory, { recursive: true });
|
|
4830
|
+
const lockPath = path.join(
|
|
4831
|
+
lockDirectory,
|
|
4832
|
+
encodeURIComponent(String(cid)) + '-' + encodeURIComponent(String(operation)) + '.lock',
|
|
4833
|
+
);
|
|
4834
|
+
let descriptor;
|
|
4835
|
+
try {
|
|
4836
|
+
descriptor = fs.openSync(lockPath, 'wx', 0o600);
|
|
4837
|
+
fs.writeFileSync(descriptor, JSON.stringify({
|
|
4838
|
+
cid: String(cid),
|
|
4839
|
+
operation: String(operation),
|
|
4840
|
+
pid: process.pid,
|
|
4841
|
+
createdAt: new Date().toISOString(),
|
|
4842
|
+
}), 'utf8');
|
|
4843
|
+
} catch (error) {
|
|
4844
|
+
if (error?.code === 'EEXIST') {
|
|
4845
|
+
try {
|
|
4846
|
+
const ageMs = Date.now() - fs.statSync(lockPath).mtimeMs;
|
|
4847
|
+
if (ageMs > 7 * 24 * 60 * 60 * 1000) {
|
|
4848
|
+
fs.unlinkSync(lockPath);
|
|
4849
|
+
return withGenerationLock(cid, operation, work);
|
|
4850
|
+
}
|
|
4851
|
+
} catch {
|
|
4852
|
+
// Keep the fail-closed lock decision below when the lock cannot be inspected.
|
|
4853
|
+
}
|
|
4854
|
+
throw new Error('[GENERATION_LOCKED] 当前任务已有同一生成操作正在执行;请查询 status,不要并发重试。');
|
|
4855
|
+
}
|
|
4856
|
+
throw error;
|
|
4857
|
+
} finally {
|
|
4858
|
+
if (descriptor !== undefined) {
|
|
4859
|
+
fs.closeSync(descriptor);
|
|
4860
|
+
}
|
|
4861
|
+
}
|
|
4862
|
+
|
|
4863
|
+
try {
|
|
4864
|
+
return await work();
|
|
4865
|
+
} finally {
|
|
4866
|
+
try {
|
|
4867
|
+
fs.unlinkSync(lockPath);
|
|
4868
|
+
} catch (error) {
|
|
4869
|
+
if (error?.code !== 'ENOENT') {
|
|
4870
|
+
// A stale lock is safer than hiding the original generation result.
|
|
4871
|
+
}
|
|
4872
|
+
}
|
|
4873
|
+
}
|
|
4874
|
+
}
|
|
4875
|
+
|
|
4620
4876
|
function pickTaskSnapshot(source) {
|
|
4621
4877
|
return compactObject({
|
|
4622
4878
|
cid: source.cid,
|
|
@@ -4652,6 +4908,8 @@ function pickTaskSnapshot(source) {
|
|
|
4652
4908
|
tableStyle: source.tableStyle,
|
|
4653
4909
|
tableQuantity: source.tableQuantity,
|
|
4654
4910
|
planMode: source.planMode,
|
|
4911
|
+
blindBidConfirm: source.blindBidConfirm,
|
|
4912
|
+
blindGenerationMode: source.blindGenerationMode || source.blindBidConfirm?.blindGenerationMode,
|
|
4655
4913
|
multiBidId: source.multiBidId,
|
|
4656
4914
|
multiBidType: source.multiBidType,
|
|
4657
4915
|
epcEngineerType: source.epcEngineerType,
|
|
@@ -4659,9 +4917,12 @@ function pickTaskSnapshot(source) {
|
|
|
4659
4917
|
referenceStatus: source.referenceStatus,
|
|
4660
4918
|
themeStyleSetting: source.themeStyleSetting,
|
|
4661
4919
|
pageMarginSetting: source.pageMarginSetting,
|
|
4920
|
+
imageSizeConfig: source.imageSizeConfig,
|
|
4662
4921
|
outlineTriggeredAt: source.outlineTriggeredAt,
|
|
4663
4922
|
triggerMode: source.triggerMode,
|
|
4664
4923
|
writeTriggeredAt: source.writeTriggeredAt,
|
|
4924
|
+
contentGenerationStartedAt: source.contentGenerationStartedAt,
|
|
4925
|
+
contentGenerationCompletedAt: source.contentGenerationCompletedAt,
|
|
4665
4926
|
});
|
|
4666
4927
|
}
|
|
4667
4928
|
|
|
@@ -4692,10 +4953,21 @@ function normalizeDownloadFileName(value) {
|
|
|
4692
4953
|
return String(value).replace(/[<>:"/\\|?*\u0000-\u001F]/g, '_').trim();
|
|
4693
4954
|
}
|
|
4694
4955
|
|
|
4695
|
-
function writeDownloadFile(outputOption, fileName, fileContent) {
|
|
4956
|
+
function writeDownloadFile(outputOption, fileName, fileContent, options = {}) {
|
|
4696
4957
|
const resolvedOutput = outputOption ? path.resolve(outputOption) : process.cwd();
|
|
4697
4958
|
const outputIsDirectory = !path.extname(resolvedOutput);
|
|
4698
4959
|
const finalPath = outputIsDirectory ? path.join(resolvedOutput, fileName) : resolvedOutput;
|
|
4960
|
+
const protectedPaths = (options.protectedPaths || [])
|
|
4961
|
+
.filter(Boolean)
|
|
4962
|
+
.map(value => path.resolve(String(value)));
|
|
4963
|
+
const normalizedFinalPath = path.resolve(finalPath);
|
|
4964
|
+
if (protectedPaths.some(value => value === normalizedFinalPath)) {
|
|
4965
|
+
throw new Error('[UNSAFE_OUTPUT_PATH] 输出文件不能覆盖源文件或 xq-cli 凭据/状态文件。请指定新的输出目录。');
|
|
4966
|
+
}
|
|
4967
|
+
if (normalizedFinalPath === path.resolve(APP_DIR)
|
|
4968
|
+
|| normalizedFinalPath.startsWith(path.resolve(APP_DIR) + path.sep)) {
|
|
4969
|
+
throw new Error('[UNSAFE_OUTPUT_PATH] 输出文件不能写入 xq-cli 内部状态目录。请指定用户输出目录。');
|
|
4970
|
+
}
|
|
4699
4971
|
fs.mkdirSync(path.dirname(finalPath), { recursive: true });
|
|
4700
4972
|
fs.writeFileSync(finalPath, Buffer.from(fileContent));
|
|
4701
4973
|
return finalPath;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
const CONTENT_VIEW_STEP = 101;
|
|
2
|
+
|
|
3
|
+
export class TaskSafetyError extends Error {
|
|
4
|
+
constructor(code, message, details = {}) {
|
|
5
|
+
super('[' + code + '] ' + message);
|
|
6
|
+
this.name = 'TaskSafetyError';
|
|
7
|
+
this.code = code;
|
|
8
|
+
this.details = details;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function finiteNumber(value) {
|
|
13
|
+
const parsed = Number(value);
|
|
14
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Normalize the backend's mixed task/content status fields into a conservative
|
|
19
|
+
* decision. A missing status is intentionally not treated as "not started".
|
|
20
|
+
*/
|
|
21
|
+
export function classifyContentState(detail = {}) {
|
|
22
|
+
const taskStatus = finiteNumber(detail.task_status);
|
|
23
|
+
const contentPortionStatus = finiteNumber(detail.content_portion_status);
|
|
24
|
+
const viewStep = finiteNumber(detail.viewStep);
|
|
25
|
+
const percentage = finiteNumber(detail.percentage);
|
|
26
|
+
const completedChapter = finiteNumber(detail.completedChapter);
|
|
27
|
+
const totalChapter = finiteNumber(detail.totalChapter);
|
|
28
|
+
const statusKnown = [taskStatus, contentPortionStatus, viewStep, percentage, completedChapter, totalChapter]
|
|
29
|
+
.some(value => value !== null);
|
|
30
|
+
const complete = percentage !== null && percentage >= 100
|
|
31
|
+
|| taskStatus === 5
|
|
32
|
+
|| (totalChapter !== null && totalChapter > 0
|
|
33
|
+
&& completedChapter !== null && completedChapter >= totalChapter)
|
|
34
|
+
|| (taskStatus === 4 && contentPortionStatus === 1);
|
|
35
|
+
const failed = taskStatus === 7 || contentPortionStatus === 7;
|
|
36
|
+
const started = complete
|
|
37
|
+
|| viewStep === CONTENT_VIEW_STEP
|
|
38
|
+
|| (taskStatus !== null && (taskStatus === 4 || taskStatus === 5))
|
|
39
|
+
|| (contentPortionStatus !== null && contentPortionStatus > 0)
|
|
40
|
+
|| (percentage !== null && percentage > 0)
|
|
41
|
+
|| (completedChapter !== null && completedChapter > 0);
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
statusKnown,
|
|
45
|
+
complete,
|
|
46
|
+
failed,
|
|
47
|
+
started,
|
|
48
|
+
inFlight: started && !complete && !failed,
|
|
49
|
+
taskStatus,
|
|
50
|
+
contentPortionStatus,
|
|
51
|
+
viewStep,
|
|
52
|
+
percentage,
|
|
53
|
+
completedChapter,
|
|
54
|
+
totalChapter,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function assertContentGenerationAllowed(detail, operation = '生成正文') {
|
|
59
|
+
const state = classifyContentState(detail);
|
|
60
|
+
if (!state.statusKnown) {
|
|
61
|
+
throw new TaskSafetyError(
|
|
62
|
+
'CONTENT_STATUS_UNKNOWN',
|
|
63
|
+
'无法确认当前任务正文状态,已拒绝' + operation + '。请先查询 status,确认后台状态后再重试。',
|
|
64
|
+
{ state },
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
if (state.complete) {
|
|
68
|
+
throw new TaskSafetyError(
|
|
69
|
+
'CONTENT_ALREADY_COMPLETED',
|
|
70
|
+
'该任务正文已经生成完成,禁止再次生成;如需新文件请使用 export 重新导出。',
|
|
71
|
+
{ state },
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
if (state.failed) {
|
|
75
|
+
throw new TaskSafetyError(
|
|
76
|
+
'CONTENT_GENERATION_FAILED',
|
|
77
|
+
'该任务的正文生成已失败,已拒绝自动重试以避免重复扣费或覆盖结果;请查询 status 后创建新的任务。',
|
|
78
|
+
{ state },
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
if (state.started) {
|
|
82
|
+
throw new TaskSafetyError(
|
|
83
|
+
'CONTENT_GENERATION_IN_PROGRESS',
|
|
84
|
+
'该任务正文已经启动或正在生成,禁止重复触发;请继续查询 status。',
|
|
85
|
+
{ state },
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
return state;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function assertContentCompleted(detail, operation = '导出') {
|
|
92
|
+
const state = classifyContentState(detail);
|
|
93
|
+
if (!state.statusKnown) {
|
|
94
|
+
throw new TaskSafetyError(
|
|
95
|
+
'CONTENT_STATUS_UNKNOWN',
|
|
96
|
+
'无法确认当前任务正文状态,已拒绝' + operation + '。请先查询 status。',
|
|
97
|
+
{ state },
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
if (!state.complete) {
|
|
101
|
+
throw new TaskSafetyError(
|
|
102
|
+
'CONTENT_NOT_COMPLETED',
|
|
103
|
+
'正文尚未生成完成,不能' + operation + '。请继续查询 status。',
|
|
104
|
+
{ state },
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
return state;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function assertPreContentMutationAllowed(detail, operation) {
|
|
111
|
+
const state = classifyContentState(detail);
|
|
112
|
+
if (!state.statusKnown) {
|
|
113
|
+
throw new TaskSafetyError(
|
|
114
|
+
'TASK_STATUS_UNKNOWN',
|
|
115
|
+
'无法确认当前任务阶段,已拒绝' + operation + '。请先查询 status。',
|
|
116
|
+
{ state },
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
if (state.complete || state.started || state.failed) {
|
|
120
|
+
throw new TaskSafetyError(
|
|
121
|
+
'TASK_CONTENT_PHASE_LOCKED',
|
|
122
|
+
'正文阶段已经开始或已经结束,禁止' + operation + ';只能查看状态或重新导出。',
|
|
123
|
+
{ state },
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
return state;
|
|
127
|
+
}
|