juice-email-cli 2.1.5 → 2.1.7

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/bin/juice.js CHANGED
@@ -11,8 +11,9 @@ program
11
11
  .description('生成符合各大邮件平台的内联 CSS HTML 邮件(同时输出标准版 + 压缩版)')
12
12
  .version(pkg.version, '-v, --version')
13
13
  .option('-f, --file <path>', '输入 HTML 模板文件路径')
14
- .option('-c, --config <path>', '配置文件路径(不指定时自动查找输入文件同级目录)')
15
- .option('--snippet <path>', '片段 HTML 文件路径:将片段内容插入模板 <tbody id="content"> 中,生成 3 个文件')
14
+ .option('-s, --snippet <path>', '片段 HTML 文件路径:将片段内容插入模板 <tbody id="content"> 中')
15
+ .option('-c, --config <path>', '配置文件路径')
16
+ .option('-n, --name <name>', '片段模式输出文件名(不含扩展名)')
16
17
  .option('--install', '注册 Windows 右键菜单(需管理员权限)')
17
18
  .option('--uninstall', '取消 Windows 右键菜单注册(需管理员权限)')
18
19
  .addHelpText('before', `
@@ -27,30 +28,44 @@ program
27
28
  2. 用户主目录 ~/juice.yaml(如果存在)
28
29
  3. 优先配置(-c 指定 或 输入文件同级目录)
29
30
 
30
- 普通模式(-f 必选,直接处理单个 HTML):
31
- juice -f my-email.html
32
- juice -c project.yaml -f emails/welcome.html
31
+ ════════════════════════════════════════════════════════════════
32
+ 使用示例
33
+ ════════════════════════════════════════════════════════════════
33
34
 
34
- 片段组装模式(--snippet + 模板拼接):
35
- juice --snippet edm/elabscience/literature/snippet.html -f edm/elabscience/elabscience-template.html
36
- juice --snippet edm/elabscience/literature/snippet.html (交互式选择模板)
35
+ 普通模式(CSS 内联 + 压缩,输出 2 个文件):
36
+ juice -f my-email.html
37
+ juice -f my-email.html -c project.yaml
38
+ juice -f edm/elabscience/elabscience-template.html
37
39
 
38
- 交互模式(全交互选择):
39
- juice (逐步选择品牌、模板、片段、配置)
40
+ 片段模式(片段 + 模板拼接,输出 4 个文件):
41
+ juice -s edm/elabscience/literature/snippet.html -f edm/elabscience/elabscience-template.html
42
+ juice -s edm/elabscience/literature/snippet.html (交互选择模板)
43
+ juice -s snippet.html -f template.html -n my-output (自定义输出名)
40
44
 
41
- 右键菜单管理:
42
- juice --install (管理员权限,注册右键菜单)
43
- juice --uninstall (管理员权限,卸载右键菜单)
45
+ 交互模式(逐步选择品牌、模板、片段、配置):
46
+ juice
44
47
 
45
- 普通模式输出文件(与输入文件同目录):
46
- <name>.output.html CSS 内联 + 变量替换后的标准版
47
- <name>.minified.html 在标准版基础上压缩的精简版
48
+ ════════════════════════════════════════════════════════════════
49
+ 输出文件说明
50
+ ════════════════════════════════════════════════════════════════
48
51
 
49
- 片段模式输出文件(当前工作目录):
50
- <name>.raw.html 原始组装(Mustache 未渲染,无 CSS 内联)
51
- <name>.html 已渲染(Mustache 变量已替换,无 CSS 内联)
52
- <name>.output.html Juice CSS 内联后
53
- <name>.minified.html 压缩版
52
+ 普通模式(与输入文件同目录):
53
+ <name>.output.html CSS 内联 + 变量替换后的标准版
54
+ <name>.minified.html 压缩版
55
+
56
+ 片段模式(当前工作目录):
57
+ <name>.raw.html 原始组装(Mustache 未渲染,无 CSS 内联)
58
+ <name>.html 已渲染(Mustache 变量已替换,无 CSS 内联)
59
+ <name>.output.html Juice CSS 内联后
60
+ <name>.minified.html 压缩版
61
+
62
+ ════════════════════════════════════════════════════════════════
63
+ 右键菜单
64
+ ════════════════════════════════════════════════════════════════
65
+
66
+ 在 .html/.htm 文件上右键即可使用:
67
+ juice --install (管理员权限,注册右键菜单)
68
+ juice --uninstall (管理员权限,卸载右键菜单)
54
69
 
55
70
  更多信息:https://gitee.com/siriussupreme/juice-cli
56
71
  `)
@@ -67,13 +82,14 @@ program
67
82
  return;
68
83
  }
69
84
 
70
- // --snippet 模式:片段组装
85
+ // --snippet / -s 模式:片段组装
71
86
  if (options.snippet) {
72
87
  const { runSnippetMode } = require('../src/snippet');
73
88
  await runSnippetMode({
74
89
  snippet: options.snippet,
75
90
  template: options.file,
76
91
  config: options.config,
92
+ outputName: options.name,
77
93
  });
78
94
  return;
79
95
  }
@@ -9,6 +9,10 @@
9
9
  #
10
10
  # 用户配置文件(~/juice.yaml 或项目 juice.yaml)只需填写需要覆盖的字段即可。
11
11
 
12
+ # 设为 false 时,模板变量中的 HTML 标签会被转义(<sup> 变成 &lt;sup&gt;)
13
+ # 默认 true:变量值中的 HTML 标签(如 <sup>、<sub>)直接渲染
14
+ rawHtml: true
15
+
12
16
  # ─── juice CSS 内联选项 ───────────────────────────────────────────────────────
13
17
  juice:
14
18
  # 处理完成后移除 <style> 标签(推荐 true,提高 Gmail/Outlook 等邮件客户端兼容性)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "juice-email-cli",
3
- "version": "2.1.5",
3
+ "version": "2.1.7",
4
4
  "description": "CLI tool to generate email-client-compatible HTML with juice (CSS inlining) + Mustache templating + minification",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -102,14 +102,14 @@ async function registerContextMenu() {
102
102
 
103
103
  // ── 注册子命令到 CommandStore ─────────────────────────────────────────────
104
104
 
105
- // 子命令 1juice 生成
105
+ // 子命令 1:普通模式 - juice 生成
106
106
  const generateCmd = `"${nodePath}" "${scriptPath}" -f "%1"`;
107
107
  regAdd(`${SUBCMD_SPACE}\\${subCmd1}`, '', 'REG_SZ', '⚡ 生成邮件 HTML(标准 + 压缩)');
108
108
  regAdd(`${SUBCMD_SPACE}\\${subCmd1}`, 'Icon', 'REG_SZ', iconPath);
109
109
  regAdd(`${SUBCMD_SPACE}\\${subCmd1}\\command`, '', 'REG_SZ', generateCmd);
110
110
 
111
- // 子命令 2:片段组装
112
- const snippetCmd = `"${nodePath}" "${scriptPath}" --snippet "%1"`;
111
+ // 子命令 2:片段模式 - 片段组装(交互选择模板)
112
+ const snippetCmd = `"${nodePath}" "${scriptPath}" -s "%1"`;
113
113
  regAdd(`${SUBCMD_SPACE}\\${subCmd2}`, '', 'REG_SZ', '🧩 邮件片段组装(交互选择模板)');
114
114
  regAdd(`${SUBCMD_SPACE}\\${subCmd2}`, 'Icon', 'REG_SZ', iconPath);
115
115
  regAdd(`${SUBCMD_SPACE}\\${subCmd2}\\command`, '', 'REG_SZ', snippetCmd);
@@ -146,13 +146,11 @@ async function registerContextMenu() {
146
146
  chalk.green(' ✔ 右键菜单注册完成!') + '\n\n' +
147
147
  ` 在 ${chalk.cyan('.html')} / ${chalk.cyan('.htm')} 文件上右键即可看到:\n` +
148
148
  ` ${chalk.bold('📧 用 juice 生成邮件 HTML')}\n` +
149
- ` ├── ⚡ 生成邮件 HTML(标准 + 压缩)\n` +
150
- ` ├── 🧩 邮件片段组装(交互选择模板)\n` +
149
+ ` ├── ⚡ 生成邮件 HTML(标准 + 压缩) → juice -f\n` +
150
+ ` ├── 🧩 邮件片段组装(交互选择模板) → juice -s\n` +
151
151
  (pwshPath ? ` └── 📂 在此目录打开 PowerShell 7\n` : '') +
152
152
  '\n' +
153
- chalk.gray(' 注意:如菜单未出现,请重启文件资源管理器(explorer.exe)。\n') +
154
- chalk.gray(' 安装时自动注册失败?请以管理员身份重新运行:\n') +
155
- chalk.cyan(' juice --register\n')
153
+ chalk.gray(' 注意:如菜单未出现,请重启文件资源管理器(explorer.exe)。\n')
156
154
  );
157
155
  }
158
156
 
package/src/index.js CHANGED
@@ -146,8 +146,13 @@ function buildConfig(highPriorityPath, homePath) {
146
146
  function processTemplate(inputFile, config) {
147
147
  const htmlRaw = fs.readFileSync(inputFile, 'utf8');
148
148
 
149
- // 1. Mustache 变量替换
149
+ // 1. Mustache 变量替换(根据 rawHtml 配置决定是否转义 HTML)
150
+ const originalEscape = Mustache.escape;
151
+ if (config.rawHtml) {
152
+ Mustache.escape = (text) => text;
153
+ }
150
154
  const htmlWithVars = Mustache.render(htmlRaw, config.variables || {});
155
+ Mustache.escape = originalEscape;
151
156
 
152
157
  // 2. 收集 extraCssFiles
153
158
  const basePath = path.dirname(path.resolve(inputFile));
package/src/snippet.js CHANGED
@@ -141,7 +141,7 @@ function buildSnippetConfig({ priorityConfigPath, cliConfigPath }) {
141
141
 
142
142
  /**
143
143
  * 将 snippet HTML 插入到模板的 <tbody id="content"> 中。
144
- * 使用深度计数算法处理嵌套 <tbody>。
144
+ * 使用深度计数算法处理嵌套 <tbody>,并自动调整片段缩进以匹配模板。
145
145
  */
146
146
  function insertIntoContent(templateHtml, snippetHtml) {
147
147
  const openRe = /<tbody[\s>][^>]*\bid\s*=\s*["']content["'][^>]*>/i;
@@ -150,6 +150,18 @@ function insertIntoContent(templateHtml, snippetHtml) {
150
150
  throw new Error('模板中未找到 <tbody id="content"> 元素。模板必须包含一个带 id="content" 的 <tbody> 用于插入片段内容。');
151
151
  }
152
152
 
153
+ // 检测模板中 <tbody id="content"> 的缩进层级
154
+ const lineStart = templateHtml.lastIndexOf('\n', match.index) + 1;
155
+ const tagIndent = match.index - lineStart;
156
+
157
+ // 找到插入点后第一个非空行,确定子元素缩进
158
+ const afterOpen = templateHtml.substring(match.index + match[0].length);
159
+ const nextLineMatch = afterOpen.match(/\n(\s*)\S/);
160
+ const childIndent = nextLineMatch ? nextLineMatch[1].length : tagIndent + 2;
161
+
162
+ // 调整片段缩进以匹配模板
163
+ const reindentedSnippet = reindentHtml(snippetHtml, childIndent);
164
+
153
165
  const openTagEnd = match.index + match[0].length;
154
166
  let depth = 1;
155
167
  let searchPos = openTagEnd;
@@ -172,7 +184,8 @@ function insertIntoContent(templateHtml, snippetHtml) {
172
184
  } else {
173
185
  depth--;
174
186
  if (depth === 0) {
175
- return templateHtml.substring(0, openTagEnd) + '\n' + snippetHtml + '\n' + templateHtml.substring(nextClose.index);
187
+ const indent = '\n' + ' '.repeat(tagIndent);
188
+ return templateHtml.substring(0, openTagEnd) + '\n' + reindentedSnippet + indent + templateHtml.substring(nextClose.index);
176
189
  }
177
190
  searchPos = nextClose.index + 1;
178
191
  }
@@ -181,10 +194,41 @@ function insertIntoContent(templateHtml, snippetHtml) {
181
194
  throw new Error('无法找到 id="content" 对应的闭合 </tbody> 标签,模板 HTML 可能存在标签不匹配问题。');
182
195
  }
183
196
 
197
+ /**
198
+ * 重新调整 HTML 片段的缩进,使其最外层元素对齐到目标缩进层级
199
+ */
200
+ function reindentHtml(html, targetIndent) {
201
+ const lines = html.split('\n');
202
+
203
+ // 找到片段中非空行的最小缩进
204
+ let minIndent = Infinity;
205
+ for (const line of lines) {
206
+ if (line.trim().length > 0) {
207
+ const indent = line.length - line.trimStart().length;
208
+ if (indent < minIndent) minIndent = indent;
209
+ }
210
+ }
211
+ if (minIndent === Infinity) minIndent = 0;
212
+
213
+ const delta = targetIndent - minIndent;
214
+ if (delta === 0) return html;
215
+
216
+ const prefix = ' '.repeat(Math.max(0, delta));
217
+
218
+ return lines.map((line) => {
219
+ if (line.trim().length === 0) return '';
220
+ if (delta > 0) {
221
+ return prefix + line;
222
+ } else {
223
+ return line.substring(Math.min(-delta, line.length - line.trimStart().length));
224
+ }
225
+ }).join('\n');
226
+ }
227
+
184
228
  // ─── 输出路径 ─────────────────────────────────────────────────────────────────
185
229
 
186
- function resolveSnippetOutputPaths(snippetPath, cwd) {
187
- const name = path.parse(snippetPath).name;
230
+ function resolveSnippetOutputPaths(outputBaseName, cwd) {
231
+ const name = outputBaseName;
188
232
  return {
189
233
  raw: path.join(cwd, name + '.raw.html'),
190
234
  normal: path.join(cwd, name + '.html'),
@@ -202,18 +246,23 @@ function resolveSnippetOutputPaths(snippetPath, cwd) {
202
246
  * 3. Juice CSS 内联 → .output.html
203
247
  * 4. 压缩 → .minified.html
204
248
  */
205
- async function assembleSnippet({ snippetPath, templatePath, config, layers, cwd }) {
249
+ async function assembleSnippet({ snippetPath, templatePath, config, layers, cwd, outputBaseName }) {
206
250
  const templateHtml = fs.readFileSync(templatePath, 'utf8');
207
251
  const snippetRaw = fs.readFileSync(snippetPath, 'utf8');
208
- const outPaths = resolveSnippetOutputPaths(snippetPath, cwd);
209
- const variables = config.variables || {};
252
+ const outPaths = resolveSnippetOutputPaths(outputBaseName, cwd);
253
+ const variables = Object.assign({}, config.variables || {});
210
254
 
211
255
  // 1. 未渲染的片段插入模板 → .raw.html(Mustache 标签保留,无 juice)
212
- const rawHtml = insertIntoContent(templateHtml, snippetRaw);
213
- fs.writeFileSync(outPaths.raw, rawHtml, 'utf8');
256
+ const rawMarkup = insertIntoContent(templateHtml, snippetRaw);
257
+ fs.writeFileSync(outPaths.raw, rawMarkup, 'utf8');
214
258
 
215
259
  // 2. Mustache 渲染合并 HTML → .html(已渲染,无 juice 内联)
216
- const renderedHtml = Mustache.render(rawHtml, variables);
260
+ const originalEscape = Mustache.escape;
261
+ if (config.rawHtml) {
262
+ Mustache.escape = (text) => text;
263
+ }
264
+ const renderedHtml = Mustache.render(rawMarkup, variables);
265
+ Mustache.escape = originalEscape;
217
266
  fs.writeFileSync(outPaths.normal, renderedHtml, 'utf8');
218
267
 
219
268
  // 3. 收集模板目录的额外 CSS + Juice CSS 内联 → .output.html
@@ -239,7 +288,7 @@ async function assembleSnippet({ snippetPath, templatePath, config, layers, cwd
239
288
  ` ${chalk.bold('模板:')} ${chalk.cyan(templatePath)}\n` +
240
289
  ` ${chalk.bold('配置层(低→高):')}\n${layerLines}\n` +
241
290
  ` ${chalk.bold('输出:')}\n` +
242
- ` ${chalk.green('·')} 原始组装 ${chalk.cyan(outPaths.raw)} ${chalk.gray('(' + fmtSize(rawHtml) + ')')}\n` +
291
+ ` ${chalk.green('·')} 原始组装 ${chalk.cyan(outPaths.raw)} ${chalk.gray('(' + fmtSize(rawMarkup) + ')')}\n` +
243
292
  ` ${chalk.green('·')} 已渲染 ${chalk.cyan(outPaths.normal)} ${chalk.gray('(' + fmtSize(renderedHtml) + ')')}\n` +
244
293
  ` ${chalk.green('·')} 内联后 ${chalk.cyan(outPaths.output)} ${chalk.gray('(' + fmtSize(processed) + ')')}\n` +
245
294
  ` ${chalk.green('·')} 压缩版 ${chalk.cyan(outPaths.minified)} ${chalk.gray('(' + fmtSize(minified) + ',节省 ' + savings(processed, minified) + ')')}`
@@ -302,7 +351,7 @@ async function promptConfigForInteractive(snippetDirYamlFiles) {
302
351
 
303
352
  if (cwdConfig) {
304
353
  choices.push({
305
- name: `📂 当前目录配置 → ${cwdConfig.name}(优先)`,
354
+ name: `[当前目录] ${cwdConfig.name} (优先)`,
306
355
  value: { type: 'file', path: cwdConfig.path, name: cwdConfig.name },
307
356
  });
308
357
  }
@@ -312,7 +361,7 @@ async function promptConfigForInteractive(snippetDirYamlFiles) {
312
361
  for (const f of snippetDirYamlFiles) {
313
362
  const isDefault = f.name === defaultName;
314
363
  choices.push({
315
- name: `📄 片段目录配置 → ${f.name}${isDefault ? '(默认)' : ''}`,
364
+ name: `[片段目录] ${f.name}${isDefault ? ' (默认)' : ''}`,
316
365
  value: { type: 'file', path: f.path, name: f.name },
317
366
  });
318
367
  if (isDefault && !cwdConfig) {
@@ -321,8 +370,8 @@ async function promptConfigForInteractive(snippetDirYamlFiles) {
321
370
  }
322
371
 
323
372
  choices.push(
324
- { name: '📁 输入自定义路径...', value: { type: 'custom' } },
325
- { name: ' 跳过(不使用项目配置)', value: { type: 'skip' } },
373
+ { name: '[自定义] 输入路径...', value: { type: 'custom' } },
374
+ { name: '[跳过] 不使用项目配置', value: { type: 'skip' } },
326
375
  );
327
376
 
328
377
  const result = await select({
@@ -357,8 +406,82 @@ async function promptTemplate(templateFiles) {
357
406
  });
358
407
  }
359
408
 
409
+ // ─── 输出文件名处理 ───────────────────────────────────────────────────────────
410
+
411
+ /**
412
+ * 检查指定 baseName 在 cwd 下是否已有输出文件冲突
413
+ * 返回冲突的文件路径列表
414
+ */
415
+ function checkOutputConflicts(baseName, cwd) {
416
+ const suffixes = ['.raw.html', '.html', '.output.html', '.minified.html'];
417
+ return suffixes
418
+ .map((s) => path.join(cwd, baseName + s))
419
+ .filter((p) => fs.existsSync(p));
420
+ }
421
+
422
+ /**
423
+ * 查找下一个可用版本号(baseName-v1, baseName-v2, ...)
424
+ */
425
+ function findNextVersion(baseName, cwd) {
426
+ let v = 1;
427
+ while (true) {
428
+ const candidate = baseName + '-v' + v;
429
+ if (checkOutputConflicts(candidate, cwd).length === 0) {
430
+ return candidate;
431
+ }
432
+ v++;
433
+ }
434
+ }
435
+
436
+ /**
437
+ * 交互式输出文件名提示
438
+ * 返回最终确定的 baseName
439
+ */
440
+ async function promptOutputName(defaultBaseName, cwd) {
441
+ const { input, select } = await import('@inquirer/prompts');
442
+
443
+ let baseName = await input({
444
+ message: '请输入输出文件名:',
445
+ default: defaultBaseName,
446
+ });
447
+
448
+ // 冲突检测循环
449
+ while (true) {
450
+ const conflicts = checkOutputConflicts(baseName, cwd);
451
+ if (conflicts.length === 0) break;
452
+
453
+ console.log(chalk.yellow(`\n ⚠ 以下文件已存在:`));
454
+ conflicts.forEach((f) => console.log(chalk.gray(` ${path.basename(f)}`)));
455
+
456
+ const action = await select({
457
+ message: '文件已存在,请选择处理方式:',
458
+ choices: [
459
+ { name: '覆盖现有文件', value: 'overwrite' },
460
+ { name: `自动版本号(${findNextVersion(baseName, cwd)})`, value: 'version' },
461
+ { name: '重新输入文件名', value: 'rename' },
462
+ ],
463
+ });
464
+
465
+ if (action === 'overwrite') {
466
+ break;
467
+ } else if (action === 'version') {
468
+ baseName = findNextVersion(baseName, cwd);
469
+ console.log(chalk.green(` ✔ 已自动生成版本号:${baseName}`));
470
+ break;
471
+ } else {
472
+ baseName = await input({
473
+ message: '请重新输入输出文件名:',
474
+ default: baseName,
475
+ });
476
+ }
477
+ }
478
+
479
+ return baseName;
480
+ }
481
+
360
482
  async function promptConfirm(summary) {
361
483
  const { confirm } = await import('@inquirer/prompts');
484
+ const outPaths = resolveSnippetOutputPaths(summary.outputBaseName, summary.outputDir);
362
485
  console.log('\n' + chalk.cyan('═══════════════════════════════════════════'));
363
486
  console.log(chalk.bold(' 片段组装汇总'));
364
487
  console.log(chalk.cyan('═══════════════════════════════════════════'));
@@ -369,8 +492,8 @@ async function promptConfirm(summary) {
369
492
  console.log(` 配置 YAML: ${chalk.green(summary.configFile)}`);
370
493
  console.log(chalk.gray('───────────────────────────────────────────'));
371
494
  console.log(` 输出目录: ${chalk.cyan(summary.outputDir)}`);
495
+ console.log(` 输出文件名: ${chalk.green(summary.outputBaseName)}`);
372
496
  console.log(` 输出文件:`);
373
- const outPaths = resolveSnippetOutputPaths(summary.snippetPath, summary.outputDir);
374
497
  console.log(` ${chalk.green('·')} ${path.basename(outPaths.raw)} ${chalk.gray('(未渲染,无 CSS 内联)')}`);
375
498
  console.log(` ${chalk.green('·')} ${path.basename(outPaths.normal)} ${chalk.gray('(已渲染,无 CSS 内联)')}`);
376
499
  console.log(` ${chalk.green('·')} ${path.basename(outPaths.output)} ${chalk.gray('(Juice CSS 内联)')}`);
@@ -392,7 +515,7 @@ async function promptConfirm(summary) {
392
515
  * - 配置文件:自动检测片段目录下的 juice.yaml / juice.yml,-c 可覆盖
393
516
  * - 合并顺序:项目默认 → 用户目录 → 片段目录配置 → CLI -c
394
517
  */
395
- async function runSnippetMode({ snippet, template, config: cliConfigPath }) {
518
+ async function runSnippetMode({ snippet, template, config: cliConfigPath, outputName }) {
396
519
  const snippetPath = path.resolve(snippet);
397
520
  if (!fs.existsSync(snippetPath)) {
398
521
  console.error(chalk.red(`片段文件不存在:${snippetPath}`));
@@ -437,12 +560,44 @@ async function runSnippetMode({ snippet, template, config: cliConfigPath }) {
437
560
 
438
561
  const { config, layers } = buildSnippetConfig({ priorityConfigPath, cliConfigPath });
439
562
 
563
+ // 确定输出文件名
564
+ const defaultBaseName = outputName || path.parse(templatePath).name;
565
+ let outputBaseName;
566
+
567
+ if (outputName) {
568
+ // 命令行指定了 --name,检查冲突,有冲突则自动版本号
569
+ const conflicts = checkOutputConflicts(defaultBaseName, process.cwd());
570
+ if (conflicts.length > 0) {
571
+ outputBaseName = findNextVersion(defaultBaseName, process.cwd());
572
+ console.warn(chalk.yellow(
573
+ `\n⚠ 文件冲突,自动使用版本号:${outputBaseName}`
574
+ ));
575
+ } else {
576
+ outputBaseName = defaultBaseName;
577
+ }
578
+ } else if (template) {
579
+ // 非交互模式(--snippet + -f 均指定),自动版本号
580
+ const conflicts = checkOutputConflicts(defaultBaseName, process.cwd());
581
+ if (conflicts.length > 0) {
582
+ outputBaseName = findNextVersion(defaultBaseName, process.cwd());
583
+ console.warn(chalk.yellow(
584
+ `\n⚠ 文件冲突,自动使用版本号:${outputBaseName}`
585
+ ));
586
+ } else {
587
+ outputBaseName = defaultBaseName;
588
+ }
589
+ } else {
590
+ // 交互模式(--snippet 单独指定),提示用户输入
591
+ outputBaseName = await promptOutputName(defaultBaseName, process.cwd());
592
+ }
593
+
440
594
  await assembleSnippet({
441
595
  snippetPath,
442
596
  templatePath,
443
597
  config,
444
598
  layers,
445
599
  cwd: process.cwd(),
600
+ outputBaseName,
446
601
  });
447
602
  }
448
603
 
@@ -493,7 +648,11 @@ async function runInteractiveMode({ config: cliConfigPath }) {
493
648
  configFileName = configChoice.name;
494
649
  }
495
650
 
496
- // 6. 汇总确认
651
+ // 6. 确定输出文件名
652
+ const defaultBaseName = path.parse(templateChoice.path).name;
653
+ const outputBaseName = await promptOutputName(defaultBaseName, process.cwd());
654
+
655
+ // 7. 汇总确认
497
656
  const confirmed = await promptConfirm({
498
657
  brand: brand.name,
499
658
  templateFile: templateChoice.name,
@@ -502,6 +661,7 @@ async function runInteractiveMode({ config: cliConfigPath }) {
502
661
  configFile: configFileName,
503
662
  snippetPath: snippetFile.path,
504
663
  outputDir: process.cwd(),
664
+ outputBaseName,
505
665
  });
506
666
 
507
667
  if (!confirmed) {
@@ -509,7 +669,7 @@ async function runInteractiveMode({ config: cliConfigPath }) {
509
669
  return;
510
670
  }
511
671
 
512
- // 7. 构建配置并执行
672
+ // 8. 构建配置并执行
513
673
  const { config, layers } = buildSnippetConfig({ priorityConfigPath, cliConfigPath });
514
674
 
515
675
  await assembleSnippet({
@@ -518,6 +678,7 @@ async function runInteractiveMode({ config: cliConfigPath }) {
518
678
  config,
519
679
  layers,
520
680
  cwd: process.cwd(),
681
+ outputBaseName,
521
682
  });
522
683
  }
523
684