siluzan-tso-cli 1.1.45-beta.2 → 1.1.45-beta.3

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.45-beta.2),供内部测试使用。正式发布后安装命令将改为 `npm install -g siluzan-tso-cli`。
54
+ > **注意**:当前为测试版(1.1.45-beta.3),供内部测试使用。正式发布后安装命令将改为 `npm install -g siluzan-tso-cli`。
55
55
 
56
56
  | 助手 | 建议 `--ai` |
57
57
  | ----------------------- | ------------------------------------ |
package/dist/index.js CHANGED
@@ -4503,17 +4503,57 @@ var init_currency_display = __esm({
4503
4503
  }
4504
4504
  });
4505
4505
 
4506
+ // src/commands/google-analysis/daily-metrics-range.ts
4507
+ function pad2(n) {
4508
+ return String(n).padStart(2, "0");
4509
+ }
4510
+ function formatChinaOffsetDateTime(d) {
4511
+ const sh = new Date(d.getTime() + CHINA_OFFSET_MS);
4512
+ return `${sh.getUTCFullYear()}-${pad2(sh.getUTCMonth() + 1)}-${pad2(sh.getUTCDate())}T${pad2(sh.getUTCHours())}:${pad2(sh.getUTCMinutes())}:${pad2(sh.getUTCSeconds())}+08:00`;
4513
+ }
4514
+ function defaultChinaDateRange(now = /* @__PURE__ */ new Date()) {
4515
+ const chinaNow = new Date(now.getTime() + CHINA_OFFSET_MS);
4516
+ chinaNow.setUTCDate(chinaNow.getUTCDate() - 1);
4517
+ const endY = chinaNow.getUTCFullYear();
4518
+ const endM = chinaNow.getUTCMonth();
4519
+ const endD = chinaNow.getUTCDate();
4520
+ const startChina = new Date(Date.UTC(endY, endM, endD - 6));
4521
+ const endChina = new Date(Date.UTC(endY, endM, endD));
4522
+ const fmt = (utcCal) => `${utcCal.getUTCFullYear()}-${pad2(utcCal.getUTCMonth() + 1)}-${pad2(utcCal.getUTCDate())}`;
4523
+ return { startDate: fmt(startChina), endDate: fmt(endChina) };
4524
+ }
4525
+ function resolveDailyMetricsQueryTimes(startDate, endDate, now = /* @__PURE__ */ new Date()) {
4526
+ const startInstant = `${startDate}T00:00:00+08:00`;
4527
+ const endOfDay = /* @__PURE__ */ new Date(`${endDate}T23:59:59+08:00`);
4528
+ const endInstant = endOfDay.getTime() > now.getTime() ? formatChinaOffsetDateTime(now) : formatChinaOffsetDateTime(endOfDay);
4529
+ return { startInstant, endInstant };
4530
+ }
4531
+ function resolveChinaQueryTimes(startDate, endDate, _now = /* @__PURE__ */ new Date()) {
4532
+ return { startInstant: startDate, endInstant: endDate };
4533
+ }
4534
+ var CHINA_OFFSET_MS;
4535
+ var init_daily_metrics_range = __esm({
4536
+ "src/commands/google-analysis/daily-metrics-range.ts"() {
4537
+ "use strict";
4538
+ CHINA_OFFSET_MS = 8 * 36e5;
4539
+ }
4540
+ });
4541
+
4506
4542
  // src/utils/balance.ts
4507
- function fmt(d) {
4508
- return d.toISOString().slice(0, 10);
4543
+ function pad22(n) {
4544
+ return String(n).padStart(2, "0");
4509
4545
  }
4510
- function dateRangeLastNDays(n) {
4546
+ function dateRangeLastNDays(n, now = /* @__PURE__ */ new Date()) {
4511
4547
  const days = Math.max(1, Math.floor(Number(n) || 1));
4512
- const end = /* @__PURE__ */ new Date();
4513
- end.setDate(end.getDate() - 1);
4514
- const start = new Date(end);
4515
- start.setDate(start.getDate() - (days - 1));
4516
- return { startDate: fmt(start), endDate: fmt(end) };
4548
+ const chinaNow = new Date(now.getTime() + CHINA_OFFSET_MS2);
4549
+ chinaNow.setUTCDate(chinaNow.getUTCDate() - 1);
4550
+ const endY = chinaNow.getUTCFullYear();
4551
+ const endM = chinaNow.getUTCMonth();
4552
+ const endD = chinaNow.getUTCDate();
4553
+ const startChina = new Date(Date.UTC(endY, endM, endD - (days - 1)));
4554
+ const endChina = new Date(Date.UTC(endY, endM, endD));
4555
+ const fmtChina = (utcCal) => `${utcCal.getUTCFullYear()}-${pad22(utcCal.getUTCMonth() + 1)}-${pad22(utcCal.getUTCDate())}`;
4556
+ return { startDate: fmtChina(startChina), endDate: fmtChina(endChina) };
4517
4557
  }
4518
4558
  function defaultDateRange() {
4519
4559
  return dateRangeLastNDays(7);
@@ -4709,9 +4749,10 @@ async function fetchOverviewMap(route, accountIds, config, startDate, endDate, v
4709
4749
  const end = endDate ?? range.endDate;
4710
4750
  try {
4711
4751
  if (route === "Google") {
4752
+ const { startInstant, endInstant } = resolveDailyMetricsQueryTimes(start, end);
4712
4753
  const params = new URLSearchParams({
4713
- startDate: start,
4714
- endDate: end,
4754
+ startDate: startInstant,
4755
+ endDate: endInstant,
4715
4756
  mediaCustomerIds: accountIds.join("|")
4716
4757
  });
4717
4758
  const url = `${config.apiBaseUrl}/report/media-account/google/account-spend-overview?${params}`;
@@ -4797,10 +4838,11 @@ async function fetchOverviewMap(route, accountIds, config, startDate, endDate, v
4797
4838
  }
4798
4839
  return result;
4799
4840
  }
4800
- var BALANCE_SUPPORTED_MEDIA;
4841
+ var BALANCE_SUPPORTED_MEDIA, CHINA_OFFSET_MS2;
4801
4842
  var init_balance = __esm({
4802
4843
  "src/utils/balance.ts"() {
4803
4844
  "use strict";
4845
+ init_daily_metrics_range();
4804
4846
  init_auth();
4805
4847
  BALANCE_SUPPORTED_MEDIA = [
4806
4848
  "Google",
@@ -4810,6 +4852,7 @@ var init_balance = __esm({
4810
4852
  "Kwai",
4811
4853
  "MetaAd"
4812
4854
  ];
4855
+ CHINA_OFFSET_MS2 = 8 * 36e5;
4813
4856
  }
4814
4857
  });
4815
4858
 
@@ -5525,42 +5568,6 @@ var init_accounts_digest2 = __esm({
5525
5568
  }
5526
5569
  });
5527
5570
 
5528
- // src/commands/google-analysis/daily-metrics-range.ts
5529
- function pad2(n) {
5530
- return String(n).padStart(2, "0");
5531
- }
5532
- function formatChinaOffsetDateTime(d) {
5533
- const sh = new Date(d.getTime() + CHINA_OFFSET_MS);
5534
- return `${sh.getUTCFullYear()}-${pad2(sh.getUTCMonth() + 1)}-${pad2(sh.getUTCDate())}T${pad2(sh.getUTCHours())}:${pad2(sh.getUTCMinutes())}:${pad2(sh.getUTCSeconds())}+08:00`;
5535
- }
5536
- function defaultChinaDateRange(now = /* @__PURE__ */ new Date()) {
5537
- const chinaNow = new Date(now.getTime() + CHINA_OFFSET_MS);
5538
- chinaNow.setUTCDate(chinaNow.getUTCDate() - 1);
5539
- const endY = chinaNow.getUTCFullYear();
5540
- const endM = chinaNow.getUTCMonth();
5541
- const endD = chinaNow.getUTCDate();
5542
- const startChina = new Date(Date.UTC(endY, endM, endD - 6));
5543
- const endChina = new Date(Date.UTC(endY, endM, endD));
5544
- const fmt2 = (utcCal) => `${utcCal.getUTCFullYear()}-${pad2(utcCal.getUTCMonth() + 1)}-${pad2(utcCal.getUTCDate())}`;
5545
- return { startDate: fmt2(startChina), endDate: fmt2(endChina) };
5546
- }
5547
- function resolveDailyMetricsQueryTimes(startDate, endDate, now = /* @__PURE__ */ new Date()) {
5548
- const startInstant = `${startDate}T00:00:00+08:00`;
5549
- const endOfDay = /* @__PURE__ */ new Date(`${endDate}T23:59:59+08:00`);
5550
- const endInstant = endOfDay.getTime() > now.getTime() ? formatChinaOffsetDateTime(now) : formatChinaOffsetDateTime(endOfDay);
5551
- return { startInstant, endInstant };
5552
- }
5553
- function resolveChinaQueryTimes(startDate, endDate, _now = /* @__PURE__ */ new Date()) {
5554
- return { startInstant: startDate, endInstant: endDate };
5555
- }
5556
- var CHINA_OFFSET_MS;
5557
- var init_daily_metrics_range = __esm({
5558
- "src/commands/google-analysis/daily-metrics-range.ts"() {
5559
- "use strict";
5560
- CHINA_OFFSET_MS = 8 * 36e5;
5561
- }
5562
- });
5563
-
5564
5571
  // src/utils/snapshot/async-lock.ts
5565
5572
  async function withAsyncLock(key, fn) {
5566
5573
  const prev = chains.get(key) ?? Promise.resolve();
@@ -108630,8 +108637,8 @@ function resolveFacebookDateRange(start, end) {
108630
108637
  endD.setDate(endD.getDate() - 1);
108631
108638
  const startD = new Date(endD);
108632
108639
  startD.setDate(startD.getDate() - 6);
108633
- const fmt2 = (d) => d.toISOString().slice(0, 10);
108634
- return { startDate: fmt2(startD), endDate: fmt2(endD) };
108640
+ const fmt = (d) => d.toISOString().slice(0, 10);
108641
+ return { startDate: fmt(startD), endDate: fmt(endD) };
108635
108642
  }
108636
108643
  console.error("\n\u274C --start \u4E0E --end \u987B\u540C\u65F6\u4F20\u5165\uFF0C\u6216\u540C\u65F6\u7701\u7565\u4EE5\u4F7F\u7528\u9ED8\u8BA4\u8FD1 7 \u5929\uFF08\u622A\u81F3\u6628\u5929\uFF09\u3002\n");
108637
108644
  process.exit(1);
@@ -108729,8 +108736,8 @@ function resolveMetaOverviewDateRange(start, end) {
108729
108736
  endD.setDate(endD.getDate() - 1);
108730
108737
  const startD = new Date(endD);
108731
108738
  startD.setDate(startD.getDate() - 6);
108732
- const fmt2 = (d) => d.toISOString().slice(0, 10);
108733
- return { startDate: fmt2(startD), endDate: fmt2(endD) };
108739
+ const fmt = (d) => d.toISOString().slice(0, 10);
108740
+ return { startDate: fmt(startD), endDate: fmt(endD) };
108734
108741
  }
108735
108742
  console.error("\n\u274C --start \u4E0E --end \u987B\u540C\u65F6\u4F20\u5165\uFF0C\u6216\u540C\u65F6\u7701\u7565\u4EE5\u4F7F\u7528\u9ED8\u8BA4\u8FD1 7 \u5929\uFF08\u622A\u81F3\u6628\u5929\uFF09\u3002\n");
108736
108743
  process.exit(1);
@@ -133252,8 +133259,8 @@ function resolveDateRange2(start, end) {
133252
133259
  endD.setDate(endD.getDate() - 1);
133253
133260
  const startD = new Date(endD);
133254
133261
  startD.setDate(startD.getDate() - 6);
133255
- const fmt2 = (d) => d.toISOString().slice(0, 10);
133256
- return { startDate: fmt2(startD), endDate: fmt2(endD) };
133262
+ const fmt = (d) => d.toISOString().slice(0, 10);
133263
+ return { startDate: fmt(startD), endDate: fmt(endD) };
133257
133264
  }
133258
133265
  console.error("\n\u274C --start \u4E0E --end \u987B\u540C\u65F6\u4F20\u5165\uFF0C\u6216\u540C\u65F6\u7701\u7565\u4EE5\u4F7F\u7528\u9ED8\u8BA4\u8FD1 7 \u5929\uFF08\u622A\u81F3\u6628\u5929\uFF09\u3002\n");
133259
133266
  process.exit(1);
@@ -134320,6 +134327,7 @@ function asRecord26(value) {
134320
134327
  return null;
134321
134328
  }
134322
134329
  function num10(value) {
134330
+ if (value === null || value === void 0 || value === "") return void 0;
134323
134331
  const n = Number(value);
134324
134332
  return Number.isFinite(n) ? n : void 0;
134325
134333
  }
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "slug": "siluzan-tso",
3
- "version": "1.1.45-beta.2",
4
- "publishedAt": 1786612533558
3
+ "version": "1.1.45-beta.3",
4
+ "publishedAt": 1786677354676
5
5
  }
@@ -24,7 +24,7 @@ siluzan-tso balance-scan -m <媒体类型> [选项]
24
24
  | `-m, --media <type>` | 必填:`Google \| TikTok \| Yandex \| MetaAd \| BingV2 \| Kwai` | — |
25
25
  | `-a, --accounts <ids>` | 指定 `mediaCustomerId`(逗号分隔);**跳过清单翻页**,对这些 ID 拉数后全部输出 | — |
26
26
  | `--threshold-days <n>` | 剩余续航天数阈值 | `7` |
27
- | `--spend-days <n>` | 日均消耗回看自然日数(截至昨天不含今天;日均 = 窗口合计 / N) | `7` |
27
+ | `--spend-days <n>` | 日均消耗回看自然日数(截至昨天不含今天,**北京时间**;日均 = 窗口合计 / N) | `7` |
28
28
  | `--min-balance <n>` | 绝对余额阈值(与 threshold 取并集) | — |
29
29
  | `--min-daily-spend <n>` | 日均消耗低于此值视为僵尸账户,不做续航估算 | `0.01` |
30
30
  | `--target-days <n>` | 建议充值目标续航天数 | `30` |
@@ -47,7 +47,7 @@ siluzan-tso balance-scan -m BingV2 -a id1,id2,id3 --json-out ./snap-p2-subset
47
47
  siluzan-tso balance-scan -m MetaAd --json-out ./snap-p2-meta
48
48
  ```
49
49
 
50
- **读盘要点**:先看 `meta.dataIssue`(非空则失败)。`data.items` = **全部已检查账户**(OAuth 失效户不在 items,见 `meta.skippedInvalidOAuth`);`meta.hitCount` = 触阈值条数。预警筛 `hitReason !== "none"`;`hitReason="none"` 表示已检查但未触阈值。按 `remainingDays` 升序交付。用 **`dailySpend`** 做日均(口径见 `meta.spendDays` / `meta.spendWindow`),勿把 `stats` 区间合计 `spend` 当「每天消耗」。与 `balance` / `accounts-digest` 分工见 `references/core/agent-conventions.md` §八。
50
+ **读盘要点**:先看 `meta.dataIssue`(非空则失败)。`data.items` = **全部已检查账户**(OAuth 失效户不在 items,见 `meta.skippedInvalidOAuth`);`meta.hitCount` = 触阈值条数。预警筛 `hitReason !== "none"`;`hitReason="none"` 表示已检查但未触阈值。按 `remainingDays` 升序交付。用 **`dailySpend`** 做日均(口径见 `meta.spendDays` / `meta.spendWindow`),勿把 `stats` 区间合计 `spend` 当「每天消耗」。Google 消耗走 `account-spend-overview`,`startDate`/`endDate` 带东八区墙钟(`…T00:00:00+08:00` / `…T23:59:59+08:00`),与 `stats` 同口径。与 `balance` / `accounts-digest` 分工见 `references/core/agent-conventions.md` §八。
51
51
 
52
52
  ---
53
53
 
@@ -27,7 +27,7 @@
27
27
  | `google-analysis --sections overview`(Google 网关 `OverviewSectionData`) | **实时**;请求起止为 **YYYY-MM-DD**(不附加时区) | ✅ 可查当天 | 当天/今日消耗、当天高消耗账号排行 |
28
28
  | `google-analysis --sections campaign-types`(`types-summary`) | **实时** | ✅ 可查当天 | 当天系列类型分布 |
29
29
  | `google-analysis` 其他 range 维度(`campaigns` / `keywords` / `devices` / `geographic` / `daily-metrics` / …) | 实时(受 Google Ads API 同步延迟影响);**一律 YYYY-MM-DD**(不附加时区;与 `stats` 的 UTC+8 不同) | 可查当天,但当天可能尚未结算 | 周期分析、报告 |
30
- | `stats -m Google` / `balance-scan -m Google` / `accounts-digest -m Google` / `list-accounts -m Google` 合并消耗(TSO `account-spend-overview`,2026-05 起) | **后端自动分流**:窗口完全在历史 → `database`;含今天 → `googleCombined`。**`stats` 请求起止按 UTC+8 墙钟**(`…T00:00:00+08:00` ~ `…T23:59:59+08:00`) | ✅ 可查当天(含今天时切实时聚合) | 历史回溯、巡检、周报 KPI、余额续航估算 |
30
+ | `stats -m Google` / `balance-scan -m Google` / `accounts-digest -m Google` / `list-accounts -m Google` 合并消耗(TSO `account-spend-overview`,2026-05 起) | **后端自动分流**:窗口完全在历史 → `database`;含今天 → `googleCombined`。**凡走本接口的请求起止一律东八区墙钟**(`…T00:00:00+08:00` ~ `…T23:59:59+08:00`) | ✅ 可查当天(含今天时切实时聚合) | 历史回溯、巡检、周报 KPI、余额续航估算 |
31
31
  | `stats` / `balance-scan` / `accounts-digest` / `list-accounts` 的 **TikTok / Yandex / BingV2 / Kwai / MetaAd** 合并消耗(TSO `accountsoverview`;MetaAd OAuth 户走 `FacebookAds` 段) | **每日同步昨天** | ❌ 查今天会全为 0 | 历史回溯、巡检、余额续航估算(口径为"截至昨天") |
32
32
  | `balance`(`GetMediaAccountInfo`) | 实时 | — | 仅当前余额,不反映消耗 |
33
33
 
@@ -22,7 +22,7 @@ siluzan-tso balance-scan -m Google [--threshold-days <n>] [--min-balance <n>] [-
22
22
  | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
23
23
  | 账户 | `data.items[].mediaCustomerId`、`data.items[].name`、`data.items[].advertiserName` |
24
24
  | 余额(主币种金额,与平台余额接口一致) | **`data.items[].balance`**(由 `remainingAccountBudget` 计算而来) |
25
- | 近 7 日估算日均消耗 | **`data.items[].dailySpend`** = 近 7 日总消耗 / 7(窗口为 **[T-7, T-1]**,即截至昨天的 7 个自然日,**不含当天**,避免拉到当天未结算数据) |
25
+ | 近 7 日估算日均消耗 | **`data.items[].dailySpend`** = 近 7 日总消耗 / 7(窗口为 **[T-7, T-1]**,北京时间截至昨天的 7 个自然日,**不含当天**;Google 请求带 `+08:00`) |
26
26
  | 按余额÷日均估算的续航天数 | **`data.items[].remainingDays`** = `balance / dailySpend`(消耗过低 < `minDailySpend` 时为 `null`) |
27
27
  | 建议充值额(按 `meta.thresholds.targetDaysForTopup` 目标) | **`data.items[].recommendedTopup`** |
28
28
  | 命中原因(阈值逻辑) | **`data.items[].hitReason`**:`low-days` \| `low-balance` \| `both` \| `none`(已检查但未触阈值)。`data.items` 含全部已检查账户;预警筛 `hitReason !== "none"`。`meta.hitCount` 为触阈值条数 |
@@ -180,46 +180,14 @@
180
180
  }
181
181
  </style>
182
182
  <style>
183
-
184
- .insight {
185
- margin-top: 12px;
186
- padding: 12px 14px;
187
- border-radius: 12px;
188
- background: #f7f3ea;
189
- border: 1px solid rgba(28, 27, 25, 0.08);
190
- }
191
- .insight strong { display: block; margin-bottom: 6px; }
192
- .insight ul { margin: 0; padding-left: 1.1em; }
193
- .insight li { margin: 0.25em 0; }
194
- .insight.suggestion { background: #eef6f1; border-color: rgba(31, 122, 78, 0.18); }
183
+ /* 补充:开发样例未覆盖的极少数辅助样式(其余全部复用样例的 .panel/.narrative/.issue-card/.todo-item/.badge) */
195
184
  .empty-note {
196
185
  color: var(--muted);
197
186
  padding: 18px;
198
187
  text-align: center;
199
188
  font-size: 13px;
200
189
  }
201
- .todo-list { margin: 0; padding-left: 1.2em; }
202
- .todo-list li { margin: 0.35em 0; }
203
- .panel table { width: 100%; border-collapse: collapse; font-size: 13px; }
204
- .panel th, .panel td {
205
- padding: 8px 10px;
206
- border-bottom: 1px solid rgba(28, 27, 25, 0.08);
207
- text-align: left;
208
- vertical-align: top;
209
- }
210
- .panel th { color: var(--muted); font-weight: 600; white-space: nowrap; }
211
- .panel td.num, .panel th.num { text-align: right; font-variant-numeric: tabular-nums; }
212
- .fold summary {
213
- cursor: pointer;
214
- font-weight: 600;
215
- padding: 8px 0;
216
- list-style: none;
217
- }
218
- .fold summary::-webkit-details-marker { display: none; }
219
- .nav a.active { background: rgba(252, 62, 57, 0.12); color: var(--yandex); }
220
- .hero h1 { margin: 8px 0 12px; font-size: clamp(1.4rem, 2.5vw, 1.9rem); }
221
- .issues { margin: 12px 0; padding: 12px 14px; border-left: 3px solid var(--yandex); background: #fff6f5; }
222
-
190
+ .panel > .table-wrap:first-child table { margin-top: 0; }
223
191
  </style>
224
192
  </head>
225
193
  <body>
@@ -262,14 +230,24 @@
262
230
  .replace(/"/g, "&quot;");
263
231
  }
264
232
 
265
- function int(v) {
233
+ /**
234
+ * 统一的“有效数字”判定:null / undefined / "" 一律视为无数据(返回 undefined),
235
+ * 避免 `Number(null) === 0` 把「无转化时 CPA 为空」误显示成 "$0.00" / "0.00%"。
236
+ */
237
+ function toNum(v) {
238
+ if (v === null || v === undefined || v === "") return undefined;
266
239
  const n = Number(v);
267
- return Number.isFinite(n) ? Math.round(n).toLocaleString("en-US") : "—";
240
+ return Number.isFinite(n) ? n : undefined;
241
+ }
242
+
243
+ function int(v) {
244
+ const n = toNum(v);
245
+ return n === undefined ? "—" : Math.round(n).toLocaleString("en-US");
268
246
  }
269
247
 
270
248
  function money(v, currency) {
271
- const n = Number(v);
272
- if (!Number.isFinite(n)) return "—";
249
+ const n = toNum(v);
250
+ if (n === undefined) return "—";
273
251
  const c = currency || "USD";
274
252
  if (c === "USD" || c === "RUB" || c === "EUR") {
275
253
  try {
@@ -284,14 +262,14 @@
284
262
  }
285
263
 
286
264
  function pct(v) {
287
- const n = Number(v);
288
- if (!Number.isFinite(n)) return "—";
265
+ const n = toNum(v);
266
+ if (n === undefined) return "—";
289
267
  return (n * 100).toFixed(2) + "%";
290
268
  }
291
269
 
292
270
  function num2(v) {
293
- const n = Number(v);
294
- return Number.isFinite(n) ? n.toFixed(2) : "—";
271
+ const n = toNum(v);
272
+ return n === undefined ? "—" : n.toFixed(2);
295
273
  }
296
274
 
297
275
  function asArr(v) {
@@ -302,13 +280,17 @@
302
280
  return data.meta?.currency || data.kpis?.currency || "USD";
303
281
  }
304
282
 
283
+ /**
284
+ * 分析 + 建议区块——样式对齐开发样例的 `.narrative`(badge「叙述」/「建议」+ ul),
285
+ * 而不是本文件之前自造的 `.insight` 盒子。
286
+ */
305
287
  function analysisBlock(section) {
306
288
  const analysis = asArr(section?.analysis).filter((x) => String(x || "").trim());
307
289
  const suggestions = asArr(section?.suggestions).filter((x) => String(x || "").trim());
308
290
  const parts = [];
309
291
  if (analysis.length) {
310
292
  parts.push(
311
- '<div class="insight"><strong>总结</strong><ul>' +
293
+ '<div class="narrative"><h3><span class="badge b-info">叙述</span> 数据解读</h3><ul>' +
312
294
  analysis.map((a) => "<li>" + escapeHtml(a) + "</li>").join("") +
313
295
  "</ul></div>"
314
296
  );
@@ -317,7 +299,7 @@
317
299
  }
318
300
  if (suggestions.length) {
319
301
  parts.push(
320
- '<div class="insight suggestion"><strong>建议</strong><ul>' +
302
+ '<div class="narrative"><h3><span class="badge b-warn">建议</span> 优化建议</h3><ul>' +
321
303
  suggestions.map((s) => "<li>" + escapeHtml(s) + "</li>").join("") +
322
304
  "</ul></div>"
323
305
  );
@@ -330,15 +312,16 @@
330
312
  '<div class="section-head"><div><h2>' +
331
313
  escapeHtml(title) +
332
314
  "</h2>" +
333
- (desc ? '<p class="muted">' + escapeHtml(desc) + "</p>" : "") +
315
+ (desc ? '<p>' + escapeHtml(desc) + "</p>" : "") +
334
316
  "</div></div>"
335
317
  );
336
318
  }
337
319
 
320
+ /** 仅返回 `.table-wrap`(不含外层 `.panel`),供调用方与叙述区块共用同一个 `.panel` 容器,对齐开发样例结构。 */
338
321
  function tableHtml(headers, rowsHtml) {
339
322
  if (!rowsHtml) return '<div class="empty-note">' + EMPTY_NOTE + "</div>";
340
323
  return (
341
- '<div class="panel" style="overflow:auto"><table><thead><tr>' +
324
+ '<div class="table-wrap"><table><thead><tr>' +
342
325
  headers
343
326
  .map(function (h) {
344
327
  const cls = h.num ? ' class="num"' : "";
@@ -375,11 +358,14 @@
375
358
  '</td><td class="num">' +
376
359
  num2(r.conversions) +
377
360
  '</td><td class="num">' +
361
+ pct(r.conversionRate) +
362
+ '</td><td class="num">' +
378
363
  money(r.costPerConversion, currency) +
379
364
  "</td>"
380
365
  );
381
366
  }
382
367
 
368
+ /** 对齐开发样例关键词/搜索词/地域/设备表格的列集合(含 cr% 转化率)。 */
383
369
  const METRIC_HEADERS = [
384
370
  { label: "消耗", num: true },
385
371
  { label: "展示", num: true },
@@ -387,6 +373,7 @@
387
373
  { label: "CTR", num: true },
388
374
  { label: "CPC", num: true },
389
375
  { label: "转化", num: true },
376
+ { label: "转化率", num: true },
390
377
  { label: "CPA", num: true },
391
378
  ];
392
379
 
@@ -443,6 +430,7 @@
443
430
  ? int(kpis.activeDays) + " 个有效投放日"
444
431
  : "";
445
432
 
433
+ // 对齐开发样例:5 张 KPI 卡(余额/消耗/日均消耗/点击-CTR/转化-CPA),展示量并入「点击 / CTR」的 delta 文案。
446
434
  const kpiCards = [
447
435
  {
448
436
  label: "账户余额" + (balAsOf ? "(截至 " + balAsOf + ")" : ""),
@@ -452,7 +440,7 @@
452
440
  {
453
441
  label: "本期消耗",
454
442
  value: money(kpis.spend, currency),
455
- delta: "",
443
+ delta: activeHint,
456
444
  },
457
445
  {
458
446
  label: "日均消耗",
@@ -462,12 +450,12 @@
462
450
  {
463
451
  label: "点击 / CTR",
464
452
  value: int(kpis.clicks),
465
- delta: "CTR " + pct(kpis.ctr) + " · CPC " + money(kpis.averageCpc, currency),
466
- },
467
- {
468
- label: "展示",
469
- value: int(kpis.impressions),
470
- delta: "",
453
+ delta:
454
+ "CTR " +
455
+ pct(kpis.ctr) +
456
+ " · CPC " +
457
+ money(kpis.averageCpc, currency) +
458
+ (kpis.impressions != null ? " · 展示 " + int(kpis.impressions) : ""),
471
459
  },
472
460
  {
473
461
  label: "转化 / CPA",
@@ -506,6 +494,8 @@
506
494
  '</td><td class="num">' +
507
495
  money(r.spend, currency) +
508
496
  '</td><td class="num">' +
497
+ int(r.impressions) +
498
+ '</td><td class="num">' +
509
499
  int(r.clicks) +
510
500
  '</td><td class="num">' +
511
501
  pct(r.ctr) +
@@ -523,6 +513,7 @@
523
513
  { label: "区间" },
524
514
  { label: "天数", num: true },
525
515
  { label: "消耗", num: true },
516
+ { label: "展示", num: true },
526
517
  { label: "点击", num: true },
527
518
  { label: "CTR", num: true },
528
519
  { label: "CPC", num: true },
@@ -557,7 +548,7 @@
557
548
  })
558
549
  .join("");
559
550
  dailyFold =
560
- '<details class="fold panel" style="margin-top:14px;padding:12px 14px"><summary>展开每日明细(' +
551
+ '<details class="fold"><summary>每日消费数据概览(点击展开,共 ' +
561
552
  daily.length +
562
553
  " 天)</summary>" +
563
554
  tableHtml(
@@ -614,57 +605,85 @@
614
605
  '<div class="kpi-grid" style="margin-bottom:14px">' +
615
606
  kpiCards +
616
607
  "</div>" +
617
- (weeklyTable
618
- ? '<h3 style="margin:18px 0 10px;font-size:15px">周报汇总</h3>' + weeklyTable
619
- : "") +
620
- dailyFold +
608
+ '<div class="panel">' +
621
609
  (exec
622
- ? '<div class="panel" style="margin-top:14px;padding:14px"><strong>一句话数据概况</strong>' +
610
+ ? '<div style="padding:14px 16px 0; font-weight:600; font-size:14px;">一句话数据概况</div><div style="padding:6px 16px 0">' +
623
611
  exec +
624
612
  "</div>"
625
613
  : "") +
614
+ (weeklyTable
615
+ ? '<div style="padding:14px 16px 0; font-weight:600; font-size:14px;">汇总数据概览(周)</div>' +
616
+ weeklyTable
617
+ : "") +
618
+ dailyFold +
626
619
  analysisBlock(sec) +
620
+ nextNarrative(data) +
621
+ "</div>" +
627
622
  "</div></section>"
628
623
  );
629
624
  }
630
625
 
631
- function renderNext(data) {
626
+ /** priority(P0/P1/P2 或 high/medium/low)归一为开发样例的徽标文案 + 颜色类。 */
627
+ function priorityBadge(pri, idx) {
628
+ const raw = String(pri || "").trim().toLowerCase();
629
+ if (raw === "high" || raw === "p0") return { label: "P0", cls: "b-danger" };
630
+ if (raw === "low" || raw === "p2") return { label: "P2", cls: "b-info" };
631
+ if (raw === "medium" || raw === "p1" || !raw) return { label: "P1", cls: "b-warn" };
632
+ return { label: String(pri), cls: "b-warn" };
633
+ }
634
+
635
+ /**
636
+ * 「后续优化动作」——对齐开发样例:不是独立 `<section>`,而是嵌在「账户总览」
637
+ * 同一个 `.panel` 内、紧跟数据概述之后的第二个 `.narrative`(id="next" 供导航锚点定位)。
638
+ */
639
+ function nextNarrative(data) {
632
640
  const recs = asArr((data.narrative || {}).recommendations);
633
641
  if (!recs.length) {
634
642
  return (
635
- '<section id="next"><div class="wrap">' +
636
- sectionHead("后续优化动作") +
643
+ '<div class="narrative" id="next"><h3><span class="badge b-warn">待办</span> 后续优化动作与展望</h3>' +
637
644
  '<div class="empty-note">' +
638
645
  EMPTY_NOTE +
639
- "</div></div></section>"
646
+ "</div></div>"
640
647
  );
641
648
  }
649
+ // 对齐开发样例的 `.todo-item`(badge + 文案 + 锚点链接三栏网格),而非普通 <li> 列表。
642
650
  const items = recs
643
651
  .map(function (r, i) {
644
652
  if (typeof r === "string") {
645
- return "<li><strong>P" + (i + 1) + "</strong> " + escapeHtml(r) + "</li>";
653
+ const b = priorityBadge(null, i);
654
+ return (
655
+ '<div class="todo-item"><span class="badge ' +
656
+ b.cls +
657
+ '">' +
658
+ b.label +
659
+ "</span><div>" +
660
+ escapeHtml(r) +
661
+ "</div><span></span></div>"
662
+ );
646
663
  }
647
- const pri = r.priority || "P" + (i + 1);
664
+ const b = priorityBadge(r.priority, i);
648
665
  const text = r.text || r.content || "";
649
666
  const anchor = r.anchor
650
- ? ' <a href="' + escapeHtml(r.anchor) + '">→</a>'
651
- : "";
667
+ ? '<a href="' + escapeHtml(r.anchor) + '">→</a>'
668
+ : "<span></span>";
652
669
  return (
653
- "<li><strong>" +
654
- escapeHtml(pri) +
655
- "</strong> " +
670
+ '<div class="todo-item"><span class="badge ' +
671
+ b.cls +
672
+ '">' +
673
+ b.label +
674
+ "</span><div>" +
656
675
  escapeHtml(text) +
676
+ "</div>" +
657
677
  anchor +
658
- "</li>"
678
+ "</div>"
659
679
  );
660
680
  })
661
681
  .join("");
662
682
  return (
663
- '<section id="next"><div class="wrap">' +
664
- sectionHead("后续优化动作", "跨维度优先级建议(来自 narrative.recommendations)") +
665
- '<div class="panel" style="padding:14px"><ol class="todo-list">' +
683
+ '<div class="narrative" id="next"><h3><span class="badge b-warn">待办</span> 后续优化动作与展望</h3>' +
684
+ '<div class="todo-list">' +
666
685
  items +
667
- "</ol></div></div></section>"
686
+ "</div></div>"
668
687
  );
669
688
  }
670
689
 
@@ -672,7 +691,9 @@
672
691
  const data = opts.data;
673
692
  const currency = currencyOf(data);
674
693
  const sec = ((data.narrative || {}).sections || {})[opts.narrativeKey] || {};
675
- const rows = topBySpend(data.tables?.[opts.tableKey], opts.limit || 20);
694
+ const allRows = asArr(data.tables?.[opts.tableKey]);
695
+ const limit = opts.limit || 20;
696
+ const rows = topBySpend(allRows, limit);
676
697
  let body = "";
677
698
  if (rows.length) {
678
699
  const rowHtml = rows
@@ -696,13 +717,23 @@
696
717
  } else {
697
718
  body = '<div class="empty-note">' + EMPTY_NOTE + "</div>";
698
719
  }
720
+ // 行数提示:明确区分「共 N 行仅显示 Top M」与「本维度确实无数据」,避免误判为报告漏了该维度。
721
+ const countHint =
722
+ allRows.length > rows.length
723
+ ? "共 " + allRows.length + " 行,按消耗降序显示 Top " + rows.length + "。"
724
+ : allRows.length > 0
725
+ ? "共 " + allRows.length + " 行。"
726
+ : "";
727
+ const desc = [opts.desc, countHint].filter(Boolean).join(" ");
699
728
  return (
700
729
  '<section id="' +
701
730
  opts.id +
702
731
  '"><div class="wrap">' +
703
- sectionHead(opts.title, opts.desc) +
732
+ sectionHead(opts.title, desc) +
733
+ '<div class="panel">' +
704
734
  body +
705
735
  analysisBlock(sec) +
736
+ "</div>" +
706
737
  "</div></section>"
707
738
  );
708
739
  }
@@ -793,13 +824,17 @@
793
824
 
794
825
  return (
795
826
  '<section id="daily"><div class="wrap">' +
796
- sectionHead("日报表", "tables.daily(来自 yandex-analysis daily)") +
827
+ sectionHead("日报表", "按日消耗/展示/点击/转化明细") +
828
+ '<div class="panel">' +
797
829
  dailyBody +
798
830
  analysisBlock(sec) +
831
+ "</div>" +
799
832
  "</div></section>" +
800
833
  '<section id="weekly"><div class="wrap">' +
801
- sectionHead("周报表", " daily 本地聚合(后端无独立 weekly 接口)") +
834
+ sectionHead("周报表", "由每日数据按 ISO 周本地聚合") +
835
+ '<div class="panel">' +
802
836
  weeklyBody +
837
+ "</div>" +
803
838
  "</div></section>"
804
839
  );
805
840
  }
@@ -809,15 +844,14 @@
809
844
  const parts = [];
810
845
  parts.push(renderHero(data));
811
846
  parts.push(renderOverview(data));
812
- parts.push(renderNext(data));
813
847
  parts.push(
814
848
  dimSection({
815
849
  data: data,
816
850
  id: "search",
817
851
  narrativeKey: "search",
818
852
  tableKey: "search",
819
- title: "搜索网络 · Search",
820
- desc: "AdNetworkType=SEARCH 汇总(tables.search)",
853
+ title: "广告类型 · Search",
854
+ desc: "搜索网络(Search)投放汇总",
821
855
  nameLabel: "网络",
822
856
  nameOf: function (r) {
823
857
  return r.network || "SEARCH";
@@ -831,7 +865,7 @@
831
865
  narrativeKey: "campaigns",
832
866
  tableKey: "campaigns",
833
867
  title: "Campaign",
834
- desc: "按消耗 Top 系列(tables.campaigns)",
868
+ desc: "按消耗排序的系列明细",
835
869
  nameLabel: "系列",
836
870
  nameOf: function (r) {
837
871
  return r.campaignName || r.campaignId || "—";
@@ -856,7 +890,7 @@
856
890
  narrativeKey: "keywords",
857
891
  tableKey: "keywords",
858
892
  title: "关键词",
859
- desc: "按消耗 Top 关键词(tables.keywords)",
893
+ desc: "按消耗排序的关键词明细",
860
894
  nameLabel: "关键词",
861
895
  nameOf: function (r) {
862
896
  return r.keyword || "—";
@@ -881,7 +915,7 @@
881
915
  narrativeKey: "searchTerms",
882
916
  tableKey: "searchTerms",
883
917
  title: "搜索词",
884
- desc: "按消耗 Top 搜索词(tables.searchTerms;仅近 180 天)",
918
+ desc: "按消耗排序的搜索词明细(仅近 180 天数据)",
885
919
  nameLabel: "搜索词",
886
920
  nameOf: function (r) {
887
921
  return r.searchQuery || "—";
@@ -906,10 +940,16 @@
906
940
  narrativeKey: "geo",
907
941
  tableKey: "geo",
908
942
  title: "地域",
909
- desc: "tables.geo",
943
+ desc: "按消耗排序的地域明细",
910
944
  nameLabel: "地域",
911
945
  nameOf: function (r) {
912
- return r.locationName || r.targetingLocationName || "—";
946
+ return (
947
+ r.locationName ||
948
+ r.targetingLocationName ||
949
+ r.location ||
950
+ r.name ||
951
+ (r.locationId != null ? "地域 " + r.locationId : "—")
952
+ );
913
953
  },
914
954
  limit: 30,
915
955
  })
@@ -921,7 +961,7 @@
921
961
  narrativeKey: "devices",
922
962
  tableKey: "devices",
923
963
  title: "设备",
924
- desc: "tables.devices",
964
+ desc: "按消耗排序的设备明细",
925
965
  nameLabel: "设备",
926
966
  nameOf: function (r) {
927
967
  return r.device || "—";
@@ -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.45-beta.2'
12
+ $PKG_VERSION = '1.1.45-beta.3'
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.45-beta.2"
12
+ readonly PKG_VERSION="1.1.45-beta.3"
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.45-beta.2",
3
+ "version": "1.1.45-beta.3",
4
4
  "description": "Siluzan 广告账户管理 CLI — 查询账户、余额、消耗数据,管理绑定关系与充值。",
5
5
  "keywords": [
6
6
  "ad-account",