siluzan-tso-cli 1.1.36 → 1.1.37-beta.2

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.
Files changed (32) hide show
  1. package/README.md +2 -1
  2. package/dist/index.js +2860 -746
  3. package/dist/skill/_meta.json +2 -2
  4. package/dist/skill/assets/google-period-report.schema.json +226 -0
  5. package/dist/skill/assets/pmax-create-template.md +7 -1
  6. package/dist/skill/references/accounts/accounts.md +3 -3
  7. package/dist/skill/references/accounts/finance.md +1 -1
  8. package/dist/skill/references/accounts/open-account-by-media.md +1 -1
  9. package/dist/skill/references/accounts/open-account-google-ui.md +1 -1
  10. package/dist/skill/references/analytics/rag.md +1 -1
  11. package/dist/skill/references/analytics/reporting.md +5 -5
  12. package/dist/skill/references/core/setup.md +5 -5
  13. package/dist/skill/references/misc/tso-home.md +2 -2
  14. package/dist/skill/references/report-templates/README.md +14 -14
  15. package/dist/skill/references/report-templates/REPORT-WORKFLOW.md +23 -15
  16. package/dist/skill/references/report-templates/bing-period-report.md +89 -23
  17. package/dist/skill/references/report-templates/google-period-report.md +55 -5
  18. package/dist/skill/references/report-templates/meta-account-diagnosis-report.md +144 -16
  19. package/dist/skill/references/report-templates/tiktok-period-report.md +112 -7
  20. package/dist/skill/report-templates/README.md +14 -14
  21. package/dist/skill/report-templates/REPORT-WORKFLOW.md +23 -15
  22. package/dist/skill/report-templates/bing-period-report.html +1343 -0
  23. package/dist/skill/report-templates/bing-period-report.md +89 -23
  24. package/dist/skill/report-templates/google-period-report.html +1314 -0
  25. package/dist/skill/report-templates/google-period-report.md +55 -5
  26. package/dist/skill/report-templates/meta-account-diagnosis-report.html +1124 -0
  27. package/dist/skill/report-templates/meta-account-diagnosis-report.md +144 -16
  28. package/dist/skill/report-templates/tiktok-period-report.html +1111 -0
  29. package/dist/skill/report-templates/tiktok-period-report.md +112 -7
  30. package/dist/skill/scripts/install.ps1 +3 -3
  31. package/dist/skill/scripts/install.sh +3 -3
  32. package/package.json +1 -1
@@ -0,0 +1,1314 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
6
+ <title>Google Ads 周期分析报告</title>
7
+
8
+ <script src="https://staticpn.siluzan.com/assets/slz/homeCDN/echarts.js"></script>
9
+ <style>
10
+ :root {
11
+ --bg: #f4f6fb;
12
+ --surface: #ffffff;
13
+ --surface-2: #f8fafc;
14
+ --surface-hover: #f1f5f9;
15
+ --border: rgba(15, 23, 42, 0.08);
16
+ --border-strong: rgba(15, 23, 42, 0.14);
17
+ --text: #0f172a;
18
+ --text-dim: #475569;
19
+ --muted: #94a3b8;
20
+ --blue: #3b82f6;
21
+ --blue-deep: #1d4ed8;
22
+ --purple: #8b5cf6;
23
+ --cyan: #06b6d4;
24
+ --green: #10b981;
25
+ --green-deep: #047857;
26
+ --yellow: #f59e0b;
27
+ --orange: #f97316;
28
+ --pink: #ec4899;
29
+ --red: #ef4444;
30
+ --red-deep: #b91c1c;
31
+ --accent: var(--blue);
32
+ --radius-lg: 16px;
33
+ --radius-md: 12px;
34
+ --radius-sm: 8px;
35
+ --shadow-card: 0 1px 2px rgba(15, 23, 42, 0.04), 0 10px 26px -14px rgba(15, 23, 42, 0.12);
36
+ }
37
+ * {
38
+ box-sizing: border-box;
39
+ }
40
+ html {
41
+ scroll-behavior: smooth;
42
+ }
43
+ body {
44
+ margin: 0;
45
+ font-family:
46
+ "Segoe UI",
47
+ -apple-system,
48
+ "PingFang SC",
49
+ "Microsoft YaHei",
50
+ sans-serif;
51
+ background:
52
+ radial-gradient(1100px 560px at 8% -8%, rgba(59, 130, 246, 0.08), transparent 60%),
53
+ radial-gradient(900px 480px at 104% 0%, rgba(139, 92, 246, 0.06), transparent 55%),
54
+ var(--bg);
55
+ color: var(--text);
56
+ line-height: 1.7;
57
+ -webkit-font-smoothing: antialiased;
58
+ }
59
+ .wrap {
60
+ max-width: 1140px;
61
+ margin: 0 auto;
62
+ padding: 32px 20px 90px;
63
+ }
64
+
65
+ /* ── Hero ── */
66
+ .hero {
67
+ position: relative;
68
+ background: linear-gradient(155deg, #eef4ff 0%, #ffffff 55%, #ffffff 100%);
69
+ border: 1px solid var(--border);
70
+ border-radius: var(--radius-lg);
71
+ padding: 34px 36px;
72
+ margin-bottom: 26px;
73
+ overflow: hidden;
74
+ box-shadow: var(--shadow-card);
75
+ animation: fadeInUp 0.5s ease both;
76
+ }
77
+ .hero::before {
78
+ content: "";
79
+ position: absolute;
80
+ inset: -40% -20% auto auto;
81
+ width: 320px;
82
+ height: 320px;
83
+ background: radial-gradient(circle, rgba(59, 130, 246, 0.12), transparent 70%);
84
+ pointer-events: none;
85
+ }
86
+ .eyebrow {
87
+ display: inline-flex;
88
+ align-items: center;
89
+ gap: 7px;
90
+ font-size: 0.72rem;
91
+ font-weight: 700;
92
+ letter-spacing: 0.09em;
93
+ color: var(--blue-deep);
94
+ background: rgba(59, 130, 246, 0.1);
95
+ border: 1px solid rgba(59, 130, 246, 0.22);
96
+ border-radius: 999px;
97
+ padding: 4px 12px 4px 10px;
98
+ margin-bottom: 14px;
99
+ }
100
+ .eyebrow::before {
101
+ content: "";
102
+ width: 6px;
103
+ height: 6px;
104
+ border-radius: 50%;
105
+ background: var(--blue);
106
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2);
107
+ }
108
+ .hero h1 {
109
+ position: relative;
110
+ margin: 0 0 14px;
111
+ font-size: 1.95rem;
112
+ font-weight: 800;
113
+ letter-spacing: -0.01em;
114
+ }
115
+ .meta-chips {
116
+ position: relative;
117
+ display: flex;
118
+ flex-wrap: wrap;
119
+ gap: 8px;
120
+ }
121
+ .meta-chip {
122
+ display: inline-flex;
123
+ align-items: center;
124
+ gap: 6px;
125
+ font-size: 0.78rem;
126
+ color: var(--text-dim);
127
+ background: rgba(15, 23, 42, 0.03);
128
+ border: 1px solid var(--border);
129
+ border-radius: 999px;
130
+ padding: 5px 12px;
131
+ }
132
+ .meta-chip b {
133
+ color: var(--text);
134
+ font-weight: 600;
135
+ }
136
+ .kpi-grid {
137
+ position: relative;
138
+ display: grid;
139
+ grid-template-columns: repeat(auto-fit, minmax(146px, 1fr));
140
+ gap: 12px;
141
+ margin-top: 24px;
142
+ }
143
+ .kpi {
144
+ position: relative;
145
+ background: #ffffff;
146
+ border: 1px solid var(--border);
147
+ border-radius: var(--radius-md);
148
+ padding: 15px 16px 14px;
149
+ overflow: hidden;
150
+ transition:
151
+ transform 0.15s ease,
152
+ border-color 0.15s ease,
153
+ box-shadow 0.15s ease;
154
+ }
155
+ .kpi::before {
156
+ content: "";
157
+ position: absolute;
158
+ left: 0;
159
+ top: 0;
160
+ bottom: 0;
161
+ width: 3px;
162
+ background: var(--kpi-color, var(--blue));
163
+ }
164
+ .kpi:hover {
165
+ transform: translateY(-2px);
166
+ border-color: var(--border-strong);
167
+ box-shadow: 0 8px 18px -10px rgba(15, 23, 42, 0.18);
168
+ }
169
+ .kpi .dot {
170
+ display: inline-block;
171
+ width: 7px;
172
+ height: 7px;
173
+ border-radius: 50%;
174
+ background: var(--kpi-color, var(--blue));
175
+ margin-right: 6px;
176
+ vertical-align: middle;
177
+ }
178
+ .kpi .l {
179
+ font-size: 0.72rem;
180
+ color: var(--muted);
181
+ text-transform: uppercase;
182
+ letter-spacing: 0.04em;
183
+ display: flex;
184
+ align-items: center;
185
+ }
186
+ .kpi .v {
187
+ font-size: 1.5rem;
188
+ font-weight: 800;
189
+ color: var(--text);
190
+ margin-top: 6px;
191
+ font-variant-numeric: tabular-nums;
192
+ letter-spacing: -0.01em;
193
+ }
194
+ .kpi .d {
195
+ display: inline-flex;
196
+ align-items: center;
197
+ gap: 3px;
198
+ font-size: 0.7rem;
199
+ font-weight: 700;
200
+ margin-top: 8px;
201
+ padding: 2px 7px;
202
+ border-radius: 999px;
203
+ }
204
+ .kpi .d.up {
205
+ color: var(--green-deep);
206
+ background: rgba(16, 185, 129, 0.12);
207
+ }
208
+ .kpi .d.down {
209
+ color: var(--red-deep);
210
+ background: rgba(239, 68, 68, 0.12);
211
+ }
212
+
213
+ /* ── Quick nav ── */
214
+ .toc {
215
+ display: flex;
216
+ gap: 8px;
217
+ flex-wrap: wrap;
218
+ margin: 22px 0 6px;
219
+ }
220
+ .toc a {
221
+ color: var(--text-dim);
222
+ text-decoration: none;
223
+ font-size: 0.79rem;
224
+ font-weight: 500;
225
+ padding: 6px 13px;
226
+ background: var(--surface);
227
+ border: 1px solid var(--border);
228
+ border-radius: 999px;
229
+ transition: all 0.15s ease;
230
+ scroll-margin-top: 16px;
231
+ }
232
+ .toc a:hover {
233
+ color: var(--text);
234
+ border-color: var(--border-strong);
235
+ background: var(--surface-hover);
236
+ }
237
+ .toc a.active {
238
+ color: #fff;
239
+ background: linear-gradient(135deg, var(--blue), var(--purple));
240
+ border-color: transparent;
241
+ }
242
+
243
+ /* ── Sections ── */
244
+ section {
245
+ margin-bottom: 34px;
246
+ scroll-margin-top: 14px;
247
+ animation: fadeInUp 0.5s ease both;
248
+ }
249
+ .sec-head {
250
+ display: flex;
251
+ align-items: flex-start;
252
+ gap: 13px;
253
+ margin: 30px 0 14px;
254
+ }
255
+ .sec-num {
256
+ flex: none;
257
+ display: inline-flex;
258
+ align-items: center;
259
+ justify-content: center;
260
+ width: 30px;
261
+ height: 30px;
262
+ border-radius: 9px;
263
+ background: linear-gradient(135deg, var(--blue), var(--purple));
264
+ color: #fff;
265
+ font-size: 0.82rem;
266
+ font-weight: 800;
267
+ margin-top: 2px;
268
+ }
269
+ .sec-head h2 {
270
+ margin: 0;
271
+ font-size: 1.22rem;
272
+ font-weight: 700;
273
+ letter-spacing: -0.005em;
274
+ color: var(--text);
275
+ }
276
+ .sec-desc {
277
+ margin: 3px 0 0;
278
+ font-size: 0.82rem;
279
+ color: var(--muted);
280
+ }
281
+
282
+ .card {
283
+ background: var(--surface);
284
+ border: 1px solid var(--border);
285
+ border-radius: var(--radius-lg);
286
+ padding: 22px 24px;
287
+ margin-bottom: 14px;
288
+ box-shadow: var(--shadow-card);
289
+ }
290
+ .card-title-row {
291
+ display: flex;
292
+ align-items: baseline;
293
+ justify-content: space-between;
294
+ gap: 12px;
295
+ margin-bottom: 4px;
296
+ }
297
+ .card-title-row h3 {
298
+ margin: 0;
299
+ font-size: 0.95rem;
300
+ font-weight: 700;
301
+ color: var(--text-dim);
302
+ }
303
+ .card-title-row .hint {
304
+ font-size: 0.74rem;
305
+ color: var(--muted);
306
+ }
307
+ .chart-box {
308
+ position: relative;
309
+ height: 320px;
310
+ margin: 10px 0 4px;
311
+ }
312
+
313
+ /* ── Stat grid(周期汇总) ── */
314
+ .stat-grid {
315
+ display: grid;
316
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
317
+ gap: 10px;
318
+ }
319
+ .stat-chip {
320
+ background: var(--surface-2);
321
+ border: 1px solid var(--border);
322
+ border-radius: var(--radius-sm);
323
+ padding: 12px 14px;
324
+ }
325
+ .stat-chip .l {
326
+ font-size: 0.72rem;
327
+ color: var(--muted);
328
+ }
329
+ .stat-chip .v {
330
+ font-size: 1.15rem;
331
+ font-weight: 700;
332
+ color: var(--text);
333
+ margin-top: 4px;
334
+ font-variant-numeric: tabular-nums;
335
+ }
336
+
337
+ /* ── Insight / suggestion callouts ── */
338
+ .insight {
339
+ font-size: 0.9rem;
340
+ color: var(--text-dim);
341
+ border: 1px solid transparent;
342
+ border-left: 3px solid var(--blue);
343
+ padding: 13px 16px;
344
+ background: rgba(59, 130, 246, 0.06);
345
+ border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
346
+ margin-top: 14px;
347
+ }
348
+ .insight strong {
349
+ display: flex;
350
+ align-items: center;
351
+ gap: 6px;
352
+ color: var(--blue-deep);
353
+ font-size: 0.82rem;
354
+ letter-spacing: 0.02em;
355
+ margin-bottom: 6px;
356
+ }
357
+ .insight.suggestion {
358
+ border-left-color: var(--green);
359
+ background: rgba(16, 185, 129, 0.06);
360
+ }
361
+ .insight.suggestion strong {
362
+ color: var(--green-deep);
363
+ }
364
+ .insight ul {
365
+ margin: 0;
366
+ padding-left: 18px;
367
+ }
368
+ .insight li {
369
+ margin-bottom: 5px;
370
+ }
371
+ .insight li:last-child {
372
+ margin-bottom: 0;
373
+ }
374
+
375
+ .tag {
376
+ display: inline-block;
377
+ padding: 3px 11px;
378
+ border-radius: 999px;
379
+ font-size: 0.72rem;
380
+ font-weight: 700;
381
+ }
382
+ .tag.green {
383
+ background: rgba(16, 185, 129, 0.14);
384
+ color: var(--green-deep);
385
+ }
386
+ .tag.yellow {
387
+ background: rgba(245, 158, 11, 0.14);
388
+ color: #92400e;
389
+ }
390
+ .tag.red {
391
+ background: rgba(239, 68, 68, 0.14);
392
+ color: var(--red-deep);
393
+ }
394
+
395
+ /* ── Tables ── */
396
+ .table-wrap {
397
+ border: 1px solid var(--border);
398
+ border-radius: var(--radius-md);
399
+ overflow: hidden;
400
+ }
401
+ table {
402
+ width: 100%;
403
+ border-collapse: collapse;
404
+ font-size: 0.86rem;
405
+ }
406
+ th,
407
+ td {
408
+ padding: 11px 14px;
409
+ text-align: left;
410
+ border-bottom: 1px solid var(--border);
411
+ white-space: nowrap;
412
+ }
413
+ td {
414
+ color: var(--text-dim);
415
+ }
416
+ th {
417
+ color: var(--muted);
418
+ font-weight: 700;
419
+ font-size: 0.76rem;
420
+ text-transform: uppercase;
421
+ letter-spacing: 0.03em;
422
+ background: var(--surface-2);
423
+ }
424
+ th.num,
425
+ td.num {
426
+ text-align: right;
427
+ font-variant-numeric: tabular-nums;
428
+ }
429
+ tbody tr:nth-child(even) td {
430
+ background: rgba(15, 23, 42, 0.015);
431
+ }
432
+ tbody tr:hover td {
433
+ background: rgba(59, 130, 246, 0.05);
434
+ color: var(--text);
435
+ }
436
+ tbody tr:last-child td {
437
+ border-bottom: none;
438
+ }
439
+
440
+ /* ── Recommendations ── */
441
+ .recommendation-list {
442
+ display: grid;
443
+ gap: 12px;
444
+ }
445
+ .rec-card {
446
+ position: relative;
447
+ display: flex;
448
+ gap: 14px;
449
+ border: 1px solid var(--border);
450
+ border-radius: var(--radius-md);
451
+ padding: 16px 18px;
452
+ background: var(--surface-2);
453
+ }
454
+ .rec-num {
455
+ flex: none;
456
+ display: inline-flex;
457
+ align-items: center;
458
+ justify-content: center;
459
+ width: 28px;
460
+ height: 28px;
461
+ border-radius: 50%;
462
+ background: rgba(16, 185, 129, 0.14);
463
+ color: var(--green-deep);
464
+ font-weight: 800;
465
+ font-size: 0.85rem;
466
+ }
467
+ .rec-body {
468
+ flex: 1;
469
+ min-width: 0;
470
+ }
471
+ .rec-card .rec-title {
472
+ font-weight: 700;
473
+ margin-bottom: 4px;
474
+ color: var(--green-deep);
475
+ font-size: 0.92rem;
476
+ }
477
+ .rec-card .rec-content {
478
+ color: var(--text-dim);
479
+ font-size: 0.9rem;
480
+ }
481
+
482
+ .footer {
483
+ text-align: center;
484
+ color: var(--muted);
485
+ font-size: 0.78rem;
486
+ margin-top: 50px;
487
+ padding-top: 22px;
488
+ border-top: 1px solid var(--border);
489
+ }
490
+ .footer .brand {
491
+ color: var(--text-dim);
492
+ font-weight: 600;
493
+ }
494
+
495
+ .empty-note {
496
+ display: flex;
497
+ align-items: center;
498
+ gap: 8px;
499
+ color: var(--muted);
500
+ font-size: 0.86rem;
501
+ padding: 14px 16px;
502
+ border: 1px dashed var(--border-strong);
503
+ border-radius: var(--radius-sm);
504
+ background: var(--surface-2);
505
+ }
506
+ .empty-note::before {
507
+ content: "•";
508
+ color: var(--muted);
509
+ flex: none;
510
+ }
511
+
512
+ @keyframes fadeInUp {
513
+ from {
514
+ opacity: 0;
515
+ transform: translateY(10px);
516
+ }
517
+ to {
518
+ opacity: 1;
519
+ transform: translateY(0);
520
+ }
521
+ }
522
+
523
+ /* 移动端原生适配(≤768px,对齐 meta-period-report.html 规则) */
524
+ @supports (padding: env(safe-area-inset-bottom)) {
525
+ body {
526
+ padding-left: env(safe-area-inset-left, 0);
527
+ padding-right: env(safe-area-inset-right, 0);
528
+ padding-bottom: env(safe-area-inset-bottom, 0);
529
+ }
530
+ }
531
+
532
+ @media (max-width: 768px) {
533
+ html {
534
+ -webkit-text-size-adjust: 100%;
535
+ text-size-adjust: 100%;
536
+ }
537
+
538
+ body {
539
+ overflow-x: clip;
540
+ -webkit-tap-highlight-color: transparent;
541
+ }
542
+
543
+ .wrap {
544
+ padding: max(12px, env(safe-area-inset-left, 0px))
545
+ max(12px, env(safe-area-inset-right, 0px)) max(40px, env(safe-area-inset-bottom, 0px));
546
+ }
547
+
548
+ .hero {
549
+ padding: 20px 18px;
550
+ border-radius: 14px;
551
+ }
552
+
553
+ .hero h1 {
554
+ font-size: clamp(1.15rem, 5.2vw, 1.4rem);
555
+ line-height: 1.4;
556
+ word-break: break-word;
557
+ }
558
+
559
+ .meta-chip {
560
+ font-size: 0.72rem;
561
+ }
562
+
563
+ .kpi-grid {
564
+ grid-template-columns: repeat(2, minmax(0, 1fr));
565
+ gap: 10px;
566
+ }
567
+
568
+ .kpi {
569
+ padding: 12px 12px 11px;
570
+ }
571
+
572
+ .kpi .v {
573
+ font-size: clamp(1.05rem, 4.5vw, 1.25rem);
574
+ }
575
+
576
+ .sec-head h2 {
577
+ font-size: clamp(1rem, 4.2vw, 1.12rem);
578
+ }
579
+
580
+ .card {
581
+ padding: 16px 14px;
582
+ border-radius: 12px;
583
+ }
584
+
585
+ .chart-box {
586
+ height: clamp(220px, 56vw, 260px);
587
+ }
588
+
589
+ .recommendation-list {
590
+ grid-template-columns: 1fr !important;
591
+ }
592
+
593
+ .card:has(table) {
594
+ overflow-x: auto;
595
+ overscroll-behavior-x: contain;
596
+ -webkit-overflow-scrolling: touch;
597
+ }
598
+
599
+ .card:has(table)::before {
600
+ display: block;
601
+ text-align: center;
602
+ font-size: 12px;
603
+ color: var(--muted);
604
+ padding: 0 0 8px;
605
+ content: "左右滑动查看表格";
606
+ }
607
+
608
+ .card table {
609
+ min-width: 520px;
610
+ font-size: 0.82rem;
611
+ }
612
+
613
+ th,
614
+ td {
615
+ padding: 9px 11px;
616
+ }
617
+ }
618
+
619
+ @media (min-width: 769px) {
620
+ .card:has(table)::before {
621
+ display: none;
622
+ content: none;
623
+ }
624
+ }
625
+
626
+ @media print {
627
+ html {
628
+ scroll-behavior: auto;
629
+ }
630
+ .toc {
631
+ display: none;
632
+ }
633
+ section,
634
+ .hero,
635
+ .kpi {
636
+ animation: none !important;
637
+ }
638
+ .card:has(table)::before {
639
+ display: none !important;
640
+ content: none !important;
641
+ }
642
+ }
643
+ </style>
644
+ </head>
645
+ <body>
646
+ <div id="report-root" class="wrap"></div>
647
+ <script>
648
+ window.__GOOGLE_PERIOD_REPORT__ = window.__GOOGLE_PERIOD_REPORT__ || null;
649
+ </script>
650
+ <script>
651
+ /**
652
+ * Google Ads 周期分析 HTML 报告运行时
653
+ * 数据源:window.__GOOGLE_PERIOD_REPORT__(Agent 撰写 JSON + google-analysis 快照自动合并)
654
+ * 本文件不由 Agent 生成或修改;渲染逻辑固定,Agent 只提供 JSON 数据。
655
+ */
656
+ (function () {
657
+ "use strict";
658
+
659
+ const PALETTE = [
660
+ "#3b82f6",
661
+ "#8b5cf6",
662
+ "#06b6d4",
663
+ "#10b981",
664
+ "#f59e0b",
665
+ "#f97316",
666
+ "#ec4899",
667
+ ];
668
+ const AXIS_COLOR = "#64748b";
669
+ const SPLIT_LINE_COLOR = "rgba(15,23,42,0.08)";
670
+ const TOOLTIP_STYLE = {
671
+ backgroundColor: "#ffffff",
672
+ borderColor: "rgba(15,23,42,0.10)",
673
+ borderWidth: 1,
674
+ textStyle: { color: "#0f172a", fontSize: 12 },
675
+ extraCssText:
676
+ "box-shadow:0 10px 28px -12px rgba(15,23,42,.18);border-radius:10px;padding:9px 12px;",
677
+ };
678
+
679
+ function escapeHtml(s) {
680
+ return String(s ?? "")
681
+ .replace(/&/g, "&amp;")
682
+ .replace(/</g, "&lt;")
683
+ .replace(/>/g, "&gt;")
684
+ .replace(/"/g, "&quot;");
685
+ }
686
+
687
+ function int(v) {
688
+ const n = Number(v);
689
+ return Number.isFinite(n) ? Math.round(n).toLocaleString("en-US") : "—";
690
+ }
691
+
692
+ function money(v, currency) {
693
+ const n = Number(v);
694
+ if (!Number.isFinite(n)) return "—";
695
+ const sym = currency && currency !== "CNY" ? currency + " " : "¥";
696
+ return sym + n.toFixed(2);
697
+ }
698
+
699
+ function pct(v) {
700
+ const n = Number(v);
701
+ if (!Number.isFinite(n)) return "—";
702
+ return (n * 100).toFixed(2) + "%";
703
+ }
704
+
705
+ function fixed2(v) {
706
+ const n = Number(v);
707
+ return Number.isFinite(n) ? n.toFixed(2) : "—";
708
+ }
709
+
710
+ function deltaLabel(current, previous) {
711
+ const c = Number(current);
712
+ const p = Number(previous);
713
+ if (!Number.isFinite(c) || !Number.isFinite(p) || p === 0) return "";
714
+ const change = ((c - p) / Math.abs(p)) * 100;
715
+ const cls = change >= 0 ? "up" : "down";
716
+ const arrow = change >= 0 ? "▲" : "▼";
717
+ return `<span class="d ${cls}">${arrow} ${Math.abs(change).toFixed(1)}% 环比</span>`;
718
+ }
719
+
720
+ function sectionHead(num, title, desc) {
721
+ return `
722
+ <div class="sec-head">
723
+ <span class="sec-num">${num}</span>
724
+ <div>
725
+ <h2>${escapeHtml(title)}</h2>
726
+ ${desc ? `<p class="sec-desc">${escapeHtml(desc)}</p>` : ""}
727
+ </div>
728
+ </div>`;
729
+ }
730
+
731
+ function analysisBlock(section) {
732
+ const analysis = Array.isArray(section?.analysis) ? section.analysis : [];
733
+ const suggestions = Array.isArray(section?.suggestions) ? section.suggestions : [];
734
+ const parts = [];
735
+ if (analysis.length > 0) {
736
+ parts.push(
737
+ `<div class="insight"><strong>分析</strong><ul>${analysis
738
+ .map((a) => `<li>${escapeHtml(a)}</li>`)
739
+ .join("")}</ul></div>`,
740
+ );
741
+ } else {
742
+ parts.push('<div class="empty-note">数据不可用:本节暂无分析</div>');
743
+ }
744
+ if (suggestions.length > 0) {
745
+ parts.push(
746
+ `<div class="insight suggestion"><strong>建议</strong><ul>${suggestions
747
+ .map((s) => `<li>${escapeHtml(s)}</li>`)
748
+ .join("")}</ul></div>`,
749
+ );
750
+ }
751
+ return parts.join("");
752
+ }
753
+
754
+ function renderHero(data) {
755
+ const meta = data.meta || {};
756
+ const kpis = data.kpis || {};
757
+ const prev = kpis.previousPeriod || {};
758
+ const currency = meta.currency || kpis.currency || "CNY";
759
+ const period =
760
+ meta.startDate && meta.endDate
761
+ ? `${meta.startDate} ~ ${meta.endDate}`
762
+ : "统计区间未提供";
763
+ const kpiDefs = [
764
+ { key: "cost", label: "消耗", fmt: (v) => money(v, currency) },
765
+ { key: "impressions", label: "展示次数", fmt: int },
766
+ { key: "clicks", label: "点击次数", fmt: int },
767
+ { key: "conversions", label: "转化次数", fmt: int },
768
+ { key: "ctr", label: "点击率 CTR", fmt: pct },
769
+ { key: "averageCpc", label: "平均点击费用", fmt: (v) => money(v, currency) },
770
+ { key: "costPerConversion", label: "每次转化费用 CPA", fmt: (v) => money(v, currency) },
771
+ ];
772
+ const kpiHtml = kpiDefs
773
+ .map((def, i) => {
774
+ const v = kpis[def.key];
775
+ const p = prev[def.key];
776
+ const color = PALETTE[i % PALETTE.length];
777
+ return `<div class="kpi" style="--kpi-color:${color}">
778
+ <div class="l"><span class="dot"></span>${def.label}</div>
779
+ <div class="v">${def.fmt(v)}</div>
780
+ ${deltaLabel(v, p)}
781
+ </div>`;
782
+ })
783
+ .join("");
784
+ return `
785
+ <div class="hero">
786
+ <div class="eyebrow">GOOGLE ADS · 周期分析报告</div>
787
+ <h1>${escapeHtml(meta.accountName || "Google Ads 账户")}</h1>
788
+ <div class="meta-chips">
789
+ <span class="meta-chip">📅 <b>${escapeHtml(period)}</b></span>
790
+ <span class="meta-chip">账户 <b>${escapeHtml(meta.accountId || "—")}</b></span>
791
+ <span class="meta-chip">货币 <b>${escapeHtml(currency)}</b></span>
792
+ </div>
793
+ <div class="kpi-grid">${kpiHtml}</div>
794
+ <div class="toc">
795
+ <a href="#sec-daily-trend">每日趋势</a>
796
+ <a href="#sec-dimension-summary">周期汇总</a>
797
+ <a href="#sec-campaigns">系列表现</a>
798
+ <a href="#sec-devices">设备分布</a>
799
+ <a href="#sec-geographic">地域分布</a>
800
+ <a href="#sec-keywords">关键词</a>
801
+ <a href="#sec-recommendations">优化建议</a>
802
+ </div>
803
+ </div>`;
804
+ }
805
+
806
+ function renderExecutiveSummary(data) {
807
+ const summary = Array.isArray(data.narrative?.executiveSummary)
808
+ ? data.narrative.executiveSummary
809
+ : [];
810
+ const overviewSection = data.narrative?.sections?.overview;
811
+ const body =
812
+ summary.length > 0
813
+ ? summary.map((p) => `<p>${escapeHtml(p)}</p>`).join("")
814
+ : '<p class="empty-note">(待 Agent 撰写执行摘要)</p>';
815
+ return `
816
+ <section id="sec-overview">
817
+ ${sectionHead("01", "执行摘要", "本期消耗/展示/点击/转化/CTR/CPC/CPA 概览")}
818
+ <div class="card">
819
+ ${body}
820
+ ${analysisBlock(overviewSection)}
821
+ </div>
822
+ </section>`;
823
+ }
824
+
825
+ function renderDailyTrend(data) {
826
+ const chart = data.charts?.dailyTrend || {};
827
+ const dates = Array.isArray(chart.dates) ? chart.dates : [];
828
+ const section = data.narrative?.sections?.dailyTrend;
829
+ const head = sectionHead("02", "每日投放趋势", "按日消耗 / 转化 / CPA 曲线");
830
+ if (dates.length === 0) {
831
+ return `
832
+ <section id="sec-daily-trend">
833
+ ${head}
834
+ <div class="card">
835
+ <div class="empty-note">数据不可用:daily-metrics 未拉取或区间内无数据</div>
836
+ ${analysisBlock(section)}
837
+ </div>
838
+ </section>`;
839
+ }
840
+ return `
841
+ <section id="sec-daily-trend">
842
+ ${head}
843
+ <div class="card">
844
+ <div id="chart-daily-trend" class="chart-box"></div>
845
+ ${analysisBlock(section)}
846
+ </div>
847
+ </section>`;
848
+ }
849
+
850
+ function renderDimensionSummary(data) {
851
+ const summary = data.tables?.dimensionSummary || {};
852
+ const currency = data.meta?.currency || "CNY";
853
+ const section = data.narrative?.sections?.dimensionSummary;
854
+ const head = sectionHead("03", "周期汇总", "全周期累计核心指标与搜索曝光份额");
855
+ const rows = [
856
+ ["消耗", money(summary.cost, currency)],
857
+ ["展示次数", int(summary.impressions)],
858
+ ["点击次数", int(summary.clicks)],
859
+ ["转化次数", int(summary.conversions)],
860
+ ["点击率 CTR", pct(summary.ctr)],
861
+ ["平均点击费用", money(summary.averageCpc, currency)],
862
+ ["每次转化费用 CPA", money(summary.costPerConversion, currency)],
863
+ ["搜索广告曝光份额", pct(summary.searchImpressionShare)],
864
+ ];
865
+ const hasData = rows.some(([, v]) => v !== "—");
866
+ const grid = hasData
867
+ ? `<div class="stat-grid">${rows
868
+ .map(
869
+ ([label, v]) =>
870
+ `<div class="stat-chip"><div class="l">${label}</div><div class="v">${v}</div></div>`,
871
+ )
872
+ .join("")}</div>`
873
+ : '<div class="empty-note">数据不可用:dimension-summary 未拉取</div>';
874
+ return `
875
+ <section id="sec-dimension-summary">
876
+ ${head}
877
+ <div class="card">
878
+ ${grid}
879
+ ${analysisBlock(section)}
880
+ </div>
881
+ </section>`;
882
+ }
883
+
884
+ function renderCampaigns(data) {
885
+ const rows = Array.isArray(data.tables?.campaigns) ? data.tables.campaigns : [];
886
+ const currency = data.meta?.currency || "CNY";
887
+ const section = data.narrative?.sections?.campaigns;
888
+ const head = sectionHead("04", "广告系列表现", "按消耗排序,含状态与预算/出价类型");
889
+ const sorted = [...rows].sort((a, b) => (Number(b.spend) || 0) - (Number(a.spend) || 0));
890
+ const body = sorted
891
+ .map(
892
+ (r) => `
893
+ <tr>
894
+ <td>${escapeHtml(r.campaignName)}</td>
895
+ <td>${escapeHtml(r.campaignStatusDisplay || "—")}</td>
896
+ <td>${escapeHtml(r.channelTypeV2 || "—")}</td>
897
+ <td class="num">${money(r.spend, currency)}</td>
898
+ <td class="num">${int(r.impressions)}</td>
899
+ <td class="num">${int(r.clicks)}</td>
900
+ <td class="num">${pct(r.ctr)}</td>
901
+ <td class="num">${int(r.conversions)}</td>
902
+ <td class="num">${money(r.costPerConversion, currency)}</td>
903
+ </tr>`,
904
+ )
905
+ .join("");
906
+ const table =
907
+ sorted.length > 0
908
+ ? `<div class="chart-box" id="chart-campaigns-top" style="height:280px"></div>
909
+ <div class="table-wrap"><table>
910
+ <thead><tr><th>广告系列</th><th>状态</th><th>类型</th><th class="num">消耗</th><th class="num">展示</th><th class="num">点击</th><th class="num">CTR</th><th class="num">转化</th><th class="num">CPA</th></tr></thead>
911
+ <tbody>${body}</tbody>
912
+ </table></div>`
913
+ : '<div class="empty-note">数据不可用:campaigns 未拉取</div>';
914
+ return `
915
+ <section id="sec-campaigns">
916
+ ${head}
917
+ <div class="card">
918
+ ${table}
919
+ ${analysisBlock(section)}
920
+ </div>
921
+ </section>`;
922
+ }
923
+
924
+ function renderDevices(data) {
925
+ const rows = Array.isArray(data.tables?.devices) ? data.tables.devices : [];
926
+ const currency = data.meta?.currency || "CNY";
927
+ const section = data.narrative?.sections?.devices;
928
+ const head = sectionHead("05", "设备分布", "PC / 移动 / 平板 消耗与效果占比");
929
+ const sorted = [...rows].sort((a, b) => (Number(b.spend) || 0) - (Number(a.spend) || 0));
930
+ const body = sorted
931
+ .map(
932
+ (r) => `
933
+ <tr>
934
+ <td>${escapeHtml(r.deviceType)}</td>
935
+ <td class="num">${money(r.spend, currency)}</td>
936
+ <td class="num">${int(r.impressions)}</td>
937
+ <td class="num">${int(r.clicks)}</td>
938
+ <td class="num">${pct(r.ctr)}</td>
939
+ <td class="num">${int(r.conversions)}</td>
940
+ <td class="num">${money(r.costPerConversion, currency)}</td>
941
+ </tr>`,
942
+ )
943
+ .join("");
944
+ const table =
945
+ sorted.length > 0
946
+ ? `<div id="chart-devices" class="chart-box" style="height:280px"></div>
947
+ <div class="table-wrap"><table>
948
+ <thead><tr><th>设备</th><th class="num">消耗</th><th class="num">展示</th><th class="num">点击</th><th class="num">CTR</th><th class="num">转化</th><th class="num">CPA</th></tr></thead>
949
+ <tbody>${body}</tbody>
950
+ </table></div>`
951
+ : '<div class="empty-note">数据不可用:devices 未拉取</div>';
952
+ return `
953
+ <section id="sec-devices">
954
+ ${head}
955
+ <div class="card">
956
+ ${table}
957
+ ${analysisBlock(section)}
958
+ </div>
959
+ </section>`;
960
+ }
961
+
962
+ function renderGeographic(data) {
963
+ const rows = Array.isArray(data.tables?.geographic) ? data.tables.geographic : [];
964
+ const currency = data.meta?.currency || "CNY";
965
+ const section = data.narrative?.sections?.geographic;
966
+ const head = sectionHead("06", "地域分布(Top 20)", "按消耗排序的国家/地区表现");
967
+ const sorted = [...rows]
968
+ .sort((a, b) => (Number(b.spend) || 0) - (Number(a.spend) || 0))
969
+ .slice(0, 20);
970
+ const body = sorted
971
+ .map(
972
+ (r) => `
973
+ <tr>
974
+ <td>${escapeHtml(r.countryNameZh || r.countryOrRegionZh || r.countryOrRegion || "—")}</td>
975
+ <td class="num">${money(r.spend, currency)}</td>
976
+ <td class="num">${int(r.impressions)}</td>
977
+ <td class="num">${int(r.clicks)}</td>
978
+ <td class="num">${pct(r.ctr)}</td>
979
+ <td class="num">${int(r.conversions)}</td>
980
+ <td class="num">${money(r.costPerConversion, currency)}</td>
981
+ </tr>`,
982
+ )
983
+ .join("");
984
+ const table =
985
+ sorted.length > 0
986
+ ? `<div id="chart-geographic" class="chart-box" style="height:320px"></div>
987
+ <div class="table-wrap"><table>
988
+ <thead><tr><th>国家/地区</th><th class="num">消耗</th><th class="num">展示</th><th class="num">点击</th><th class="num">CTR</th><th class="num">转化</th><th class="num">CPA</th></tr></thead>
989
+ <tbody>${body}</tbody>
990
+ </table></div>`
991
+ : '<div class="empty-note">数据不可用:geographic 未拉取</div>';
992
+ return `
993
+ <section id="sec-geographic">
994
+ ${head}
995
+ <div class="card">
996
+ ${table}
997
+ ${analysisBlock(section)}
998
+ </div>
999
+ </section>`;
1000
+ }
1001
+
1002
+ function renderKeywords(data) {
1003
+ const rows = Array.isArray(data.tables?.keywords) ? data.tables.keywords : [];
1004
+ const currency = data.meta?.currency || "CNY";
1005
+ const section = data.narrative?.sections?.keywords;
1006
+ const head = sectionHead("07", "关键词表现(Top 50)", "跨系列合并展示,按消耗排序");
1007
+ const sorted = [...rows]
1008
+ .sort((a, b) => (Number(b.spend) || 0) - (Number(a.spend) || 0))
1009
+ .slice(0, 50);
1010
+ const body = sorted
1011
+ .map(
1012
+ (r) => `
1013
+ <tr>
1014
+ <td>${escapeHtml(r.keyword)}</td>
1015
+ <td>${escapeHtml(r.keywordMatchTypeZh || r.keywordMatchType || "—")}</td>
1016
+ <td>${escapeHtml(r.campaignName || "—")}</td>
1017
+ <td class="num">${money(r.spend, currency)}</td>
1018
+ <td class="num">${pct(r.ctr)}</td>
1019
+ <td class="num">${money(r.averageCpc, currency)}</td>
1020
+ <td class="num">${int(r.conversions)}</td>
1021
+ <td class="num">${money(r.costPerConversion, currency)}</td>
1022
+ </tr>`,
1023
+ )
1024
+ .join("");
1025
+ const table =
1026
+ sorted.length > 0
1027
+ ? `<div class="table-wrap"><table>
1028
+ <thead><tr><th>关键词</th><th>匹配方式</th><th>广告系列</th><th class="num">消耗</th><th class="num">CTR</th><th class="num">平均CPC</th><th class="num">转化</th><th class="num">CPA</th></tr></thead>
1029
+ <tbody>${body}</tbody>
1030
+ </table></div>
1031
+ <div class="card-title-row" style="margin-top:10px"><span class="hint">共 ${rows.length} 个关键词,表格展示消耗 Top 50</span></div>`
1032
+ : '<div class="empty-note">数据不可用:keywords 未拉取</div>';
1033
+ return `
1034
+ <section id="sec-keywords">
1035
+ ${head}
1036
+ <div class="card">
1037
+ ${table}
1038
+ ${analysisBlock(section)}
1039
+ </div>
1040
+ </section>`;
1041
+ }
1042
+
1043
+ function renderRecommendations(data) {
1044
+ const recs = Array.isArray(data.narrative?.recommendations)
1045
+ ? data.narrative.recommendations
1046
+ : [];
1047
+ const cards = recs
1048
+ .map((r, i) => {
1049
+ const num = `<span class="rec-num">${i + 1}</span>`;
1050
+ if (typeof r === "string") {
1051
+ return `<div class="rec-card">${num}<div class="rec-body"><div class="rec-content">${escapeHtml(r)}</div></div></div>`;
1052
+ }
1053
+ const title = r?.title ? `<div class="rec-title">${escapeHtml(r.title)}</div>` : "";
1054
+ return `<div class="rec-card">${num}<div class="rec-body">${title}<div class="rec-content">${escapeHtml(r?.content || "")}</div></div></div>`;
1055
+ })
1056
+ .join("");
1057
+ const body =
1058
+ recs.length > 0
1059
+ ? `<div class="recommendation-list">${cards}</div>`
1060
+ : '<div class="empty-note">(待 Agent 撰写优化建议)</div>';
1061
+ return `
1062
+ <section id="sec-recommendations">
1063
+ ${sectionHead("08", "优化建议", "综合以上数据的可执行改进建议")}
1064
+ <div class="card">${body}</div>
1065
+ </section>`;
1066
+ }
1067
+
1068
+ function renderFooter(data) {
1069
+ const generatedAt = data.meta?.generatedAt || new Date().toISOString();
1070
+ return `<div class="footer"><span class="brand">siluzan-tso</span> · Google Ads 周期分析报告 · ${escapeHtml(generatedAt)} · 数据来源 google-analysis</div>`;
1071
+ }
1072
+
1073
+ function styleAxis(extra) {
1074
+ return Object.assign(
1075
+ {
1076
+ axisLine: { lineStyle: { color: "rgba(15,23,42,0.14)" } },
1077
+ axisTick: { show: false },
1078
+ axisLabel: { color: AXIS_COLOR },
1079
+ splitLine: { lineStyle: { color: SPLIT_LINE_COLOR, type: "dashed" } },
1080
+ },
1081
+ extra,
1082
+ );
1083
+ }
1084
+
1085
+ function renderCharts(data) {
1086
+ if (typeof echarts === "undefined") return;
1087
+ const currency = data.meta?.currency || "CNY";
1088
+
1089
+ const dailyEl = document.getElementById("chart-daily-trend");
1090
+ const daily = data.charts?.dailyTrend;
1091
+ if (dailyEl && daily && Array.isArray(daily.dates) && daily.dates.length > 0) {
1092
+ const chart = echarts.init(dailyEl);
1093
+ chart.setOption({
1094
+ tooltip: Object.assign(
1095
+ {
1096
+ trigger: "axis",
1097
+ formatter: (params) =>
1098
+ params
1099
+ .map((p) => {
1100
+ if (p.seriesName === "消耗" || p.seriesName === "CPA") {
1101
+ return `${p.marker}${p.seriesName}: ${fixed2(p.value)}`;
1102
+ }
1103
+ return `${p.marker}${p.seriesName}: ${int(p.value)}`;
1104
+ })
1105
+ .join("<br/>"),
1106
+ },
1107
+ TOOLTIP_STYLE,
1108
+ ),
1109
+ legend: { data: ["消耗", "转化", "CPA"], textStyle: { color: AXIS_COLOR }, top: 0 },
1110
+ grid: { left: 52, right: 52, top: 44, bottom: 30 },
1111
+ xAxis: styleAxis({ type: "category", data: daily.dates, boundaryGap: false }),
1112
+ yAxis: [
1113
+ styleAxis({ type: "value", name: "消耗/CPA" }),
1114
+ styleAxis({ type: "value", name: "转化", splitLine: { show: false } }),
1115
+ ],
1116
+ series: [
1117
+ {
1118
+ name: "消耗",
1119
+ type: "line",
1120
+ data: daily.cost || [],
1121
+ smooth: true,
1122
+ showSymbol: false,
1123
+ lineStyle: { width: 2.5, color: PALETTE[0] },
1124
+ areaStyle: {
1125
+ color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
1126
+ { offset: 0, color: "rgba(59,130,246,0.22)" },
1127
+ { offset: 1, color: "rgba(59,130,246,0.02)" },
1128
+ ]),
1129
+ },
1130
+ },
1131
+ {
1132
+ name: "转化",
1133
+ type: "bar",
1134
+ yAxisIndex: 1,
1135
+ data: daily.conversions || [],
1136
+ barMaxWidth: 18,
1137
+ itemStyle: { color: "rgba(16,185,129,0.55)", borderRadius: [4, 4, 0, 0] },
1138
+ },
1139
+ {
1140
+ name: "CPA",
1141
+ type: "line",
1142
+ data: daily.cpa || [],
1143
+ smooth: true,
1144
+ showSymbol: false,
1145
+ lineStyle: { width: 2, color: PALETTE[4], type: "dashed" },
1146
+ },
1147
+ ],
1148
+ });
1149
+ }
1150
+
1151
+ const campaignsEl = document.getElementById("chart-campaigns-top");
1152
+ const campaigns = Array.isArray(data.tables?.campaigns) ? data.tables.campaigns : [];
1153
+ if (campaignsEl && campaigns.length > 0) {
1154
+ const top = [...campaigns]
1155
+ .sort((a, b) => (Number(b.spend) || 0) - (Number(a.spend) || 0))
1156
+ .slice(0, 10)
1157
+ .reverse();
1158
+ const chart = echarts.init(campaignsEl);
1159
+ chart.setOption({
1160
+ tooltip: Object.assign(
1161
+ { trigger: "axis", formatter: (p) => money(p[0].value, currency) },
1162
+ TOOLTIP_STYLE,
1163
+ ),
1164
+ grid: { left: 140, right: 30, top: 16, bottom: 20 },
1165
+ xAxis: styleAxis({ type: "value" }),
1166
+ yAxis: styleAxis({
1167
+ type: "category",
1168
+ data: top.map((c) => c.campaignName || "—"),
1169
+ axisLabel: { color: AXIS_COLOR, width: 120, overflow: "truncate" },
1170
+ splitLine: { show: false },
1171
+ }),
1172
+ series: [
1173
+ {
1174
+ type: "bar",
1175
+ data: top.map((c) => Number(c.spend) || 0),
1176
+ barMaxWidth: 18,
1177
+ itemStyle: {
1178
+ borderRadius: [0, 6, 6, 0],
1179
+ color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
1180
+ { offset: 0, color: "rgba(59,130,246,0.55)" },
1181
+ { offset: 1, color: PALETTE[0] },
1182
+ ]),
1183
+ },
1184
+ },
1185
+ ],
1186
+ });
1187
+ }
1188
+
1189
+ const devicesEl = document.getElementById("chart-devices");
1190
+ const devices = Array.isArray(data.tables?.devices) ? data.tables.devices : [];
1191
+ if (devicesEl && devices.length > 0) {
1192
+ const chart = echarts.init(devicesEl);
1193
+ chart.setOption({
1194
+ tooltip: Object.assign(
1195
+ { trigger: "item", formatter: (p) => `${p.name}: ${money(p.value, currency)}` },
1196
+ TOOLTIP_STYLE,
1197
+ ),
1198
+ legend: { bottom: 0, textStyle: { color: AXIS_COLOR } },
1199
+ series: [
1200
+ {
1201
+ type: "pie",
1202
+ radius: ["44%", "72%"],
1203
+ itemStyle: { borderColor: "#ffffff", borderWidth: 3, borderRadius: 8 },
1204
+ data: devices.map((d, i) => ({
1205
+ name: d.deviceType || "—",
1206
+ value: Number(d.spend) || 0,
1207
+ itemStyle: { color: PALETTE[i % PALETTE.length] },
1208
+ })),
1209
+ label: { color: "#0f172a", fontSize: 12 },
1210
+ labelLine: { lineStyle: { color: "rgba(15,23,42,0.25)" } },
1211
+ },
1212
+ ],
1213
+ });
1214
+ }
1215
+
1216
+ const geoEl = document.getElementById("chart-geographic");
1217
+ const geo = Array.isArray(data.tables?.geographic) ? data.tables.geographic : [];
1218
+ if (geoEl && geo.length > 0) {
1219
+ const top = [...geo]
1220
+ .sort((a, b) => (Number(b.spend) || 0) - (Number(a.spend) || 0))
1221
+ .slice(0, 10)
1222
+ .reverse();
1223
+ const chart = echarts.init(geoEl);
1224
+ chart.setOption({
1225
+ tooltip: Object.assign(
1226
+ { trigger: "axis", formatter: (p) => money(p[0].value, currency) },
1227
+ TOOLTIP_STYLE,
1228
+ ),
1229
+ grid: { left: 140, right: 30, top: 16, bottom: 20 },
1230
+ xAxis: styleAxis({ type: "value" }),
1231
+ yAxis: styleAxis({
1232
+ type: "category",
1233
+ data: top.map(
1234
+ (c) => c.countryNameZh || c.countryOrRegionZh || c.countryOrRegion || "—",
1235
+ ),
1236
+ axisLabel: { color: AXIS_COLOR, width: 120, overflow: "truncate" },
1237
+ splitLine: { show: false },
1238
+ }),
1239
+ series: [
1240
+ {
1241
+ type: "bar",
1242
+ data: top.map((c) => Number(c.spend) || 0),
1243
+ barMaxWidth: 18,
1244
+ itemStyle: {
1245
+ borderRadius: [0, 6, 6, 0],
1246
+ color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
1247
+ { offset: 0, color: "rgba(16,185,129,0.55)" },
1248
+ { offset: 1, color: PALETTE[3] },
1249
+ ]),
1250
+ },
1251
+ },
1252
+ ],
1253
+ });
1254
+ }
1255
+
1256
+ window.addEventListener("resize", () => {
1257
+ document
1258
+ .querySelectorAll(".chart-box")
1259
+ .forEach((el) => echarts.getInstanceByDom(el)?.resize());
1260
+ });
1261
+ }
1262
+
1263
+ function setupScrollSpy() {
1264
+ const links = Array.from(document.querySelectorAll(".toc a"));
1265
+ if (links.length === 0 || typeof IntersectionObserver === "undefined") return;
1266
+ const targets = links
1267
+ .map((a) => document.querySelector(a.getAttribute("href")))
1268
+ .filter(Boolean);
1269
+ const setActive = (id) => {
1270
+ links.forEach((a) => a.classList.toggle("active", a.getAttribute("href") === `#${id}`));
1271
+ };
1272
+ const observer = new IntersectionObserver(
1273
+ (entries) => {
1274
+ const visible = entries.filter((e) => e.isIntersecting);
1275
+ if (visible.length > 0) setActive(visible[0].target.id);
1276
+ },
1277
+ { rootMargin: "-20% 0px -70% 0px" },
1278
+ );
1279
+ targets.forEach((el) => observer.observe(el));
1280
+ }
1281
+
1282
+ function render() {
1283
+ const data = window.__GOOGLE_PERIOD_REPORT__;
1284
+ const root = document.getElementById("report-root");
1285
+ if (!data) {
1286
+ root.innerHTML =
1287
+ '<div class="card">未找到报告数据(window.__GOOGLE_PERIOD_REPORT__ 为空),请通过 `google-analysis render` 生成本文件。</div>';
1288
+ return;
1289
+ }
1290
+ root.innerHTML = [
1291
+ renderHero(data),
1292
+ renderExecutiveSummary(data),
1293
+ renderDailyTrend(data),
1294
+ renderDimensionSummary(data),
1295
+ renderCampaigns(data),
1296
+ renderDevices(data),
1297
+ renderGeographic(data),
1298
+ renderKeywords(data),
1299
+ renderRecommendations(data),
1300
+ renderFooter(data),
1301
+ ].join("\n");
1302
+ renderCharts(data);
1303
+ setupScrollSpy();
1304
+ }
1305
+
1306
+ if (document.readyState === "loading") {
1307
+ document.addEventListener("DOMContentLoaded", render);
1308
+ } else {
1309
+ render();
1310
+ }
1311
+ })();
1312
+ </script>
1313
+ </body>
1314
+ </html>