siluzan-tso-cli 1.1.53-beta.5 → 1.1.53-beta.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/README.md CHANGED
@@ -51,7 +51,7 @@ siluzan-tso init -d /path/to/skills # 写入自定义目录
51
51
  siluzan-tso init --force # 强制覆盖已存在文件
52
52
  ```
53
53
 
54
- > **注意**:当前为测试版(1.1.53-beta.5),供内部测试使用。正式发布后安装命令将改为 `npm install -g siluzan-tso-cli`。
54
+ > **注意**:当前为测试版(1.1.53-beta.6),供内部测试使用。正式发布后安装命令将改为 `npm install -g siluzan-tso-cli`。
55
55
 
56
56
  | 助手 | 建议 `--ai` |
57
57
  | ----------------------- | ------------------------------------ |
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "slug": "siluzan-tso",
3
- "version": "1.1.53-beta.5",
4
- "publishedAt": 1789876126053
3
+ "version": "1.1.53-beta.6",
4
+ "publishedAt": 1789897587250
5
5
  }
@@ -24,6 +24,8 @@
24
24
 
25
25
  先结论,再表,最后路线图和风险。每条建议至少挂一项依据:目标 / 历史表现 / 业务战略 / 资源约束。缺数据的市场、类型、受众、词 → 写**验证计划**,禁止直接扩量。
26
26
 
27
+ render 会把九章铺成看板,不必为版式再写一份:`goals` → 五卡/目标分层;`budget` → 配比纪律;`structure`+`countries` → 加/守/砍与搜索结构;`keywords`/`creative` → 搜索打法;`exploreAds` → 非搜索四卡;`audience` → 受众步骤;`roadmap` → 月度阶梯 + 三月路线图;`risks` → 周节奏页脚注。金额与国家动作由 collect 算,Agent 只写判断。某章没写就标「待业务确认」,**不要**用通用运营清单填满版式。周节奏表是通用巡检。
28
+
27
29
  | 序 | 键 | 章节 | 最低要写 |
28
30
  | -- | -- | ---- | -------- |
29
31
  | 1 | `analysis` | 季度投放结论 | 3~5 条:主攻方向、重点市场、核心广告类型、主要建设任务 |
@@ -1,7 +1,7 @@
1
1
  # Google 季度经营报告
2
2
 
3
- > collect 只出事实;Agent 只写 `narrative`;`render` 注入 `google-quarterly-report.html`(大族版式 + ECharts)。
4
- > **禁止** Read HTML 模板。行业数据不做。新探索(视频 / PMax / 发现 / 展示)全无量则整章隐藏。
3
+ > collect 只出事实;Agent 只写 `narrative`;`render` 注入 `google-quarterly-report.html`(大族分页版式 + ECharts)。
4
+ > **禁止** Read HTML 模板。行业数据不做。展会 / 蜜月期服务页不自动出。新探索(视频 / PMax / 发现 / 展示)全无量则整章隐藏。
5
5
  >
6
6
  > **下期规划**:写 `narrative.plan` 前必读 `report-templates/google-quarterly-plan.md`(九章 + Q01–Q30)。规划只消费 collect,不编造预算/CPA/询盘,不写自动调价/暂停。
7
7
  >
@@ -120,3 +120,7 @@ Agent JSON Schema:`assets/google-quarterly-report.schema.json`。
120
120
  ```
121
121
 
122
122
  缺 `executiveSummary` / `sections.*.analysis` / `plan.analysis`(或 `plan.goals` / `plan.text`)时 render 失败。九章缺章不拒渲染,但规划不合格。
123
+
124
+ HTML 章节(render 自动分页,勿手写 HTML):封面 → 浏览逻辑 → 核心结论 → ①效率 ②类型 ③区域 ④国家 ⑤下钻 ⑥账户 → 新探索 tabs → 总结 → 规划核心(五卡 + 月度阶梯)→ 预算配比 → 国家加/守/砍 → 搜索打法 → 非搜索清单 → 受众 → 周节奏 → 谢谢。
125
+
126
+ 规划页数字只来自 collect:`planDefaults` 三档与月拆、`countries` 诊断出加/守/砍、`spendByChannel` 出本期类型占比。`narrative.plan` 九章填目标分层、执行要点、路线图;**禁止**为填满版式编造询盘/CPA/行业数。九章缺章标「待业务确认」,不塞通用运营话术。周节奏表是通用巡检,不是本户规划。`narrative.agenda` 可覆盖浏览逻辑四步说明。
@@ -24,6 +24,8 @@
24
24
 
25
25
  先结论,再表,最后路线图和风险。每条建议至少挂一项依据:目标 / 历史表现 / 业务战略 / 资源约束。缺数据的市场、类型、受众、词 → 写**验证计划**,禁止直接扩量。
26
26
 
27
+ render 会把九章铺成看板,不必为版式再写一份:`goals` → 五卡/目标分层;`budget` → 配比纪律;`structure`+`countries` → 加/守/砍与搜索结构;`keywords`/`creative` → 搜索打法;`exploreAds` → 非搜索四卡;`audience` → 受众步骤;`roadmap` → 月度阶梯 + 三月路线图;`risks` → 周节奏页脚注。金额与国家动作由 collect 算,Agent 只写判断。某章没写就标「待业务确认」,**不要**用通用运营清单填满版式。周节奏表是通用巡检。
28
+
27
29
  | 序 | 键 | 章节 | 最低要写 |
28
30
  | -- | -- | ---- | -------- |
29
31
  | 1 | `analysis` | 季度投放结论 | 3~5 条:主攻方向、重点市场、核心广告类型、主要建设任务 |
@@ -171,9 +171,144 @@
171
171
  background: var(--brand-soft); color: var(--brand); font-weight: 500;
172
172
  }
173
173
  .explore-meta-row .tag-pill.muted { background: var(--line-light); color: var(--text-3); }
174
+ .kpi-row.c5 { grid-template-columns: repeat(5, minmax(0, 1fr)); }
174
175
  .kpi-row.c4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
175
- @media (max-width: 860px) { .kpi-row.c4 { grid-template-columns: 1fr 1fr; } }
176
+ .kpi-row.c3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
177
+ @media (max-width: 1000px) {
178
+ .kpi-row.c7, .kpi-row.c5 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
179
+ }
180
+ @media (max-width: 860px) {
181
+ .kpi-row.c4, .kpi-row.c3 { grid-template-columns: 1fr 1fr; }
182
+ }
176
183
  .chart-box.sm { height: 240px; }
184
+ .chart-box.xs { height: 180px; }
185
+ .compare { display: grid; grid-template-columns: 1fr 1fr 1.2fr; gap: 12px; }
186
+ @media (max-width: 900px) { .compare { grid-template-columns: 1fr; } }
187
+ .panel {
188
+ border: 1px solid var(--line-light); border-radius: var(--radius-lg); overflow: hidden; background: #fff;
189
+ }
190
+ .panel .ph {
191
+ padding: 10px 14px; font-size: 13px; font-weight: 600;
192
+ background: var(--bg); border-bottom: 1px solid var(--line-light); color: var(--text);
193
+ }
194
+ .panel .ph.goal { color: var(--warning); background: var(--warning-soft); }
195
+ .panel .ph.result { color: var(--success); background: var(--success-soft); }
196
+ .panel .ph.mix { color: var(--brand); background: var(--brand-soft); }
197
+ .panel .pb { padding: 14px 16px; }
198
+ .panel .big { font-size: 28px; font-weight: 600; letter-spacing: -.02em; margin: 4px 0 8px; }
199
+ .panel .row { display: flex; justify-content: space-between; gap: 8px; padding: 6px 0; border-bottom: 1px dashed var(--line-light); font-size: 13px; }
200
+ .panel .row:last-child { border-bottom: 0; }
201
+ .panel .row .k { color: var(--text-3); }
202
+ .panel .row .v { color: var(--text); font-weight: 500; text-align: right; }
203
+ .chart-grid.vs { grid-template-columns: 1fr auto 1fr; align-items: center; }
204
+ @media (max-width: 860px) { .chart-grid.vs { grid-template-columns: 1fr; } }
205
+ .vs-mid { display: flex; align-items: center; justify-content: center; }
206
+ .vs-mid span {
207
+ width: 42px; height: 42px; border-radius: 50%;
208
+ display: flex; align-items: center; justify-content: center;
209
+ background: var(--brand-soft); color: var(--brand); font-weight: 600; font-size: 13px;
210
+ border: 1px solid #BACEFD;
211
+ }
212
+ .adtype-layout { display: grid; grid-template-columns: 260px 1fr; gap: 14px; align-items: stretch; }
213
+ @media (max-width: 860px) { .adtype-layout { grid-template-columns: 1fr; } }
214
+ .invest-card {
215
+ border: 1px solid var(--line-light); border-radius: var(--radius-lg); background: #fff;
216
+ padding: 18px 16px; display: flex; flex-direction: column; min-height: 100%; box-shadow: var(--shadow);
217
+ }
218
+ .invest-card .ic-title {
219
+ font-size: 13px; font-weight: 600; color: var(--text); margin: 0 0 16px;
220
+ padding-bottom: 10px; border-bottom: 1px solid var(--line-light);
221
+ }
222
+ .invest-card .ic-label { font-size: 12px; color: var(--text-3); margin-bottom: 4px; }
223
+ .invest-card .ic-q3 { font-size: 28px; font-weight: 600; letter-spacing: -.02em; color: var(--text); margin: 0 0 16px; }
224
+ .invest-card .ic-row {
225
+ display: flex; justify-content: space-between; align-items: center;
226
+ padding: 10px 0; border-top: 1px dashed var(--line-light); font-size: 13px;
227
+ }
228
+ .invest-card .ic-row .k { color: var(--text-3); }
229
+ .invest-card .ic-row .v { color: var(--text); font-weight: 600; }
230
+ .invest-card .ic-vs { margin-top: auto; padding-top: 14px; }
231
+ .invest-card .ic-vs-badge {
232
+ display: flex; flex-direction: column; gap: 4px; border-radius: var(--radius); padding: 12px 14px;
233
+ }
234
+ .invest-card .ic-vs-badge.down { background: var(--danger-soft); border: 1px solid #F7C1BF; }
235
+ .invest-card .ic-vs-badge.up { background: var(--success-soft); border: 1px solid #B7E4B5; }
236
+ .invest-card .ic-vs-badge.flat { background: var(--bg); border: 1px solid var(--line-light); }
237
+ .invest-card .ic-vs-badge .vb-l { font-size: 12px; color: var(--text-3); }
238
+ .invest-card .ic-vs-badge .vb-v { font-size: 20px; font-weight: 600; }
239
+ .invest-card .ic-vs-badge.down .vb-v { color: var(--danger); }
240
+ .invest-card .ic-vs-badge.up .vb-v { color: var(--success); }
241
+ .invest-card .ic-note {
242
+ margin-top: 12px; font-size: 12px; color: var(--text-2); line-height: 1.5;
243
+ background: var(--bg); border-radius: 6px; padding: 10px 12px;
244
+ }
245
+ .two { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
246
+ .three { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; }
247
+ @media (max-width: 860px) { .two, .three { grid-template-columns: 1fr; } }
248
+ .strat {
249
+ border: 1px solid var(--line-light); border-radius: var(--radius); padding: 12px 14px; margin-bottom: 10px; background: #fff;
250
+ }
251
+ .strat h4 { margin: 0 0 4px; font-size: 13px; color: var(--brand); font-weight: 600; }
252
+ .strat p { margin: 0; font-size: 12px; color: var(--text-2); }
253
+ .pill { display: inline-block; font-size: 12px; padding: 1px 8px; border-radius: 4px; font-weight: 500; }
254
+ .pill.good { background: var(--success-soft); color: var(--success); }
255
+ .pill.ok { background: var(--brand-soft); color: var(--brand); }
256
+ .pill.bad { background: var(--danger-soft); color: var(--danger); }
257
+ .pill.warn { background: var(--warning-soft); color: #D97706; }
258
+ table.data th.left, table.data td.left { text-align: left; }
259
+ table.data tr.total td { background: #F5F6F7 !important; font-weight: 600; border-top: 1px solid var(--line); }
260
+ table.data tr.warn td { background: var(--warning-soft); }
261
+ table.data tr.danger td { background: var(--danger-soft); }
262
+ table.data tr.good td { background: var(--success-soft); }
263
+ .blk-grid { display: grid; grid-template-columns: 2fr 1fr; gap: 12px; }
264
+ @media (max-width: 800px) { .blk-grid { grid-template-columns: 1fr; } }
265
+ .blk { border: 1px solid var(--line-light); border-radius: var(--radius); padding: 14px 16px; background: #fff; }
266
+ .blk.blue { background: var(--brand-soft); border-color: #BACEFD; }
267
+ .blk.orange { background: var(--warning-soft); border-color: #FFD9A8; }
268
+ .blk h4 { margin: 0 0 8px; font-size: 13px; font-weight: 600; }
269
+ .blk ul { margin: 0; padding-left: 18px; color: var(--text-2); font-size: 13px; }
270
+ .steps { display: grid; gap: 10px; counter-reset: s; }
271
+ .step {
272
+ display: grid; grid-template-columns: 36px minmax(0, 1fr); gap: 12px;
273
+ border: 1px solid var(--line-light); border-radius: var(--radius); padding: 12px 14px; background: #fff;
274
+ align-items: start;
275
+ }
276
+ .step::before {
277
+ counter-increment: s; content: counter(s, decimal-leading-zero);
278
+ width: 36px; height: 36px; border-radius: 8px;
279
+ background: var(--brand-soft); color: var(--brand); font-weight: 600; font-size: 13px;
280
+ display: flex; align-items: center; justify-content: center;
281
+ grid-column: 1;
282
+ grid-row: 1 / -1;
283
+ }
284
+ .step > * { grid-column: 2; min-width: 0; }
285
+ .step h4 { margin: 0 0 4px; font-size: 13px; font-weight: 600; }
286
+ .step p { margin: 0; font-size: 12.5px; color: var(--text-2); line-height: 1.6; }
287
+ .step p:empty { display: none; }
288
+ .muted { color: var(--text-3); font-size: 12px; margin: 0 0 12px; }
289
+ .list-card .hl { color: var(--brand); font-weight: 600; }
290
+ .five { display: grid; grid-template-columns: repeat(5, 1fr); gap: 10px; }
291
+ @media (max-width: 900px) { .five { grid-template-columns: 1fr 1fr; } }
292
+ .fcard {
293
+ border: 1px solid var(--line-light); border-radius: var(--radius); background: #fff;
294
+ padding: 16px 10px; text-align: center; box-shadow: var(--shadow);
295
+ }
296
+ .fcard .ft { font-size: 13px; font-weight: 600; margin-top: 4px; }
297
+ .fcard .fs { font-size: 11px; color: var(--text-3); margin-top: 4px; }
298
+ .fcard.c1 { border-top: 3px solid var(--brand); }
299
+ .fcard.c2 { border-top: 3px solid var(--success); }
300
+ .fcard.c3 { border-top: 3px solid var(--warning); }
301
+ .fcard.c4 { border-top: 3px solid var(--info); }
302
+ .fcard.c5 { border-top: 3px solid var(--purple); }
303
+ .budget { display: grid; grid-template-columns: 1.2fr 1fr; gap: 18px; align-items: start; }
304
+ @media (max-width: 800px) { .budget { grid-template-columns: 1fr; } }
305
+ .ol-clean { margin: 0; padding-left: 18px; color: var(--text-2); }
306
+ .ol-clean li { margin: 8px 0; }
307
+ .cta {
308
+ margin-top: 14px; text-align: center; padding: 12px;
309
+ background: var(--brand-soft); color: var(--brand); border: 1px solid #BACEFD;
310
+ border-radius: var(--radius); font-weight: 500;
311
+ }
177
312
  .chapter {
178
313
  background: #fff; border: 1px solid var(--line-light); border-radius: var(--radius-lg);
179
314
  padding: 28px 28px 28px 32px; margin: 24px 0 16px; box-shadow: var(--shadow);
@@ -260,6 +395,7 @@
260
395
  <div class="topnav-inner">
261
396
  <div class="brand-mark"><i></i><span id="nav-brand">Google Ads</span></div>
262
397
  <a href="#cover">封面</a>
398
+ <a href="#agenda">浏览逻辑</a>
263
399
  <a href="#exec">核心结论</a>
264
400
  <a href="#s1">本期表现</a>
265
401
  <a href="#explore" id="nav-explore">新探索</a>
@@ -308,14 +444,77 @@
308
444
  return (n * 100).toFixed(2) + "%";
309
445
  }
310
446
 
311
- function deltaHtml(v) {
447
+ function deltaHtml(v, invert) {
312
448
  if (v == null || !Number.isFinite(Number(v))) return '<span class="flat">—</span>';
313
449
  const n = Number(v) * 100;
314
- const cls = n > 0 ? "up" : n < 0 ? "down" : "flat";
450
+ const better = invert ? n < 0 : n > 0;
451
+ const worse = invert ? n > 0 : n < 0;
452
+ const cls = better ? "up" : worse ? "down" : "flat";
315
453
  const arrow = n > 0 ? "▲" : n < 0 ? "▼" : "•";
316
454
  return `<span class="${cls}">${arrow} ${Math.abs(n).toFixed(1)}%</span>`;
317
455
  }
318
456
 
457
+ function quarterTag(range) {
458
+ if (!range || !range.start) return "";
459
+ const month = Number(String(range.start).slice(5, 7));
460
+ if (!Number.isFinite(month) || month < 1) return "";
461
+ return "Q" + Math.ceil(month / 3);
462
+ }
463
+
464
+ function nextQuarterTag(range) {
465
+ const tag = quarterTag(range);
466
+ if (!tag) return "下期";
467
+ const n = Number(tag.slice(1));
468
+ return n === 4 ? "Q1" : "Q" + (n + 1);
469
+ }
470
+
471
+ function compactCount(v) {
472
+ const n = Number(v);
473
+ if (!Number.isFinite(n)) return "—";
474
+ if (Math.abs(n) >= 10000) {
475
+ const wan = n / 10000;
476
+ const text = wan >= 10 ? wan.toFixed(1).replace(/\.0$/, "") : wan.toFixed(1);
477
+ return text + "万";
478
+ }
479
+ return Math.round(n).toLocaleString("en-US");
480
+ }
481
+
482
+ function firstLine(arr) {
483
+ const items = lines(arr);
484
+ return items[0] || "";
485
+ }
486
+
487
+ function restLines(arr) {
488
+ return lines(arr).slice(1);
489
+ }
490
+
491
+ function regionShare(map) {
492
+ const keys = ["欧美", "东南亚", "印度", "印尼", "中东", "非洲", "其他"];
493
+ const total = keys.reduce((s, k) => s + num(map && map[k]), 0);
494
+ if (!total) return "—";
495
+ const top = keys
496
+ .map((k) => ({ k, v: num(map && map[k]) }))
497
+ .filter((x) => x.v > 0)
498
+ .sort((a, b) => b.v - a.v)
499
+ .slice(0, 3)
500
+ .map((x) => x.k + " " + ((x.v / total) * 100).toFixed(0) + "%");
501
+ return top.join(" · ") || "—";
502
+ }
503
+
504
+ function topRegionName(map) {
505
+ const keys = ["欧美", "东南亚", "印度", "印尼", "中东", "非洲", "其他"];
506
+ let best = "";
507
+ let max = 0;
508
+ keys.forEach((k) => {
509
+ const v = num(map && map[k]);
510
+ if (v > max) {
511
+ max = v;
512
+ best = k;
513
+ }
514
+ });
515
+ return best;
516
+ }
517
+
319
518
  function lines(arr) {
320
519
  return (Array.isArray(arr) ? arr : []).map((x) => clientCopy(x)).filter(Boolean);
321
520
  }
@@ -399,58 +598,194 @@
399
598
  document.getElementById("nav-explore").classList.toggle("hidden", hideSpecial);
400
599
 
401
600
  const exec = lines(narrative.executiveSummary);
601
+ const curQ = quarterTag(meta.current) || "本期";
602
+ const prevQ = quarterTag(meta.previous) || "上期";
603
+ const nextQ = nextQuarterTag(meta.current);
604
+ const prev = kpis.previous || {};
605
+ const vsYoy = kpis.vsYoy || {};
606
+ const months = data.monthly || [];
607
+ const channelSum = {};
608
+ months.forEach((m) => {
609
+ Object.entries(m.spendByChannel || {}).forEach(([k, v]) => {
610
+ channelSum[k] = (channelSum[k] || 0) + num(v);
611
+ });
612
+ });
613
+ const channelTotal = Object.values(channelSum).reduce((s, v) => s + num(v), 0);
614
+ const searchShare = channelTotal > 0 ? channelSum["搜索"] / channelTotal : 0;
615
+ const regions = data.regions || {};
616
+ const regionCurrent = regions.current || {};
617
+ const regionPrevious = regions.previous || {};
618
+ const inquiryDelta = (kpis.inquiries != null && kpis.previousInquiries != null && kpis.previousInquiries > 0)
619
+ ? (kpis.inquiries - kpis.previousInquiries) / kpis.previousInquiries
620
+ : null;
621
+ const spendDelta = vs.spend;
622
+ const spendDeltaCls = spendDelta == null ? "flat" : spendDelta > 0 ? "up" : spendDelta < 0 ? "down" : "flat";
623
+ const topRegion = topRegionName(regionCurrent);
624
+ const regionMixText = regionShare(regionCurrent);
625
+ const watch = data.accountWatch || {};
626
+ const channelsLabel = Object.entries(channelSum)
627
+ .filter(([, v]) => num(v) > 0)
628
+ .map(([k]) => k)
629
+ .slice(0, 4)
630
+ .join(" / ") || "搜索";
402
631
  const root = document.getElementById("report-root");
403
632
  root.innerHTML = `
404
633
  <header class="cover" id="cover">
405
634
  <div class="cover-tag">GOOGLE ADS · QUARTERLY REPORT</div>
406
635
  <h1>${esc(name)}</h1>
407
- <p class="subtitle">季度数据汇总 &amp; 下期规划</p>
636
+ <p class="subtitle">${esc(curQ)} 季度数据汇总 &amp; ${esc(nextQ)} 规划</p>
408
637
  <div class="cover-meta">
409
638
  <span>账户:<strong>${esc(meta.accountId || "—")}</strong></span>
639
+ <span>渠道:<strong>${esc(channelsLabel)}</strong></span>
410
640
  <span>周期:<strong>${esc(periodLabel(meta.current))}</strong></span>
411
641
  <span>货币:<strong>${esc(currency || "—")}</strong></span>
412
642
  </div>
413
643
  </header>
644
+ ${agendaPage(narrative.agenda, hideSpecial)}
414
645
  <section class="page" id="exec">
415
- <div class="page-head"><h2>核心结论</h2></div>
416
- <ol class="list-card">${exec.map((x) => `<li>${esc(x)}</li>`).join("") || "<li>暂无结论</li>"}</ol>
646
+ <div class="page-head"><h2>核心结论</h2><span class="chip">总览</span></div>
647
+ ${exec[0] ? `<p class="lead">${esc(exec[0])}</p>` : ""}
648
+ <div class="kpi-row c4">
649
+ ${kpiCard(curQ + " 花费", compactMoney(cur.spend, currency), vs.spend)}
650
+ ${kpiCard(curQ + " 询盘", kpis.inquiries == null ? "—" : int(kpis.inquiries) + " 个", inquiryDelta)}
651
+ ${kpiCard("询盘成本", kpis.inquiryCost == null ? "—" : money(kpis.inquiryCost, currency), null)}
652
+ ${kpiCard("转化次数 YoY", vsYoy.conversions == null ? compactCount(cur.conversions) : deltaText(vsYoy.conversions), vsYoy.conversions)}
653
+ </div>
654
+ <div class="compare">
655
+ <div class="panel">
656
+ <div class="ph goal">目标</div>
657
+ <div class="pb">${goalRows(plan)}</div>
658
+ </div>
659
+ <div class="panel">
660
+ <div class="ph result">${esc(curQ)} 达成</div>
661
+ <div class="pb">
662
+ <div class="big">${kpis.inquiries == null ? "—" : int(kpis.inquiries) + " 个"}</div>
663
+ <div class="row"><span class="k">询盘成本</span><span class="v">${kpis.inquiryCost == null ? "—" : money(kpis.inquiryCost, currency)}</span></div>
664
+ <div class="row"><span class="k">主力区域</span><span class="v">${esc(topRegion || "—")}</span></div>
665
+ <div class="row"><span class="k">花费环比</span><span class="v">${deltaHtml(vs.spend)}</span></div>
666
+ </div>
667
+ </div>
668
+ <div class="panel">
669
+ <div class="ph mix">结构变化</div>
670
+ <div class="pb">
671
+ <div class="chart-box xs" id="chart-region-mini"></div>
672
+ <div class="muted" style="margin-top:8px">${esc(regionMixText)}</div>
673
+ </div>
674
+ </div>
675
+ </div>
676
+ ${exec.slice(1).length ? `<div class="insight"><div class="tag">一句话</div><div class="txt">${exec.slice(1).map((x) => esc(x)).join("<br/>")}</div></div>` : ""}
417
677
  </section>
418
678
  <section class="chapter" id="s1">
419
- <p class="num">CHAPTER 01</p>
420
- <h2>本期表现</h2>
679
+ <p class="num">PART 01</p>
680
+ <h2>${esc(curQ)} 季度数据表现</h2>
681
+ <p>先看效率是否变好,再看预算花在哪,最后看询盘从哪些国家来。</p>
421
682
  </section>
422
- <section class="page">
683
+ <section class="page" id="efficiency">
684
+ <div class="page-head"><h2>① 投放效率总览</h2><span class="chip">效率</span></div>
685
+ ${leadOrEmpty(sections.efficiency)}
686
+ <h3 class="st">${esc(curQ)} 核心 7 维(对标投放报表)</h3>
423
687
  <div class="kpi-row c7">
424
- ${kpiCard("花费", money(cur.spend, currency), vs.spend)}
425
- ${kpiCard("展示", int(cur.impressions), vs.impressions)}
426
- ${kpiCard("点击", int(cur.clicks), vs.clicks)}
427
- ${kpiCard("CTR", pct(cur.ctr), vs.ctr)}
428
- ${kpiCard("CPC", money(cur.cpc, currency), vs.cpc)}
429
- ${kpiCard("转化", int(cur.conversions), vs.conversions)}
430
- ${kpiCard("询盘", kpis.inquiries == null ? "—" : int(kpis.inquiries), null)}
688
+ ${kpiCard("消费", compactMoney(cur.spend, currency), vs.spend)}
689
+ ${kpiCard("展示次数", compactCount(cur.impressions), vs.impressions)}
690
+ ${kpiCard("点击次数", compactCount(cur.clicks), vs.clicks)}
691
+ ${kpiCard("点击率 CTR", pct(cur.ctr), vs.ctr)}
692
+ ${kpiCard("转化次数", compactCount(cur.conversions), vs.conversions)}
693
+ ${kpiCard("CPC", money(cur.cpc, currency), vs.cpc, true)}
694
+ ${kpiCard("CPA", money(cur.cpa, currency), vs.cpa, true)}
431
695
  </div>
432
- <h3 class="st">月度节奏</h3>
433
- <div class="chart-grid">
434
- <div class="chart-card"><p class="ct">月度花费</p><div class="chart-box" id="chart-month-spend"></div></div>
435
- <div class="chart-card"><p class="ct">广告类型花费</p><div class="chart-box" id="chart-adtype"></div></div>
696
+ <h3 class="st">询盘侧补充</h3>
697
+ <div class="kpi-row c5">
698
+ ${kpiCard("转化率 CVR", pct(cur.cvr), null)}
699
+ ${kpiCard("询盘数", kpis.inquiries == null ? "" : int(kpis.inquiries), inquiryDelta)}
700
+ ${kpiCard("询盘成本", kpis.inquiryCost == null ? "—" : money(kpis.inquiryCost, currency), null)}
701
+ ${kpiCard(prevQ + " 消费", compactMoney(prev.spend, currency), null)}
702
+ ${kpiCard(prevQ + " 询盘", kpis.previousInquiries == null ? "—" : int(kpis.previousInquiries), null)}
436
703
  </div>
437
- ${tableWrap(monthTable(data.monthly || [], currency))}
438
- <h3 class="st">区域询盘</h3>
704
+ <h3 class="st">月度全维度明细</h3>
705
+ ${tableWrap(monthTable(months, currency, curQ))}
439
706
  <div class="chart-grid">
440
- <div class="chart-card"><p class="ct">本期 vs 上期大区</p><div class="chart-box" id="chart-region"></div></div>
441
- <div class="chart-card"><p class="ct">国家花费 Top</p><div class="chart-box" id="chart-country"></div></div>
707
+ <div class="chart-card"><p class="ct">量级:消费 / 展示 / 点击 / 转化 / 询盘</p><div class="chart-box" id="chart-month-volume"></div></div>
708
+ <div class="chart-card"><p class="ct">效率:CTR / CVR / CPC / CPA / 询盘成本</p><div class="chart-box" id="chart-month-efficiency"></div></div>
709
+ </div>
710
+ ${analysisBlock(sections.efficiency, "重点")}
711
+ </section>
712
+ <section class="page" id="adtype">
713
+ <div class="page-head"><h2>② 预算花在哪(广告类型)</h2><span class="chip">预算结构</span></div>
714
+ ${leadOrEmpty(sections.adType)}
715
+ <div class="adtype-layout">
716
+ <div class="invest-card">
717
+ <div class="ic-title">总投资对比</div>
718
+ <div class="ic-label">${esc(curQ)} 总花费</div>
719
+ <div class="ic-q3">${compactMoney(cur.spend, currency)}</div>
720
+ <div class="ic-row"><span class="k">${esc(prevQ)} 总花费</span><span class="v">${compactMoney(prev.spend, currency)}</span></div>
721
+ <div class="ic-row"><span class="k">搜索端占比</span><span class="v">${searchShare ? (searchShare * 100).toFixed(0) + "%" : "—"}</span></div>
722
+ <div class="ic-vs">
723
+ <div class="ic-vs-badge ${spendDeltaCls}">
724
+ <span class="vb-l">环比变化 ${esc(curQ)} vs ${esc(prevQ)}</span>
725
+ <span class="vb-v">${spendDelta == null ? "—" : ((spendDelta > 0 ? "+" : "") + (spendDelta * 100).toFixed(2) + "%")}</span>
726
+ </div>
727
+ </div>
728
+ <div class="ic-note">${esc(firstLine(sections.adType && sections.adType.analysis) || "搜索守基本盘;非搜索看新探索。")}</div>
729
+ </div>
730
+ <div class="chart-card" style="margin:0;height:100%">
731
+ <p class="ct">分类型月度消费(堆积)</p>
732
+ <div class="chart-box sm" id="chart-adtype"></div>
733
+ </div>
734
+ </div>
735
+ ${analysisBlock(sections.adType, "重点")}
736
+ </section>
737
+ <section class="page" id="region">
738
+ <div class="page-head"><h2>③ 询盘从哪来(区域结构)</h2><span class="chip">区域结构</span></div>
739
+ ${leadOrEmpty(sections.region)}
740
+ <div class="kpi-row c3">
741
+ ${kpiCard(curQ + " 询盘", kpis.inquiries == null ? "—" : int(kpis.inquiries), null)}
742
+ ${kpiCard(prevQ + " 询盘", kpis.previousInquiries == null ? "—" : int(kpis.previousInquiries), null)}
743
+ <div class="kpi"><div class="label">变化</div><div class="val">${inquiryDelta == null ? "—" : ((inquiryDelta > 0 ? "+" : "") + (inquiryDelta * 100).toFixed(0) + "%")}</div></div>
744
+ </div>
745
+ <div class="chart-grid vs">
746
+ <div class="chart-card"><p class="ct">${esc(prevQ)} 区域询盘</p><div class="chart-box sm" id="chart-pie-prev"></div></div>
747
+ <div class="vs-mid"><span>VS</span></div>
748
+ <div class="chart-card"><p class="ct">${esc(curQ)} 区域询盘</p><div class="chart-box sm" id="chart-pie-cur"></div></div>
749
+ </div>
750
+ ${analysisBlock(sections.region, "重点")}
751
+ </section>
752
+ <section class="page" id="country">
753
+ <div class="page-head"><h2>④ 国家明细:消费 · 询盘 · 成本</h2><span class="chip">国家明细</span></div>
754
+ <p class="lead">按花费看预算落点,按询盘看产出,零询盘高消费国家单独标出。</p>
755
+ <div class="chart-card" style="margin-bottom:12px">
756
+ <p class="ct">Top 国家:消费 / 询盘 / 成本</p>
757
+ <div class="chart-box" style="height:320px" id="chart-country"></div>
442
758
  </div>
443
- ${tableWrap(countryTable(data.countries || [], currency))}
444
- ${showDrill ? `<h3 class="st">${esc(drillTitle)}</h3>${tableWrap(stateTable(data.usStates || [], adminLabel))}` : ""}
445
- ${analysisBlock(sections.efficiency, "投放效率")}
446
- ${analysisBlock(sections.adType, "广告类型")}
447
- ${analysisBlock(sections.region, "区域")}
448
- ${keywordBlock(data.keywordSeeds)}
759
+ ${tableWrap(countryTable(data.countries || [], currency, kpis.inquiryCost))}
760
+ ${analysisBlock(sections.region, "重点")}
449
761
  </section>
762
+ ${showDrill ? `
763
+ <section class="page" id="us">
764
+ <div class="page-head"><h2>⑤ ${esc(drillTitle)}</h2><span class="chip">${esc(adminLabel)}</span></div>
765
+ <p class="lead">消耗最高国家下钻到${esc(adminLabel)},找出流量集中区,避免广撒网。</p>
766
+ <div class="two">
767
+ <div>
768
+ <div class="strat"><h4>集中投放</h4><p>找到流量趋势地区,为成本控制与询盘提升找准方向。</p></div>
769
+ <div class="strat"><h4>降成本</h4><p>针对曝光+点击较高地区投放,避免广撒网。</p></div>
770
+ </div>
771
+ <div class="chart-card" style="margin:0">
772
+ <p class="ct">${esc(adminLabel)}展示 / 点击 / CTR</p>
773
+ <div class="chart-box" id="chart-us"></div>
774
+ </div>
775
+ </div>
776
+ ${tableWrap(stateTable(data.usStates || [], adminLabel))}
777
+ </section>` : ""}
778
+ ${accountWatchPage(watch, drillCountry)}
779
+ ${keywordBlock(data.keywordSeeds) ? `<section class="page" id="keywords-seed">${keywordBlock(data.keywordSeeds)}</section>` : ""}
450
780
  <section class="chapter explore-chapter ${hideSpecial ? "hidden" : ""}" id="explore">
451
- <p class="num">CHAPTER 01B · 新探索</p>
781
+ <p class="num">PART 01 · 新探索</p>
452
782
  <h2>广告探索</h2>
783
+ <p>基石(搜索)之外的试验线:非搜索广告按类型查看结果。</p>
453
784
  <div class="explore-switch-wrap">
785
+ <div class="explore-switch-head">
786
+ <div class="sw-title">在此切换新探索内容</div>
787
+ <div class="sw-rule">视频 · PMax · 发现 · 展示</div>
788
+ </div>
454
789
  <div class="explore-switch" id="exploreSwitch" role="tablist" aria-label="新探索切换">
455
790
  ${exploreTabButtons(exploreTypes, currency)}
456
791
  </div>
@@ -488,83 +823,42 @@
488
823
  </div>
489
824
  </section>
490
825
  <section class="chapter" id="s2">
491
- <p class="num">CHAPTER 02</p>
492
- <h2>总结</h2>
826
+ <p class="num">PART 02</p>
827
+ <h2>${esc(curQ)} 总结</h2>
828
+ <p>把亮点和风险讲清楚,再过渡到${esc(nextQ)}。</p>
493
829
  </section>
494
- <section class="page">
495
- ${analysisBlock(sections.summary, "总结")}
496
- </section>
497
- <section class="chapter" id="s3">
498
- <p class="num">CHAPTER 03</p>
499
- <h2>下期规划</h2>
500
- </section>
501
- <section class="page">
502
- <div class="budget-strip">
503
- <div class="budget-grid c3">
504
- <div class="bcell"><div class="p">${money(planDefaults.spendFloor ?? planDefaults.currentSpend, currency)}</div><div class="n">最低可执行 · 维持本期</div></div>
505
- <div class="bcell"><div class="p">${money(planDefaults.spendLow, currency)}</div><div class="n">建议档 +30%</div></div>
506
- <div class="bcell"><div class="p">${money(planDefaults.spendHigh, currency)}</div><div class="n">扩量档 +50%</div></div>
507
- </div>
508
- ${monthSplitTable(planDefaults, currency, meta.current)}
509
- </div>
510
- <div class="plan-stack">${planChapters(plan)}</div>
830
+ <section class="page" id="summary">
831
+ <div class="page-head"><h2>内容汇总:亮点与注意</h2><span class="chip">汇总</span></div>
832
+ ${summaryBlocks(exec, sections.summary)}
511
833
  </section>
834
+ ${planPages({
835
+ plan,
836
+ planDefaults,
837
+ currency,
838
+ meta,
839
+ curQ,
840
+ nextQ,
841
+ prevQ,
842
+ cur,
843
+ kpis,
844
+ channelSum,
845
+ searchShare,
846
+ countries: data.countries || [],
847
+ exploreTypes,
848
+ watch,
849
+ })}
512
850
  <section class="closing">
513
851
  <h2>谢谢</h2>
514
852
  <p>${esc(name)} · ${esc(periodLabel(meta.current))}</p>
515
853
  </section>
516
854
  `;
517
855
 
518
- const months = data.monthly || [];
519
- initChart(document.getElementById("chart-month-spend"), {
520
- color: PALETTE,
521
- tooltip: { trigger: "axis" },
522
- grid: { left: 48, right: 16, top: 24, bottom: 32 },
523
- xAxis: { type: "category", data: months.map((m) => m.label || m.yearMonth) },
524
- yAxis: { type: "value" },
525
- series: [{ type: "bar", name: "花费", data: months.map((m) => num((m.metrics || {}).spend)), barMaxWidth: 36 }],
526
- });
527
-
528
- const channelSum = {};
529
- months.forEach((m) => {
530
- Object.entries(m.spendByChannel || {}).forEach(([k, v]) => {
531
- channelSum[k] = (channelSum[k] || 0) + num(v);
532
- });
533
- });
534
- initChart(document.getElementById("chart-adtype"), {
535
- color: PALETTE,
536
- tooltip: { trigger: "item" },
537
- series: [{
538
- type: "pie",
539
- radius: ["36%", "68%"],
540
- data: Object.entries(channelSum).filter(([, v]) => v > 0).map(([name, value]) => ({ name, value })),
541
- }],
542
- });
543
-
544
- const regionKeys = ["欧美", "东南亚", "印度", "印尼", "中东", "非洲", "其他"];
545
- const regions = data.regions || {};
546
- initChart(document.getElementById("chart-region"), {
547
- color: PALETTE,
548
- tooltip: { trigger: "axis" },
549
- legend: legendTop(["本期", "上期"]),
550
- grid: { left: 40, right: 12, top: 44, bottom: 28 },
551
- xAxis: categoryAxis(regionKeys),
552
- yAxis: { type: "value" },
553
- series: [
554
- { type: "bar", name: "本期", data: regionKeys.map((k) => num((regions.current || {})[k])) },
555
- { type: "bar", name: "上期", data: regionKeys.map((k) => num((regions.previous || {})[k])) },
556
- ],
557
- });
558
-
559
- const topCountries = (data.countries || []).slice(0, 8);
560
- initChart(document.getElementById("chart-country"), {
561
- color: PALETTE,
562
- tooltip: { trigger: "axis" },
563
- grid: { left: 72, right: 16, top: 16, bottom: 24 },
564
- xAxis: { type: "value" },
565
- yAxis: { type: "category", data: topCountries.map((c) => c.country).reverse() },
566
- series: [{ type: "bar", data: topCountries.map((c) => num(c.spend)).reverse(), barMaxWidth: 18 }],
567
- });
856
+ initVolumeChart(months);
857
+ initEfficiencyChart(months, currency);
858
+ initStackedAdType(months);
859
+ initRegionPies(regionPrevious, regionCurrent, prevQ, curQ);
860
+ initCountryCombo(data.countries || []);
861
+ if (showDrill) initUsChart(data.usStates || []);
568
862
 
569
863
  if (!hideSpecial) {
570
864
  initExploreSwitch(normalizeExploreTypes(exploreTypes, data, special), {
@@ -840,8 +1134,461 @@
840
1134
  });
841
1135
  }
842
1136
 
843
- function kpiCard(label, value, delta) {
844
- return `<div class="kpi"><div class="label">${esc(label)}</div><div class="val">${value}</div><div class="delta">${deltaHtml(delta)}</div></div>`;
1137
+ function kpiCard(label, value, delta, invert) {
1138
+ return `<div class="kpi"><div class="label">${esc(label)}</div><div class="val">${value}</div><div class="delta">${deltaHtml(delta, invert)}</div></div>`;
1139
+ }
1140
+
1141
+ function deltaText(v) {
1142
+ if (v == null || !Number.isFinite(Number(v))) return "—";
1143
+ const n = Number(v) * 100;
1144
+ const sign = n > 0 ? "+" : n < 0 ? "−" : "";
1145
+ return sign + Math.abs(n).toFixed(1) + "%";
1146
+ }
1147
+
1148
+ function leadOrEmpty(block) {
1149
+ const text = firstLine(block && block.analysis);
1150
+ return text ? `<p class="lead">${esc(text)}</p>` : "";
1151
+ }
1152
+
1153
+ function agendaPage(agenda, hideSpecial) {
1154
+ const defaults = [
1155
+ { n: "STEP 01", t: "先看核心结论", d: "花费、询盘、成本达成情况总览。" },
1156
+ { n: "STEP 02", t: "拆解本期表现", d: "效率趋势 → 广告类型 → 区域/国家结构。" },
1157
+ { n: "STEP 03", t: hideSpecial ? "账户与国家" : "新探索专项", d: hideSpecial ? "国家明细、下钻与账户侧观察。" : "非搜索广告探索(可切换类型)。" },
1158
+ { n: "STEP 04", t: "总结与下期", d: "亮点风险 → 预算与动作清单。" },
1159
+ ];
1160
+ const extra = Array.isArray(agenda) ? agenda.map((x) => String(x).trim()).filter(Boolean) : [];
1161
+ const items = defaults.map((item, i) => extra[i] ? { ...item, d: extra[i] } : item);
1162
+ return `<section class="page" id="agenda">
1163
+ <div class="page-head"><h2>报告浏览逻辑</h2><span class="chip">阅读路径</span></div>
1164
+ <p class="lead">建议按「结论 → 证据 → 动作」顺序阅读:先看结果是否达标,再拆结构和专项,最后落到怎么干。</p>
1165
+ <div class="story">${items.map((x) => `<div class="story-item"><div class="n">${esc(x.n)}</div><div class="t">${esc(x.t)}</div><p class="d">${esc(x.d)}</p></div>`).join("")}</div>
1166
+ </section>`;
1167
+ }
1168
+
1169
+ function goalRows(plan) {
1170
+ const items = lines((plan && plan.goals && (plan.goals.analysis || plan.goals.goals)) || []);
1171
+ if (!items.length) {
1172
+ return `<div class="row"><span class="k">主目标</span><span class="v">待业务确认</span></div>
1173
+ <div class="row"><span class="k">询盘 / CPA</span><span class="v">不编造数字</span></div>
1174
+ <div class="row"><span class="k">市场</span><span class="v">按历史结构建议</span></div>`;
1175
+ }
1176
+ return items.slice(0, 4).map((x) => {
1177
+ const parts = x.split(/[::]/);
1178
+ const k = parts.length > 1 ? parts[0] : "目标";
1179
+ const v = parts.length > 1 ? parts.slice(1).join(":") : x;
1180
+ return `<div class="row"><span class="k">${esc(k)}</span><span class="v">${esc(v)}</span></div>`;
1181
+ }).join("");
1182
+ }
1183
+
1184
+ function accountWatchPage(watch, drillCountry) {
1185
+ const items = [];
1186
+ if (watch.searchImpressionShare != null && Number.isFinite(Number(watch.searchImpressionShare))) {
1187
+ items.push(`搜索展示份额 <span class="hl">${esc(pct(watch.searchImpressionShare))}</span>`);
1188
+ }
1189
+ if (Array.isArray(watch.topClickCountries) && watch.topClickCountries.length) {
1190
+ items.push(`点击最多国家:${watch.topClickCountries.map((x) => esc(x)).join("、")}`);
1191
+ }
1192
+ if (drillCountry) items.push(`消耗最高国家:<strong>${esc(drillCountry)}</strong>,已在下钻页展开`);
1193
+ if (!items.length) return "";
1194
+ return `<section class="page" id="account">
1195
+ <div class="page-head"><h2>⑥ 账户侧观察</h2><span class="chip">账户观察</span></div>
1196
+ <ul class="list-card">${items.map((x) => `<li>${x}</li>`).join("")}</ul>
1197
+ </section>`;
1198
+ }
1199
+
1200
+ function summaryBlocks(exec, summary) {
1201
+ const highlights = exec.slice(0, 4);
1202
+ const notes = lines((summary && (summary.analysis || summary.goals)) || []);
1203
+ const left = highlights.length ? highlights : notes.slice(0, 4);
1204
+ const right = notes.length ? notes : highlights.slice(0, 3);
1205
+ return `<div class="blk-grid">
1206
+ <div class="blk blue"><h4>核心结论</h4><ul>${(left.length ? left : ["暂无结论"]).map((x) => `<li>${esc(x)}</li>`).join("")}</ul></div>
1207
+ <div class="blk orange"><h4>亮点</h4><ul>${(right.length ? right : ["见上期数据"]).map((x) => `<li>${esc(x)}</li>`).join("")}</ul></div>
1208
+ </div>
1209
+ ${notes.length ? analysisBlock(summary, "注意") : ""}`;
1210
+ }
1211
+
1212
+ function planItems(block) {
1213
+ return lines((block && (block.analysis || block.goals)) || (typeof (block && block.text) === "string" ? [block.text] : []));
1214
+ }
1215
+
1216
+ /** 缺章占位,不走 clientCopy,避免把「待业务确认」前缀剥掉。 */
1217
+ function pending(reason) {
1218
+ return "待业务确认:" + String(reason || "本章未写入 narrative.plan").trim();
1219
+ }
1220
+
1221
+ function asList(items, emptyReason) {
1222
+ return items && items.length ? items : [pending(emptyReason)];
1223
+ }
1224
+
1225
+ function shareOf(part, total) {
1226
+ if (!total) return 0;
1227
+ return num(part) / num(total);
1228
+ }
1229
+
1230
+ function planPages(ctx) {
1231
+ const plan = ctx.plan || {};
1232
+ const defaults = ctx.planDefaults || {};
1233
+ const currency = ctx.currency;
1234
+ const channelTotal = Object.values(ctx.channelSum || {}).reduce((s, v) => s + num(v), 0);
1235
+ const mix = {
1236
+ search: shareOf((ctx.channelSum || {})["搜索"], channelTotal),
1237
+ pmax: shareOf((ctx.channelSum || {})["效果最大化"], channelTotal),
1238
+ video: shareOf((ctx.channelSum || {})["视频"], channelTotal),
1239
+ other: shareOf(num((ctx.channelSum || {})["发现"]) + num((ctx.channelSum || {})["展示"]), channelTotal),
1240
+ };
1241
+ const analysis = planItems(plan);
1242
+ const goals = planItems(plan.goals);
1243
+ const budgetNotes = planItems(plan.budget);
1244
+ const structure = planItems(plan.structure);
1245
+ const keywords = planItems(plan.keywords);
1246
+ const audience = planItems(plan.audience);
1247
+ const creative = planItems(plan.creative);
1248
+ const roadmap = planItems(plan.roadmap);
1249
+ const risks = planItems(plan.risks);
1250
+ const monthLabels = nextPeriodMonthLabels(ctx.meta && ctx.meta.current);
1251
+ const suggested = Array.isArray(defaults.monthSuggested) ? defaults.monthSuggested : [];
1252
+ const shares = (defaults.monthShares || [0.3, 0.35, 0.35]).map((n) => Math.round(Number(n) * 100) + "%");
1253
+
1254
+ const goalCards = [
1255
+ { cls: "c1", t: ctx.kpis.inquiries == null ? "询盘" : (int(ctx.kpis.inquiries) + " 询盘"), s: "本期对照,目标待业务确认" },
1256
+ { cls: "c2", t: ctx.kpis.inquiryCost == null ? "询盘成本" : money(ctx.kpis.inquiryCost, currency), s: "本期对照,不外推目标" },
1257
+ { cls: "c3", t: mix.search ? ((mix.search * 100).toFixed(0) + "% 搜索") : "搜索基本盘", s: "本期类型结构" },
1258
+ { cls: "c4", t: compactMoney(defaults.spendLow, currency), s: "建议档 · 本期 +30%" },
1259
+ { cls: "c5", t: goals[0] ? ellipsis(goals[0], 18) : "待业务确认", s: goals[1] ? ellipsis(goals[1], 22) : "主目标未写入 plan.goals" },
1260
+ ];
1261
+
1262
+ const ladderRows = monthLabels.map((label, i) => {
1263
+ const focus = roadmap[i] || pending("本月任务未写进 plan.roadmap");
1264
+ return `<tr>
1265
+ <td class="left">${esc(label)}</td>
1266
+ <td class="num">${suggested[i] == null ? "—" : money(suggested[i], currency)}</td>
1267
+ <td class="num">${shares[i] || "—"}</td>
1268
+ <td class="left">${esc(focus)}</td>
1269
+ </tr>`;
1270
+ }).join("");
1271
+
1272
+ const actionTable = countryActionTable(ctx.countries, ctx.kpis.inquiryCost, currency);
1273
+ const exploreCards = explorePlanCards(ctx.exploreTypes, channelTotal, currency, structure.concat(creative));
1274
+ const clickCountries = (ctx.watch && ctx.watch.topClickCountries) || [];
1275
+ const inquiryCountries = (ctx.countries || []).filter((r) => num(r.inquiries) > 0).slice(0, 5).map((r) => r.country);
1276
+ const dangerCountries = (ctx.countries || [])
1277
+ .filter((r) => countryDiagnosis(r, ctx.kpis.inquiryCost).cls === "danger")
1278
+ .slice(0, 3)
1279
+ .map((r) => r.country);
1280
+ const flowNote = clickCountries.length
1281
+ ? `区分「流量国」与「询盘国」:点击集中在 ${clickCountries.join("、")};询盘较多在 ${inquiryCountries.join("、") || "—"}。先看有效询盘再决定加搜索还是只做再营销。`
1282
+ : "加码只给近一期询盘成本低于账户均值的国家;零询盘高消费优先缩减。";
1283
+ const landingNotes = [
1284
+ dangerCountries.length ? `连续空耗:${dangerCountries.join("、")},见「加 / 守 / 砍」` : "",
1285
+ clickCountries.length ? `点击集中在 ${clickCountries.join("、")},先对有效询盘再决定加搜索` : "",
1286
+ ctx.meta && ctx.meta.drillCountry ? `消耗最高国家(${ctx.meta.drillCountry})已在下钻页展开` : "",
1287
+ ].filter(Boolean);
1288
+ const videoShareNote = mix.video
1289
+ ? `本期视频占比 ${(mix.video * 100).toFixed(1)}%,是否加码待业务确认。`
1290
+ : pending("本期视频无量,加码计划未写");
1291
+
1292
+ return `
1293
+ <section class="chapter" id="s3">
1294
+ <p class="num">PART 03</p>
1295
+ <h2>${esc(ctx.nextQ)} 数据规划</h2>
1296
+ <p>目标与月度节奏 → 预算与国家矩阵 → 搜索/非搜索动作 → 周巡检清单。</p>
1297
+ </section>
1298
+ <section class="page" id="q4">
1299
+ <div class="page-head"><h2>规划核心</h2><span class="chip">目标</span></div>
1300
+ ${analysis[0] ? `<p class="lead">${esc(analysis[0])}</p>` : `<p class="lead">${esc(pending("未写 plan.analysis。数字仍按本期结构与建议档展示,不编造询盘/CPA 目标。"))}</p>`}
1301
+ <div class="five">${goalCards.map((c) => `<div class="fcard ${c.cls}"><div class="ft">${esc(c.t)}</div><div class="fs">${esc(c.s)}</div></div>`).join("")}</div>
1302
+ ${goals.length ? `<h3 class="st">目标分层</h3><div class="steps">${goals.map((x) => `<div class="step"><div><h4>${esc(x)}</h4></div></div>`).join("")}</div>` : `<p class="muted">${esc(pending("未写 plan.goals"))}</p>`}
1303
+ <h3 class="st">月度目标阶梯(建议档花费,不是已批预算)</h3>
1304
+ <div class="table-wrap"><table class="data" style="min-width:640px">
1305
+ <thead><tr><th class="left">月份</th><th class="num">建议花费</th><th class="num">占比</th><th class="left">规划重点</th></tr></thead>
1306
+ <tbody>${ladderRows}</tbody>
1307
+ </table></div>
1308
+ ${analysis.slice(1).length ? `<div class="insight"><div class="tag">原则</div><div class="txt">${analysis.slice(1).map((x) => esc(x)).join("<br/>")}</div></div>` : `<div class="insight"><div class="tag">口径</div><div class="txt">加 / 守 / 砍只按本期花费与询盘成本算:零询盘高消费优先缩减;加量只给询盘成本低于账户均值的单元。询盘数/成本目标须写入 plan.goals。</div></div>`}
1309
+ </section>
1310
+ <section class="page" id="budget">
1311
+ <div class="page-head"><h2>预算怎么分</h2><span class="chip">预算配比</span></div>
1312
+ <div class="budget-strip" style="margin-bottom:16px">
1313
+ <div class="budget-grid c3">
1314
+ <div class="bcell"><div class="p">${money(defaults.spendFloor ?? defaults.currentSpend, currency)}</div><div class="n">最低可执行 · 维持本期</div></div>
1315
+ <div class="bcell"><div class="p">${money(defaults.spendLow, currency)}</div><div class="n">建议档 +30%</div></div>
1316
+ <div class="bcell"><div class="p">${money(defaults.spendHigh, currency)}</div><div class="n">扩量档 +50%</div></div>
1317
+ </div>
1318
+ </div>
1319
+ <div class="budget">
1320
+ <div>
1321
+ <h3 class="st" style="margin-top:0">询盘数量 + 质量</h3>
1322
+ <p class="muted">${esc(budgetNotes[0] || pending("未写核心市场分配"))}</p>
1323
+ <h3 class="st">品牌声量</h3>
1324
+ <p class="muted">${esc(budgetNotes[1] || videoShareNote)}</p>
1325
+ <h3 class="st">配比纪律</h3>
1326
+ <ul class="ol-clean">
1327
+ ${asList(budgetNotes.slice(2), "未写配比纪律").map((x) => `<li>${esc(x)}</li>`).join("")}
1328
+ </ul>
1329
+ </div>
1330
+ <div class="budget-grid">
1331
+ <div class="bcell"><div class="p">${mix.search ? ((mix.search * 100).toFixed(0) + "%") : "—"}</div><div class="n">搜索 · 本期占比</div></div>
1332
+ <div class="bcell"><div class="p">${mix.pmax ? ((mix.pmax * 100).toFixed(0) + "%") : "—"}</div><div class="n">效果最大化 · 本期占比</div></div>
1333
+ <div class="bcell"><div class="p">${mix.video ? ((mix.video * 100).toFixed(0) + "%") : "—"}</div><div class="n">视频 / YouTube · 本期占比</div></div>
1334
+ <div class="bcell"><div class="p">${mix.other ? ((mix.other * 100).toFixed(0) + "%") : "—"}</div><div class="n">发现 + 展示 · 本期占比</div></div>
1335
+ </div>
1336
+ </div>
1337
+ <div class="strat" style="margin-top:14px">
1338
+ <h4>类型职责(避免预算打架)</h4>
1339
+ <p>${esc(structure[0] || pending("未写各类型职责"))}</p>
1340
+ </div>
1341
+ </section>
1342
+ <section class="page" id="q4-region">
1343
+ <div class="page-head"><h2>区域与国家:加 / 守 / 砍</h2><span class="chip">结构优化</span></div>
1344
+ <p class="lead">用国家清单管预算,而不是只调账户总预算。动作由本期花费与询盘成本算出,不编造目标市场。</p>
1345
+ ${actionTable}
1346
+ <div class="insight"><div class="tag">提醒</div><div class="txt">${esc(flowNote)}</div></div>
1347
+ </section>
1348
+ <section class="page" id="q4-search">
1349
+ <div class="page-head"><h2>搜索优化打法(基本盘)</h2><span class="chip">${mix.search ? ((mix.search * 100).toFixed(0) + "% 本期") : "搜索"}</span></div>
1350
+ <div class="blk-grid">
1351
+ <div class="blk blue"><h4>词与结构</h4><ul>${asList(keywords, "未写 plan.keywords").map((x) => `<li>${esc(x)}</li>`).join("")}</ul></div>
1352
+ <div class="blk orange"><h4>账户结构</h4><ul>${asList(structure, "未写 plan.structure").map((x) => `<li>${esc(x)}</li>`).join("")}</ul></div>
1353
+ </div>
1354
+ <h3 class="st">落地页与线索质量</h3>
1355
+ <div class="two">
1356
+ <div class="steps">${asList(creative.slice(0, 3), "未写 plan.creative").map((x) => `<div class="step"><div><h4>${esc(x)}</h4></div></div>`).join("")}</div>
1357
+ <ul class="list-card">
1358
+ ${asList(landingNotes, "落地页与线索质量缺少国家/点击依据").map((x) => `<li>${esc(x)}</li>`).join("")}
1359
+ </ul>
1360
+ </div>
1361
+ </section>
1362
+ <section class="page" id="q4-nonssearch">
1363
+ <div class="page-head"><h2>非搜索:按类型执行清单</h2><span class="chip">探索落地</span></div>
1364
+ <p class="lead">承接新探索结论:有量的类型写动作,无量的类型只写验证计划,不直接扩。</p>
1365
+ <div class="story">${exploreCards}</div>
1366
+ ${creative.slice(3).length ? `<div class="insight" style="margin-top:12px"><div class="tag">素材</div><div class="txt">${creative.slice(3).map((x) => esc(x)).join("<br/>")}</div></div>` : ""}
1367
+ </section>
1368
+ <section class="page" id="audience">
1369
+ <div class="page-head"><h2>受众与运营动作</h2><span class="chip">受众</span></div>
1370
+ <div class="two">
1371
+ <div class="steps">${asList(audience, "未写 plan.audience").map((x) => `<div class="step"><div><h4>${esc(x)}</h4></div></div>`).join("")}</div>
1372
+ <div>
1373
+ <h3 class="st" style="margin-top:0">视频口径(不是本户规划)</h3>
1374
+ <ul class="ol-clean">
1375
+ <li>只报 Ads 能拿到的曝光 / TrueView / 完播 / CPV</li>
1376
+ <li>不编造频道粉数、订阅来源饼、ABCD 素材组</li>
1377
+ <li>广告带来订阅有转化动作才展示</li>
1378
+ </ul>
1379
+ </div>
1380
+ </div>
1381
+ </section>
1382
+ <section class="page" id="q4-ops">
1383
+ <div class="page-head"><h2>周节奏与执行清单</h2><span class="chip">通用巡检</span></div>
1384
+ <p class="muted">下表是通用巡检节奏,不是本户专属规划。本户任务见三月路线图与 plan.roadmap。</p>
1385
+ <div class="table-wrap"><table class="data" style="min-width:640px">
1386
+ <thead><tr><th class="left">频率</th><th class="left">事项</th><th class="left">产出</th></tr></thead>
1387
+ <tbody>
1388
+ <tr><td class="left">每日</td><td class="left">消耗、掉量、拒审、异常 CPC</td><td class="left">异常当日记录</td></tr>
1389
+ <tr><td class="left">每周</td><td class="left">搜索词否词 + 国家/类型消耗结构</td><td class="left">周优化纪要</td></tr>
1390
+ <tr><td class="left">每周</td><td class="left">对照加 / 守 / 砍清单看零询盘国</td><td class="left">停投或重测名单</td></tr>
1391
+ <tr><td class="left">每月</td><td class="left">对照月度阶梯复盘建议档花费</td><td class="left">月度复盘</td></tr>
1392
+ </tbody>
1393
+ </table></div>
1394
+ <h3 class="st">三月路线图</h3>
1395
+ <div class="steps">${asList(roadmap, "未写 plan.roadmap").map((x, i) => `<div class="step"><div><h4>${esc(monthLabels[i] || ("阶段 " + (i + 1)))}</h4><p>${esc(x)}</p></div></div>`).join("")}</div>
1396
+ ${risks.length ? `<div class="insight" style="margin-top:12px"><div class="tag">风险</div><div class="txt">${risks.map((x) => esc(x)).join("<br/>")}</div></div>` : `<div class="insight" style="margin-top:12px"><div class="tag">风险</div><div class="txt">${esc(pending("未写 plan.risks"))}</div></div>`}
1397
+ <div class="cta">${esc(ctx.nextQ)} 先确认建议档与国家清单,再进入搭建或写命令。</div>
1398
+ </section>
1399
+ `;
1400
+ }
1401
+
1402
+ function countryActionTable(rows, avgCost, currency) {
1403
+ const mapped = (rows || []).slice(0, 20).map((r) => {
1404
+ const diag = countryDiagnosis(r, avgCost);
1405
+ let action = "守量";
1406
+ let exec = "保持观察,不盲目加预算";
1407
+ if (diag.cls === "danger") {
1408
+ action = "暂停/排查";
1409
+ exec = "停投或换落地页后小预算重测";
1410
+ } else if (diag.cls === "good") {
1411
+ action = "加码";
1412
+ exec = "优先给建议档增量,盯询盘质量";
1413
+ } else if (diag.cls === "warn") {
1414
+ action = "控量提质";
1415
+ exec = "收词、否词、控频次,先降成本再谈加量";
1416
+ }
1417
+ const basis = r.inquiries == null
1418
+ ? `花费 ${money(r.spend, currency)} · 无询盘口径`
1419
+ : `花费 ${money(r.spend, currency)} · 询盘 ${int(r.inquiries)} · 成本 ${r.inquiryCost == null ? "—" : money(r.inquiryCost, currency)}`;
1420
+ return `<tr class="${diag.cls}">
1421
+ <td class="left">${diag.pill}</td>
1422
+ <td class="left">${esc(r.country)}</td>
1423
+ <td class="left">${esc(basis)}</td>
1424
+ <td class="left">${esc(exec)}</td>
1425
+ </tr>`;
1426
+ }).join("");
1427
+ if (!mapped) return `<p class="muted">本期没有国家行,无法做加 / 守 / 砍。</p>`;
1428
+ return `<div class="table-wrap"><table class="data" style="min-width:680px">
1429
+ <thead><tr><th class="left">动作</th><th class="left">国家</th><th class="left">依据(本期)</th><th class="left">下期执行要点</th></tr></thead>
1430
+ <tbody>${mapped}</tbody>
1431
+ </table></div>`;
1432
+ }
1433
+
1434
+ function explorePlanCards(types, accountSpend, currency, hints) {
1435
+ const meta = [
1436
+ { id: "pmax", n: "01", t: "效果最大化" },
1437
+ { id: "video", n: "02", t: "视频 / YouTube" },
1438
+ { id: "demand", n: "03", t: "需求开发 / 发现" },
1439
+ { id: "display", n: "04", t: "展示" },
1440
+ ];
1441
+ const byId = {};
1442
+ (types || []).forEach((t) => { if (t && t.id) byId[t.id] = t; });
1443
+ return meta.map((m) => {
1444
+ const t = byId[m.id] || {};
1445
+ const spend = num(t.spend);
1446
+ const hint = hints.find((x) => x.includes(m.t) || x.toLowerCase().includes(m.id)) || "";
1447
+ const d = hint || (spend > 0 ? pending("未写本类型动作") : pending("本期无花费,验证计划未写"));
1448
+ return `<div class="story-item">
1449
+ <div class="n">${esc(m.n)}</div>
1450
+ <div class="t">${esc(m.t)}</div>
1451
+ <p class="d">${esc(compactMoney(spend, currency) + " · 占账户 " + shareText(spend, accountSpend))}<br/>${esc(d)}</p>
1452
+ </div>`;
1453
+ }).join("");
1454
+ }
1455
+
1456
+ function pieOption(map) {
1457
+ const keys = ["欧美", "东南亚", "印度", "印尼", "中东", "非洲", "其他"];
1458
+ const data = keys.map((name) => ({ name, value: num(map[name]) })).filter((d) => d.value > 0);
1459
+ return {
1460
+ color: PALETTE,
1461
+ tooltip: { trigger: "item" },
1462
+ series: [{
1463
+ type: "pie",
1464
+ radius: ["42%", "70%"],
1465
+ label: { formatter: (p) => ellipsis(p.name, 6) },
1466
+ data: data.length ? data : [{ name: "无数据", value: 1, itemStyle: { color: "#DEE0E3" } }],
1467
+ }],
1468
+ };
1469
+ }
1470
+
1471
+ function initVolumeChart(months) {
1472
+ initChart(document.getElementById("chart-month-volume"), {
1473
+ color: PALETTE,
1474
+ tooltip: { trigger: "axis" },
1475
+ legend: legendTop(["消费(千)", "展示(十万)", "点击(千)", "转化", "询盘"]),
1476
+ grid: { left: 48, right: 48, top: 44, bottom: 28 },
1477
+ xAxis: { type: "category", data: months.map((m) => m.label || m.yearMonth) },
1478
+ yAxis: [
1479
+ { type: "value", name: "消费/展示" },
1480
+ { type: "value", name: "点击/转化/询盘", splitLine: { show: false } },
1481
+ ],
1482
+ series: [
1483
+ { type: "bar", name: "消费(千)", data: months.map((m) => num((m.metrics || {}).spend) / 1000), barMaxWidth: 28 },
1484
+ { type: "line", name: "展示(十万)", data: months.map((m) => num((m.metrics || {}).impressions) / 100000), smooth: true },
1485
+ { type: "line", name: "点击(千)", yAxisIndex: 1, data: months.map((m) => num((m.metrics || {}).clicks) / 1000), smooth: true },
1486
+ { type: "line", name: "转化", yAxisIndex: 1, data: months.map((m) => num((m.metrics || {}).conversions)), smooth: true },
1487
+ { type: "line", name: "询盘", yAxisIndex: 1, data: months.map((m) => num(m.inquiries)), smooth: true },
1488
+ ],
1489
+ });
1490
+ }
1491
+
1492
+ function initEfficiencyChart(months, currency) {
1493
+ initChart(document.getElementById("chart-month-efficiency"), {
1494
+ color: PALETTE,
1495
+ tooltip: { trigger: "axis" },
1496
+ legend: legendTop(["CTR %", "CVR %", "CPC", "CPA", "询盘成本"]),
1497
+ grid: { left: 48, right: 48, top: 44, bottom: 28 },
1498
+ xAxis: { type: "category", data: months.map((m) => m.label || m.yearMonth) },
1499
+ yAxis: [
1500
+ { type: "value", name: "CTR/CVR %", axisLabel: { formatter: (v) => v.toFixed(1) } },
1501
+ { type: "value", name: "金额", splitLine: { show: false } },
1502
+ ],
1503
+ series: [
1504
+ { type: "line", name: "CTR %", data: months.map((m) => num((m.metrics || {}).ctr) * 100), smooth: true },
1505
+ { type: "line", name: "CVR %", data: months.map((m) => num((m.metrics || {}).cvr) * 100), smooth: true },
1506
+ { type: "line", name: "CPC", yAxisIndex: 1, data: months.map((m) => num((m.metrics || {}).cpc)), smooth: true },
1507
+ { type: "line", name: "CPA", yAxisIndex: 1, data: months.map((m) => num((m.metrics || {}).cpa)), smooth: true },
1508
+ { type: "line", name: "询盘成本", yAxisIndex: 1, data: months.map((m) => num(m.inquiryCost)), smooth: true, lineStyle: { type: "dashed" } },
1509
+ ],
1510
+ });
1511
+ }
1512
+
1513
+ function initStackedAdType(months) {
1514
+ const keys = ["搜索", "效果最大化", "视频", "发现", "展示", "其他"];
1515
+ initChart(document.getElementById("chart-adtype"), {
1516
+ color: PALETTE,
1517
+ tooltip: { trigger: "axis" },
1518
+ legend: legendTop(keys),
1519
+ grid: { left: 48, right: 16, top: 44, bottom: 28 },
1520
+ xAxis: { type: "category", data: months.map((m) => m.label || m.yearMonth) },
1521
+ yAxis: { type: "value" },
1522
+ series: keys.map((name) => ({
1523
+ type: "bar",
1524
+ name,
1525
+ stack: "ad",
1526
+ barMaxWidth: 36,
1527
+ data: months.map((m) => num((m.spendByChannel || {})[name])),
1528
+ })),
1529
+ });
1530
+ }
1531
+
1532
+ function initRegionPies(prevMap, curMap, prevQ, curQ) {
1533
+ initChart(document.getElementById("chart-pie-prev"), pieOption(prevMap || {}));
1534
+ initChart(document.getElementById("chart-pie-cur"), pieOption(curMap || {}));
1535
+ initChart(document.getElementById("chart-region-mini"), pieOption(curMap || {}));
1536
+ }
1537
+
1538
+ function initCountryCombo(rows) {
1539
+ const top = (rows || []).slice(0, 12);
1540
+ initChart(document.getElementById("chart-country"), {
1541
+ color: PALETTE,
1542
+ tooltip: { trigger: "axis" },
1543
+ legend: legendTop(["消费", "询盘数", "询盘成本"]),
1544
+ grid: { left: 48, right: 48, top: 44, bottom: 56 },
1545
+ xAxis: {
1546
+ type: "category",
1547
+ data: top.map((c) => c.country),
1548
+ axisLabel: { interval: 0, rotate: 30, formatter: (v) => ellipsis(v, 6) },
1549
+ },
1550
+ yAxis: [
1551
+ { type: "value", name: "消费/成本" },
1552
+ { type: "value", name: "询盘", splitLine: { show: false } },
1553
+ ],
1554
+ series: [
1555
+ { type: "bar", name: "消费", data: top.map((c) => num(c.spend)), barMaxWidth: 16 },
1556
+ { type: "bar", name: "询盘数", yAxisIndex: 1, data: top.map((c) => num(c.inquiries)), barMaxWidth: 16 },
1557
+ { type: "line", name: "询盘成本", data: top.map((c) => num(c.inquiryCost)), smooth: true },
1558
+ ],
1559
+ });
1560
+ }
1561
+
1562
+ function initUsChart(rows) {
1563
+ const top = (rows || []).slice(0, 10);
1564
+ initChart(document.getElementById("chart-us"), {
1565
+ color: PALETTE,
1566
+ tooltip: { trigger: "axis" },
1567
+ legend: legendTop(["展示", "点击", "CTR%"]),
1568
+ grid: { left: 48, right: 48, top: 44, bottom: 40 },
1569
+ xAxis: categoryAxis(top.map((r) => r.name)),
1570
+ yAxis: [
1571
+ { type: "value", name: "展示" },
1572
+ { type: "value", name: "点击 / CTR", splitLine: { show: false } },
1573
+ ],
1574
+ series: [
1575
+ { type: "bar", name: "展示", data: top.map((r) => num(r.impressions)), barMaxWidth: 16 },
1576
+ { type: "bar", name: "点击", yAxisIndex: 1, data: top.map((r) => num(r.clicks)), barMaxWidth: 16 },
1577
+ { type: "line", name: "CTR%", yAxisIndex: 1, data: top.map((r) => num(r.ctr) * 100), smooth: true },
1578
+ ],
1579
+ });
1580
+ }
1581
+
1582
+ function countryDiagnosis(row, avgCost) {
1583
+ if (row.inquiries === 0 && num(row.spend) > 0) {
1584
+ return { cls: "danger", pill: '<span class="pill bad">暂停/排查</span>' };
1585
+ }
1586
+ if (row.inquiryCost == null) return { cls: "", pill: "—" };
1587
+ const cost = num(row.inquiryCost);
1588
+ const baseline = avgCost && avgCost > 0 ? avgCost : 1000;
1589
+ if (cost < baseline * 0.6) return { cls: "good", pill: '<span class="pill good">高性价比</span>' };
1590
+ if (cost < baseline * 1.2) return { cls: "", pill: '<span class="pill ok">成本可控</span>' };
1591
+ return { cls: "warn", pill: '<span class="pill warn">成本偏高</span>' };
845
1592
  }
846
1593
 
847
1594
  function planChapters(plan) {
@@ -896,19 +1643,71 @@
896
1643
  return `<div class="table-wrap">${inner}</div>`;
897
1644
  }
898
1645
 
899
- function monthTable(rows, currency) {
1646
+ function monthTable(rows, currency, periodTag) {
1647
+ const totals = { spend: 0, impressions: 0, clicks: 0, conversions: 0, inquiries: 0 };
900
1648
  const body = rows.map((r) => {
901
1649
  const m = r.metrics || {};
902
- return `<tr><td>${esc(r.label || r.yearMonth)}</td><td class="num">${money(m.spend, currency)}</td><td class="num">${int(m.clicks)}</td><td class="num">${int(m.conversions)}</td><td class="num">${r.inquiries == null ? "—" : int(r.inquiries)}</td><td class="num">${r.inquiryCost == null ? "—" : money(r.inquiryCost, currency)}</td></tr>`;
1650
+ totals.spend += num(m.spend);
1651
+ totals.impressions += num(m.impressions);
1652
+ totals.clicks += num(m.clicks);
1653
+ totals.conversions += num(m.conversions);
1654
+ if (r.inquiries != null) totals.inquiries += num(r.inquiries);
1655
+ const cvr = num(m.cvr) || (num(m.clicks) > 0 ? num(m.conversions) / num(m.clicks) : 0);
1656
+ return `<tr>
1657
+ <td class="left">${esc(r.label || r.yearMonth)}</td>
1658
+ <td class="num">${money(m.spend, currency)}</td>
1659
+ <td class="num">${int(m.impressions)}</td>
1660
+ <td class="num">${int(m.clicks)}</td>
1661
+ <td class="num">${pct(m.ctr)}</td>
1662
+ <td class="num">${int(m.conversions)}</td>
1663
+ <td class="num">${pct(cvr)}</td>
1664
+ <td class="num">${money(m.cpc, currency)}</td>
1665
+ <td class="num">${money(m.cpa, currency)}</td>
1666
+ <td class="num">${r.inquiries == null ? "—" : int(r.inquiries)}</td>
1667
+ <td class="num">${r.inquiryCost == null ? "—" : money(r.inquiryCost, currency)}</td>
1668
+ </tr>`;
903
1669
  }).join("");
904
- return `<table class="data"><thead><tr><th>月份</th><th class="num">花费</th><th class="num">点击</th><th class="num">转化</th><th class="num">询盘</th><th class="num">询盘成本</th></tr></thead><tbody>${body}</tbody></table>`;
1670
+ const tCtr = totals.impressions > 0 ? totals.clicks / totals.impressions : 0;
1671
+ const tCvr = totals.clicks > 0 ? totals.conversions / totals.clicks : 0;
1672
+ const tCpc = totals.clicks > 0 ? totals.spend / totals.clicks : 0;
1673
+ const tCpa = totals.conversions > 0 ? totals.spend / totals.conversions : 0;
1674
+ const tInqCost = totals.inquiries > 0 ? totals.spend / totals.inquiries : null;
1675
+ const hasInq = rows.some((r) => r.inquiries != null);
1676
+ const totalRow = `<tr class="total">
1677
+ <td class="left">${esc(periodTag || "合计")}</td>
1678
+ <td class="num">${money(totals.spend, currency)}</td>
1679
+ <td class="num">${int(totals.impressions)}</td>
1680
+ <td class="num">${int(totals.clicks)}</td>
1681
+ <td class="num">${pct(tCtr)}</td>
1682
+ <td class="num">${int(totals.conversions)}</td>
1683
+ <td class="num">${pct(tCvr)}</td>
1684
+ <td class="num">${money(tCpc, currency)}</td>
1685
+ <td class="num">${money(tCpa, currency)}</td>
1686
+ <td class="num">${hasInq ? int(totals.inquiries) : "—"}</td>
1687
+ <td class="num">${tInqCost == null ? "—" : money(tInqCost, currency)}</td>
1688
+ </tr>`;
1689
+ return `<table class="data"><thead><tr>
1690
+ <th class="left">月份</th><th class="num">消费</th><th class="num">展示次数</th><th class="num">点击次数</th>
1691
+ <th class="num">点击率</th><th class="num">转化次数</th><th class="num">转化率</th>
1692
+ <th class="num">CPC</th><th class="num">CPA</th><th class="num">询盘数</th><th class="num">询盘成本</th>
1693
+ </tr></thead><tbody>${body}${totalRow}</tbody></table>`;
905
1694
  }
906
1695
 
907
- function countryTable(rows, currency) {
908
- const body = rows.slice(0, 20).map((r) =>
909
- `<tr><td>${esc(r.country)}</td><td class="num">${money(r.spend, currency)}</td><td class="num">${r.inquiries == null ? "—" : int(r.inquiries)}</td><td class="num">${r.inquiryCost == null ? "—" : money(r.inquiryCost, currency)}</td></tr>`
910
- ).join("");
911
- return `<table class="data"><thead><tr><th>国家</th><th class="num">花费</th><th class="num">询盘</th><th class="num">询盘成本</th></tr></thead><tbody>${body}</tbody></table>`;
1696
+ function countryTable(rows, currency, avgCost) {
1697
+ const body = rows.slice(0, 20).map((r) => {
1698
+ const diag = countryDiagnosis(r, avgCost);
1699
+ return `<tr class="${diag.cls}">
1700
+ <td class="left">${esc(r.country)}</td>
1701
+ <td class="num">${money(r.spend, currency)}</td>
1702
+ <td class="num">${r.inquiries == null ? "—" : int(r.inquiries)}</td>
1703
+ <td class="num">${r.inquiryCost == null ? "—" : money(r.inquiryCost, currency)}</td>
1704
+ <td class="left">${diag.pill}</td>
1705
+ </tr>`;
1706
+ }).join("");
1707
+ return `<table class="data"><thead><tr>
1708
+ <th class="left">国家</th><th class="num">消费</th><th class="num">询盘数</th>
1709
+ <th class="num">询盘成本</th><th class="left">诊断</th>
1710
+ </tr></thead><tbody>${body}</tbody></table>`;
912
1711
  }
913
1712
 
914
1713
  function stateTable(rows, adminLabel) {
@@ -1,7 +1,7 @@
1
1
  # Google 季度经营报告
2
2
 
3
- > collect 只出事实;Agent 只写 `narrative`;`render` 注入 `google-quarterly-report.html`(大族版式 + ECharts)。
4
- > **禁止** Read HTML 模板。行业数据不做。新探索(视频 / PMax / 发现 / 展示)全无量则整章隐藏。
3
+ > collect 只出事实;Agent 只写 `narrative`;`render` 注入 `google-quarterly-report.html`(大族分页版式 + ECharts)。
4
+ > **禁止** Read HTML 模板。行业数据不做。展会 / 蜜月期服务页不自动出。新探索(视频 / PMax / 发现 / 展示)全无量则整章隐藏。
5
5
  >
6
6
  > **下期规划**:写 `narrative.plan` 前必读 `report-templates/google-quarterly-plan.md`(九章 + Q01–Q30)。规划只消费 collect,不编造预算/CPA/询盘,不写自动调价/暂停。
7
7
  >
@@ -120,3 +120,7 @@ Agent JSON Schema:`assets/google-quarterly-report.schema.json`。
120
120
  ```
121
121
 
122
122
  缺 `executiveSummary` / `sections.*.analysis` / `plan.analysis`(或 `plan.goals` / `plan.text`)时 render 失败。九章缺章不拒渲染,但规划不合格。
123
+
124
+ HTML 章节(render 自动分页,勿手写 HTML):封面 → 浏览逻辑 → 核心结论 → ①效率 ②类型 ③区域 ④国家 ⑤下钻 ⑥账户 → 新探索 tabs → 总结 → 规划核心(五卡 + 月度阶梯)→ 预算配比 → 国家加/守/砍 → 搜索打法 → 非搜索清单 → 受众 → 周节奏 → 谢谢。
125
+
126
+ 规划页数字只来自 collect:`planDefaults` 三档与月拆、`countries` 诊断出加/守/砍、`spendByChannel` 出本期类型占比。`narrative.plan` 九章填目标分层、执行要点、路线图;**禁止**为填满版式编造询盘/CPA/行业数。九章缺章标「待业务确认」,不塞通用运营话术。周节奏表是通用巡检,不是本户规划。`narrative.agenda` 可覆盖浏览逻辑四步说明。
@@ -9,7 +9,7 @@ $ErrorActionPreference = 'Stop'
9
9
  # -- Package info (injected at build time) ------------------------------------
10
10
  $PKG_NAME = 'siluzan-tso-cli'
11
11
  # PKG_VERSION 锁定到与本脚本同批构建产物一致的版本,避免与 dist/skill 错位
12
- $PKG_VERSION = '1.1.53-beta.5'
12
+ $PKG_VERSION = '1.1.53-beta.6'
13
13
  $CLI_BIN = 'siluzan-tso'
14
14
  $SKILL_LABEL = 'Siluzan TSO'
15
15
  $INSTALL_CMD = 'npm install -g siluzan-tso-cli@beta'
@@ -9,7 +9,7 @@ set -euo pipefail
9
9
  # -- Package info (injected at build time) ------------------------------------
10
10
  readonly PKG_NAME="siluzan-tso-cli"
11
11
  # PKG_VERSION 锁定到与本脚本同批构建产物一致的版本,避免与 dist/skill 错位
12
- readonly PKG_VERSION="1.1.53-beta.5"
12
+ readonly PKG_VERSION="1.1.53-beta.6"
13
13
  readonly CLI_BIN="siluzan-tso"
14
14
  readonly SKILL_LABEL="Siluzan TSO"
15
15
  readonly INSTALL_CMD="npm install -g siluzan-tso-cli@beta"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "siluzan-tso-cli",
3
- "version": "1.1.53-beta.5",
3
+ "version": "1.1.53-beta.6",
4
4
  "description": "Siluzan 广告账户管理 CLI — 查询账户、余额、消耗数据,管理绑定关系与充值。",
5
5
  "keywords": [
6
6
  "ad-account",