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,1343 @@
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>Bing 广告账户分析报告</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: #0ea5e9;
21
+ --blue-deep: #0369a1;
22
+ --purple: #6366f1;
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(14, 165, 233, 0.08), transparent 60%),
53
+ radial-gradient(900px 480px at 104% 0%, rgba(99, 102, 241, 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, #eaf7ff 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(14, 165, 233, 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(14, 165, 233, 0.1);
95
+ border: 1px solid rgba(14, 165, 233, 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(14, 165, 233, 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.4rem;
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: 8px;
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: 300px;
310
+ margin: 10px 0 4px;
311
+ }
312
+ .chart-box.sm {
313
+ height: 220px;
314
+ }
315
+ .chart-grid-2 {
316
+ display: grid;
317
+ grid-template-columns: 1fr 1fr;
318
+ gap: 14px;
319
+ }
320
+
321
+ /* ── Insight / suggestion callouts ── */
322
+ .insight {
323
+ font-size: 0.9rem;
324
+ color: var(--text-dim);
325
+ border: 1px solid transparent;
326
+ border-left: 3px solid var(--blue);
327
+ padding: 13px 16px;
328
+ background: rgba(14, 165, 233, 0.06);
329
+ border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
330
+ margin-top: 14px;
331
+ }
332
+ .insight strong {
333
+ display: flex;
334
+ align-items: center;
335
+ gap: 6px;
336
+ color: var(--blue-deep);
337
+ font-size: 0.82rem;
338
+ letter-spacing: 0.02em;
339
+ margin-bottom: 6px;
340
+ }
341
+ .insight.suggestion {
342
+ border-left-color: var(--green);
343
+ background: rgba(16, 185, 129, 0.06);
344
+ }
345
+ .insight.suggestion strong {
346
+ color: var(--green-deep);
347
+ }
348
+ .insight ul {
349
+ margin: 0;
350
+ padding-left: 18px;
351
+ }
352
+ .insight li {
353
+ margin-bottom: 5px;
354
+ }
355
+ .insight li:last-child {
356
+ margin-bottom: 0;
357
+ }
358
+
359
+ /* ── Tables ── */
360
+ .table-wrap {
361
+ border: 1px solid var(--border);
362
+ border-radius: var(--radius-md);
363
+ overflow: hidden;
364
+ }
365
+ table {
366
+ width: 100%;
367
+ border-collapse: collapse;
368
+ font-size: 0.86rem;
369
+ }
370
+ th,
371
+ td {
372
+ padding: 11px 14px;
373
+ text-align: left;
374
+ border-bottom: 1px solid var(--border);
375
+ white-space: nowrap;
376
+ }
377
+ td {
378
+ color: var(--text-dim);
379
+ }
380
+ th {
381
+ color: var(--muted);
382
+ font-weight: 700;
383
+ font-size: 0.76rem;
384
+ text-transform: uppercase;
385
+ letter-spacing: 0.03em;
386
+ background: var(--surface-2);
387
+ }
388
+ th.num,
389
+ td.num {
390
+ text-align: right;
391
+ font-variant-numeric: tabular-nums;
392
+ }
393
+ tbody tr:nth-child(even) td {
394
+ background: rgba(15, 23, 42, 0.015);
395
+ }
396
+ tbody tr:hover td {
397
+ background: rgba(14, 165, 233, 0.05);
398
+ color: var(--text);
399
+ }
400
+ tbody tr:last-child td {
401
+ border-bottom: none;
402
+ }
403
+
404
+ /* ── Recommendations ── */
405
+ .recommendation-list {
406
+ display: grid;
407
+ gap: 12px;
408
+ }
409
+ .rec-card {
410
+ position: relative;
411
+ display: flex;
412
+ gap: 14px;
413
+ border: 1px solid var(--border);
414
+ border-radius: var(--radius-md);
415
+ padding: 16px 18px;
416
+ background: var(--surface-2);
417
+ }
418
+ .rec-num {
419
+ flex: none;
420
+ display: inline-flex;
421
+ align-items: center;
422
+ justify-content: center;
423
+ width: 28px;
424
+ height: 28px;
425
+ border-radius: 50%;
426
+ background: rgba(16, 185, 129, 0.14);
427
+ color: var(--green-deep);
428
+ font-weight: 800;
429
+ font-size: 0.85rem;
430
+ }
431
+ .rec-body {
432
+ flex: 1;
433
+ min-width: 0;
434
+ }
435
+ .rec-card .rec-title {
436
+ font-weight: 700;
437
+ margin-bottom: 4px;
438
+ color: var(--green-deep);
439
+ font-size: 0.92rem;
440
+ }
441
+ .rec-card .rec-content {
442
+ color: var(--text-dim);
443
+ font-size: 0.9rem;
444
+ }
445
+
446
+ .footer {
447
+ text-align: center;
448
+ color: var(--muted);
449
+ font-size: 0.78rem;
450
+ margin-top: 50px;
451
+ padding-top: 22px;
452
+ border-top: 1px solid var(--border);
453
+ }
454
+ .footer .brand {
455
+ color: var(--text-dim);
456
+ font-weight: 600;
457
+ }
458
+
459
+ .empty-note {
460
+ display: flex;
461
+ align-items: center;
462
+ gap: 8px;
463
+ color: var(--muted);
464
+ font-size: 0.86rem;
465
+ padding: 14px 16px;
466
+ border: 1px dashed var(--border-strong);
467
+ border-radius: var(--radius-sm);
468
+ background: var(--surface-2);
469
+ }
470
+ .empty-note::before {
471
+ content: "•";
472
+ color: var(--muted);
473
+ flex: none;
474
+ }
475
+
476
+ @keyframes fadeInUp {
477
+ from {
478
+ opacity: 0;
479
+ transform: translateY(10px);
480
+ }
481
+ to {
482
+ opacity: 1;
483
+ transform: translateY(0);
484
+ }
485
+ }
486
+
487
+ @supports (padding: env(safe-area-inset-bottom)) {
488
+ body {
489
+ padding-left: env(safe-area-inset-left, 0);
490
+ padding-right: env(safe-area-inset-right, 0);
491
+ padding-bottom: env(safe-area-inset-bottom, 0);
492
+ }
493
+ }
494
+
495
+ @media (max-width: 768px) {
496
+ html {
497
+ -webkit-text-size-adjust: 100%;
498
+ text-size-adjust: 100%;
499
+ }
500
+ body {
501
+ overflow-x: clip;
502
+ -webkit-tap-highlight-color: transparent;
503
+ }
504
+ .wrap {
505
+ padding: max(12px, env(safe-area-inset-left, 0px))
506
+ max(12px, env(safe-area-inset-right, 0px)) max(40px, env(safe-area-inset-bottom, 0px));
507
+ }
508
+ .hero {
509
+ padding: 20px 18px;
510
+ border-radius: 14px;
511
+ }
512
+ .hero h1 {
513
+ font-size: clamp(1.15rem, 5.2vw, 1.4rem);
514
+ line-height: 1.4;
515
+ word-break: break-word;
516
+ }
517
+ .meta-chip {
518
+ font-size: 0.72rem;
519
+ }
520
+ .kpi-grid {
521
+ grid-template-columns: repeat(2, minmax(0, 1fr));
522
+ gap: 10px;
523
+ }
524
+ .kpi {
525
+ padding: 12px 12px 11px;
526
+ }
527
+ .kpi .v {
528
+ font-size: clamp(1.05rem, 4.5vw, 1.25rem);
529
+ }
530
+ .sec-head h2 {
531
+ font-size: clamp(1rem, 4.2vw, 1.12rem);
532
+ }
533
+ .card {
534
+ padding: 16px 14px;
535
+ border-radius: 12px;
536
+ }
537
+ .chart-box {
538
+ height: clamp(220px, 56vw, 260px);
539
+ }
540
+ .chart-grid-2 {
541
+ grid-template-columns: 1fr;
542
+ }
543
+ .recommendation-list {
544
+ grid-template-columns: 1fr !important;
545
+ }
546
+ .card:has(table) {
547
+ overflow-x: auto;
548
+ overscroll-behavior-x: contain;
549
+ -webkit-overflow-scrolling: touch;
550
+ }
551
+ .card:has(table)::before {
552
+ display: block;
553
+ text-align: center;
554
+ font-size: 12px;
555
+ color: var(--muted);
556
+ padding: 0 0 8px;
557
+ content: "左右滑动查看表格";
558
+ }
559
+ .card table {
560
+ min-width: 520px;
561
+ font-size: 0.82rem;
562
+ }
563
+ th,
564
+ td {
565
+ padding: 9px 11px;
566
+ }
567
+ }
568
+
569
+ @media (min-width: 769px) {
570
+ .card:has(table)::before {
571
+ display: none;
572
+ content: none;
573
+ }
574
+ }
575
+
576
+ @media print {
577
+ html {
578
+ scroll-behavior: auto;
579
+ }
580
+ .toc {
581
+ display: none;
582
+ }
583
+ section,
584
+ .hero,
585
+ .kpi {
586
+ animation: none !important;
587
+ }
588
+ .card:has(table)::before {
589
+ display: none !important;
590
+ content: none !important;
591
+ }
592
+ }
593
+ </style>
594
+ </head>
595
+ <body>
596
+ <div id="report-root" class="wrap"></div>
597
+ <script>
598
+ window.__BING_PERIOD_REPORT__ = window.__BING_PERIOD_REPORT__ || null;
599
+ </script>
600
+ <script>
601
+ /**
602
+ * Bing(BingV2)广告账户分析 HTML 报告运行时
603
+ * 数据来自 window.__BING_PERIOD_REPORT__,由 bing-analysis render 注入;
604
+ * Agent 不得手写/拼接 HTML,只提供 JSON 数据。
605
+ */
606
+ (function () {
607
+ "use strict";
608
+
609
+ const PALETTE = [
610
+ "#0ea5e9",
611
+ "#6366f1",
612
+ "#06b6d4",
613
+ "#10b981",
614
+ "#f59e0b",
615
+ "#f97316",
616
+ "#ec4899",
617
+ ];
618
+ const AXIS_COLOR = "#64748b";
619
+ const SPLIT_LINE_COLOR = "rgba(15,23,42,0.08)";
620
+ const EMPTY_NOTE = "本节暂无数据";
621
+ const TOOLTIP_STYLE = {
622
+ backgroundColor: "#ffffff",
623
+ borderColor: "rgba(15,23,42,0.10)",
624
+ borderWidth: 1,
625
+ textStyle: { color: "#0f172a", fontSize: 12 },
626
+ extraCssText:
627
+ "box-shadow:0 10px 28px -12px rgba(15,23,42,.18);border-radius:10px;padding:9px 12px;",
628
+ };
629
+
630
+ function escapeHtml(s) {
631
+ return String(s ?? "")
632
+ .replace(/&/g, "&amp;")
633
+ .replace(/</g, "&lt;")
634
+ .replace(/>/g, "&gt;")
635
+ .replace(/"/g, "&quot;");
636
+ }
637
+
638
+ function int(v) {
639
+ const n = Number(v);
640
+ return Number.isFinite(n) ? Math.round(n).toLocaleString("en-US") : "—";
641
+ }
642
+
643
+ function money(v, currency) {
644
+ const n = Number(v);
645
+ if (!Number.isFinite(n)) return "—";
646
+ const sym = currency && currency !== "CNY" ? currency + " " : "¥";
647
+ return sym + n.toFixed(2);
648
+ }
649
+
650
+ function pct(v) {
651
+ const n = Number(v);
652
+ if (!Number.isFinite(n)) return "—";
653
+ return (n * 100).toFixed(2) + "%";
654
+ }
655
+
656
+ function qs(v) {
657
+ const n = Number(v);
658
+ if (!Number.isFinite(n) || n <= 0) return "—";
659
+ return String(Math.round(n));
660
+ }
661
+
662
+ function kpiVal(kpis, key) {
663
+ if (!kpis) return undefined;
664
+ if (key === "spend") {
665
+ return kpis.spend != null ? kpis.spend : kpis.cost;
666
+ }
667
+ return kpis[key];
668
+ }
669
+
670
+ function deltaLabel(current, previous) {
671
+ const c = Number(current);
672
+ const p = Number(previous);
673
+ if (!Number.isFinite(c) || !Number.isFinite(p) || p === 0) return "";
674
+ const change = ((c - p) / Math.abs(p)) * 100;
675
+ const cls = change >= 0 ? "up" : "down";
676
+ const arrow = change >= 0 ? "▲" : "▼";
677
+ return `<span class="d ${cls}">${arrow} ${Math.abs(change).toFixed(1)}% 环比</span>`;
678
+ }
679
+
680
+ function sectionHead(num, title, desc) {
681
+ return `
682
+ <div class="sec-head">
683
+ <span class="sec-num">${num}</span>
684
+ <div>
685
+ <h2>${escapeHtml(title)}</h2>
686
+ ${desc ? `<p class="sec-desc">${escapeHtml(desc)}</p>` : ""}
687
+ </div>
688
+ </div>`;
689
+ }
690
+
691
+ function analysisBlock(section) {
692
+ const analysis = Array.isArray(section?.analysis) ? section.analysis : [];
693
+ const suggestions = Array.isArray(section?.suggestions) ? section.suggestions : [];
694
+ const parts = [];
695
+ if (analysis.length > 0) {
696
+ parts.push(
697
+ `<div class="insight"><strong>总结</strong><ul>${analysis
698
+ .map((a) => `<li>${escapeHtml(a)}</li>`)
699
+ .join("")}</ul></div>`,
700
+ );
701
+ } else {
702
+ parts.push(`<div class="empty-note">${EMPTY_NOTE}</div>`);
703
+ }
704
+ if (suggestions.length > 0) {
705
+ parts.push(
706
+ `<div class="insight suggestion"><strong>建议</strong><ul>${suggestions
707
+ .map((s) => `<li>${escapeHtml(s)}</li>`)
708
+ .join("")}</ul></div>`,
709
+ );
710
+ }
711
+ return parts.join("");
712
+ }
713
+
714
+ function topBySpend(rows, n) {
715
+ return [...(Array.isArray(rows) ? rows : [])]
716
+ .sort((a, b) => (Number(b.spend) || 0) - (Number(a.spend) || 0))
717
+ .slice(0, n);
718
+ }
719
+
720
+ function emptyNoteHtml() {
721
+ return `<div class="empty-note">${EMPTY_NOTE}</div>`;
722
+ }
723
+
724
+ function currencyOf(data) {
725
+ return data.meta?.currency || data.kpis?.currency || "CNY";
726
+ }
727
+
728
+ function renderHero(data) {
729
+ const meta = data.meta || {};
730
+ const kpis = data.kpis || {};
731
+ const prev = kpis.previousPeriod || {};
732
+ const currency = currencyOf(data);
733
+ const period =
734
+ meta.startDate && meta.endDate
735
+ ? `${meta.startDate} ~ ${meta.endDate}`
736
+ : "统计区间未提供";
737
+ const deltaKeys = new Set([
738
+ "spend",
739
+ "clicks",
740
+ "conversions",
741
+ "ctr",
742
+ "averageCpc",
743
+ "costPerConversion",
744
+ ]);
745
+ const kpiDefs = [
746
+ { key: "spend", label: "消耗", fmt: (v) => money(v, currency) },
747
+ { key: "impressions", label: "展示次数", fmt: int },
748
+ { key: "clicks", label: "点击次数", fmt: int },
749
+ { key: "conversions", label: "转化次数", fmt: int },
750
+ { key: "ctr", label: "点击率 CTR", fmt: pct },
751
+ { key: "averageCpc", label: "平均点击费用", fmt: (v) => money(v, currency) },
752
+ { key: "costPerConversion", label: "每次转化费用 CPA", fmt: (v) => money(v, currency) },
753
+ { key: "averageDailyCost", label: "日均消耗", fmt: (v) => money(v, currency) },
754
+ { key: "balance", label: "账户余额", fmt: (v) => money(v, currency) },
755
+ ];
756
+ const kpiHtml = kpiDefs
757
+ .map((def, i) => {
758
+ const v = kpiVal(kpis, def.key);
759
+ const p =
760
+ def.key === "spend"
761
+ ? prev.spend != null
762
+ ? prev.spend
763
+ : prev.cost
764
+ : prev[def.key];
765
+ const color = PALETTE[i % PALETTE.length];
766
+ const delta = deltaKeys.has(def.key) ? deltaLabel(v, p) : "";
767
+ return `<div class="kpi" style="--kpi-color:${color}">
768
+ <div class="l"><span class="dot"></span>${def.label}</div>
769
+ <div class="v">${def.fmt(v)}</div>
770
+ ${delta}
771
+ </div>`;
772
+ })
773
+ .join("");
774
+ return `
775
+ <div class="hero">
776
+ <div class="eyebrow">BING ADS · 账户分析报告</div>
777
+ <h1>${escapeHtml(meta.accountName || "Bing 广告账户")}</h1>
778
+ <div class="meta-chips">
779
+ <span class="meta-chip">📅 <b>${escapeHtml(period)}</b></span>
780
+ <span class="meta-chip">账户 <b>${escapeHtml(meta.accountId || "—")}</b></span>
781
+ <span class="meta-chip">货币 <b>${escapeHtml(currency)}</b></span>
782
+ </div>
783
+ <div class="kpi-grid">${kpiHtml}</div>
784
+ <div class="toc">
785
+ <a href="#sec-overview">执行摘要</a>
786
+ <a href="#sec-devices">设备</a>
787
+ <a href="#sec-geographic">地域</a>
788
+ <a href="#sec-audience">受众</a>
789
+ <a href="#sec-campaigns">系列</a>
790
+ <a href="#sec-adgroups">广告组</a>
791
+ <a href="#sec-ads">广告</a>
792
+ <a href="#sec-keywords">关键词</a>
793
+ <a href="#sec-search-terms">搜索字词</a>
794
+ <a href="#sec-recommendations">优化建议</a>
795
+ </div>
796
+ </div>`;
797
+ }
798
+
799
+ function renderExecutiveSummary(data) {
800
+ const summary = Array.isArray(data.narrative?.executiveSummary)
801
+ ? data.narrative.executiveSummary
802
+ : [];
803
+ const overviewSection = data.narrative?.sections?.overview;
804
+ const body =
805
+ summary.length > 0
806
+ ? summary.map((p) => `<p>${escapeHtml(p)}</p>`).join("")
807
+ : emptyNoteHtml();
808
+ return `
809
+ <section id="sec-overview">
810
+ ${sectionHead("01", "执行摘要(总览)", "本期消耗 / 展示 / 点击 / 转化 / CTR / CPC / CPA / 日均消耗 / 余额")}
811
+ <div class="card">
812
+ ${body}
813
+ ${analysisBlock(overviewSection)}
814
+ </div>
815
+ </section>`;
816
+ }
817
+
818
+ function renderDevices(data) {
819
+ const rows = Array.isArray(data.tables?.devices) ? data.tables.devices : [];
820
+ const currency = currencyOf(data);
821
+ const section = data.narrative?.sections?.devices;
822
+ const sorted = topBySpend(rows, rows.length || 0);
823
+ const body = sorted
824
+ .map(
825
+ (r) => `<tr>
826
+ <td>${escapeHtml(r.deviceType || "—")}</td>
827
+ <td class="num">${money(r.spend, currency)}</td>
828
+ <td class="num">${int(r.impressions)}</td>
829
+ <td class="num">${int(r.clicks)}</td>
830
+ <td class="num">${pct(r.ctr)}</td>
831
+ <td class="num">${money(r.averageCpc, currency)}</td>
832
+ <td class="num">${int(r.conversions)}</td>
833
+ <td class="num">${money(r.costPerConversion, currency)}</td>
834
+ </tr>`,
835
+ )
836
+ .join("");
837
+ const table =
838
+ sorted.length > 0
839
+ ? `<div class="chart-grid-2">
840
+ <div id="chart-devices-pie" class="chart-box" style="height:280px"></div>
841
+ <div id="chart-devices-bar" class="chart-box" style="height:280px"></div>
842
+ </div>
843
+ <div class="table-wrap"><table>
844
+ <thead><tr><th>设备</th><th class="num">消耗</th><th class="num">展示</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>
845
+ <tbody>${body}</tbody>
846
+ </table></div>`
847
+ : emptyNoteHtml();
848
+ return `
849
+ <section id="sec-devices">
850
+ ${sectionHead("02", "设备分布", "各设备类型消耗与效果占比")}
851
+ <div class="card">
852
+ ${table}
853
+ ${analysisBlock(section)}
854
+ </div>
855
+ </section>`;
856
+ }
857
+
858
+ function renderGeographic(data) {
859
+ const rows = Array.isArray(data.tables?.geographic) ? data.tables.geographic : [];
860
+ const currency = currencyOf(data);
861
+ const section = data.narrative?.sections?.geographic;
862
+ const sorted = topBySpend(rows, 10);
863
+ const body = sorted
864
+ .map(
865
+ (r) => `<tr>
866
+ <td>${escapeHtml(r.countryOrRegion || "—")}</td>
867
+ <td class="num">${money(r.spend, currency)}</td>
868
+ <td class="num">${int(r.impressions)}</td>
869
+ <td class="num">${int(r.clicks)}</td>
870
+ <td class="num">${pct(r.ctr)}</td>
871
+ <td class="num">${money(r.averageCpc, currency)}</td>
872
+ <td class="num">${int(r.conversions)}</td>
873
+ <td class="num">${money(r.costPerConversion, currency)}</td>
874
+ </tr>`,
875
+ )
876
+ .join("");
877
+ const table =
878
+ sorted.length > 0
879
+ ? `<div id="chart-geographic" class="chart-box" style="height:320px"></div>
880
+ <div class="card-title-row"><h3>地域 Top 10</h3><span class="hint">共 ${rows.length} 个国家/地区,按消耗排序</span></div>
881
+ <div class="table-wrap"><table>
882
+ <thead><tr><th>国家/地区</th><th class="num">消耗</th><th class="num">展示</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>
883
+ <tbody>${body}</tbody>
884
+ </table></div>`
885
+ : emptyNoteHtml();
886
+ return `
887
+ <section id="sec-geographic">
888
+ ${sectionHead("03", "地域分布", "按消耗排序的国家/地区表现")}
889
+ <div class="card">
890
+ ${table}
891
+ ${analysisBlock(section)}
892
+ </div>
893
+ </section>`;
894
+ }
895
+
896
+ function renderAudience(data) {
897
+ const age = Array.isArray(data.tables?.audienceAge) ? data.tables.audienceAge : [];
898
+ const gender = Array.isArray(data.tables?.audienceGender)
899
+ ? data.tables.audienceGender
900
+ : [];
901
+ const currency = currencyOf(data);
902
+ const section = data.narrative?.sections?.audience;
903
+
904
+ const rowsHtml = (rows, labelKey) =>
905
+ topBySpend(rows, rows.length || 0)
906
+ .map(
907
+ (r) => `<tr>
908
+ <td>${escapeHtml(r[labelKey] || "—")}</td>
909
+ <td class="num">${money(r.spend, currency)}</td>
910
+ <td class="num">${int(r.impressions)}</td>
911
+ <td class="num">${int(r.clicks)}</td>
912
+ <td class="num">${pct(r.ctr)}</td>
913
+ <td class="num">${money(r.averageCpc, currency)}</td>
914
+ </tr>`,
915
+ )
916
+ .join("");
917
+
918
+ const ageBlock =
919
+ age.length > 0
920
+ ? `<div class="card-title-row"><h3>年龄段</h3></div>
921
+ <div id="chart-audience-age" class="chart-box sm"></div>
922
+ <div class="table-wrap"><table>
923
+ <thead><tr><th>年龄段</th><th class="num">消耗</th><th class="num">展示</th><th class="num">点击</th><th class="num">CTR</th><th class="num">平均CPC</th></tr></thead>
924
+ <tbody>${rowsHtml(age, "ageRange")}</tbody>
925
+ </table></div>`
926
+ : `<div class="card-title-row"><h3>年龄段</h3></div>${emptyNoteHtml()}`;
927
+
928
+ const genderBlock =
929
+ gender.length > 0
930
+ ? `<div class="card-title-row" style="margin-top:18px"><h3>性别</h3></div>
931
+ <div id="chart-audience-gender" class="chart-box sm"></div>
932
+ <div class="table-wrap"><table>
933
+ <thead><tr><th>性别</th><th class="num">消耗</th><th class="num">展示</th><th class="num">点击</th><th class="num">CTR</th><th class="num">平均CPC</th></tr></thead>
934
+ <tbody>${rowsHtml(gender, "gender")}</tbody>
935
+ </table></div>`
936
+ : `<div class="card-title-row" style="margin-top:18px"><h3>性别</h3></div>${emptyNoteHtml()}`;
937
+
938
+ return `
939
+ <section id="sec-audience">
940
+ ${sectionHead("04", "受众分析", "年龄段与性别的展示 / 点击 / 消耗 / CTR / CPC")}
941
+ <div class="card">
942
+ ${ageBlock}
943
+ ${genderBlock}
944
+ ${analysisBlock(section)}
945
+ </div>
946
+ </section>`;
947
+ }
948
+
949
+ function renderCampaigns(data) {
950
+ const rows = Array.isArray(data.tables?.campaigns) ? data.tables.campaigns : [];
951
+ const currency = currencyOf(data);
952
+ const section = data.narrative?.sections?.campaigns;
953
+ const sorted = topBySpend(rows, 10);
954
+ const body = sorted
955
+ .map(
956
+ (r) => `<tr>
957
+ <td>${escapeHtml(r.campaignName || "—")}</td>
958
+ <td>${escapeHtml(r.campaignStatusDisplay || "—")}</td>
959
+ <td class="num">${money(r.spend, currency)}</td>
960
+ <td class="num">${int(r.impressions)}</td>
961
+ <td class="num">${int(r.clicks)}</td>
962
+ <td class="num">${pct(r.ctr)}</td>
963
+ <td class="num">${int(r.conversions)}</td>
964
+ <td class="num">${money(r.costPerConversion, currency)}</td>
965
+ </tr>`,
966
+ )
967
+ .join("");
968
+ const table =
969
+ sorted.length > 0
970
+ ? `<div id="chart-campaigns" class="chart-box" style="height:280px"></div>
971
+ <div class="card-title-row"><h3>系列 Top 10</h3><span class="hint">共 ${rows.length} 个系列,按消耗排序</span></div>
972
+ <div class="table-wrap"><table>
973
+ <thead><tr><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>
974
+ <tbody>${body}</tbody>
975
+ </table></div>`
976
+ : emptyNoteHtml();
977
+ return `
978
+ <section id="sec-campaigns">
979
+ ${sectionHead("05", "广告系列", "按消耗排序的系列表现")}
980
+ <div class="card">
981
+ ${table}
982
+ ${analysisBlock(section)}
983
+ </div>
984
+ </section>`;
985
+ }
986
+
987
+ function renderAdGroups(data) {
988
+ const rows = Array.isArray(data.tables?.adGroups) ? data.tables.adGroups : [];
989
+ const currency = currencyOf(data);
990
+ const section = data.narrative?.sections?.adGroups;
991
+ const sorted = topBySpend(rows, 10);
992
+ const body = sorted
993
+ .map(
994
+ (r) => `<tr>
995
+ <td>${escapeHtml(r.adGroupName || "—")}</td>
996
+ <td>${escapeHtml(r.campaignName || "—")}</td>
997
+ <td class="num">${qs(r.qualityScore)}</td>
998
+ <td class="num">${money(r.spend, currency)}</td>
999
+ <td class="num">${int(r.clicks)}</td>
1000
+ <td class="num">${pct(r.ctr)}</td>
1001
+ <td class="num">${int(r.conversions)}</td>
1002
+ <td class="num">${money(r.costPerConversion, currency)}</td>
1003
+ </tr>`,
1004
+ )
1005
+ .join("");
1006
+ const table =
1007
+ sorted.length > 0
1008
+ ? `<div class="card-title-row"><h3>广告组 Top 10</h3><span class="hint">共 ${rows.length} 个广告组,按消耗排序</span></div>
1009
+ <div class="table-wrap"><table>
1010
+ <thead><tr><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>
1011
+ <tbody>${body}</tbody>
1012
+ </table></div>`
1013
+ : emptyNoteHtml();
1014
+ return `
1015
+ <section id="sec-adgroups">
1016
+ ${sectionHead("06", "广告组", "组级消耗、质量分与转化效率")}
1017
+ <div class="card">
1018
+ ${table}
1019
+ ${analysisBlock(section)}
1020
+ </div>
1021
+ </section>`;
1022
+ }
1023
+
1024
+ function renderAds(data) {
1025
+ const rows = Array.isArray(data.tables?.ads) ? data.tables.ads : [];
1026
+ const currency = currencyOf(data);
1027
+ const section = data.narrative?.sections?.ads;
1028
+ const sorted = topBySpend(rows, 10);
1029
+ const body = sorted
1030
+ .map(
1031
+ (r) => `<tr>
1032
+ <td>${escapeHtml(r.adTitle || "—")}</td>
1033
+ <td>${escapeHtml(r.adGroupName || "—")}</td>
1034
+ <td>${escapeHtml(r.adType || "—")}</td>
1035
+ <td class="num">${money(r.spend, currency)}</td>
1036
+ <td class="num">${int(r.clicks)}</td>
1037
+ <td class="num">${pct(r.ctr)}</td>
1038
+ <td class="num">${int(r.conversions)}</td>
1039
+ </tr>`,
1040
+ )
1041
+ .join("");
1042
+ const table =
1043
+ sorted.length > 0
1044
+ ? `<div class="card-title-row"><h3>广告 Top 10</h3><span class="hint">共 ${rows.length} 条广告,按消耗排序</span></div>
1045
+ <div class="table-wrap"><table>
1046
+ <thead><tr><th>广告标题</th><th>广告组</th><th>广告类型</th><th class="num">消耗</th><th class="num">点击</th><th class="num">CTR</th><th class="num">转化</th></tr></thead>
1047
+ <tbody>${body}</tbody>
1048
+ </table></div>`
1049
+ : emptyNoteHtml();
1050
+ return `
1051
+ <section id="sec-ads">
1052
+ ${sectionHead("07", "广告", "创意级消耗与点击表现")}
1053
+ <div class="card">
1054
+ ${table}
1055
+ ${analysisBlock(section)}
1056
+ </div>
1057
+ </section>`;
1058
+ }
1059
+
1060
+ function renderKeywords(data) {
1061
+ const rows = Array.isArray(data.tables?.keywords) ? data.tables.keywords : [];
1062
+ const currency = currencyOf(data);
1063
+ const section = data.narrative?.sections?.keywords;
1064
+ const sorted = topBySpend(rows, 15);
1065
+ const body = sorted
1066
+ .map(
1067
+ (r) => `<tr>
1068
+ <td>${escapeHtml(r.keyword || "—")}</td>
1069
+ <td>${escapeHtml(r.matchType || "—")}</td>
1070
+ <td class="num">${qs(r.qualityScore)}</td>
1071
+ <td class="num">${money(r.spend, currency)}</td>
1072
+ <td class="num">${pct(r.ctr)}</td>
1073
+ <td class="num">${money(r.averageCpc, currency)}</td>
1074
+ <td class="num">${int(r.conversions)}</td>
1075
+ <td class="num">${money(r.costPerConversion, currency)}</td>
1076
+ </tr>`,
1077
+ )
1078
+ .join("");
1079
+ const table =
1080
+ sorted.length > 0
1081
+ ? `<div class="card-title-row"><h3>关键词 Top 15</h3><span class="hint">共 ${rows.length} 个关键词,按消耗排序</span></div>
1082
+ <div class="table-wrap"><table>
1083
+ <thead><tr><th>关键词</th><th>匹配方式</th><th class="num">质量分</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>
1084
+ <tbody>${body}</tbody>
1085
+ </table></div>`
1086
+ : emptyNoteHtml();
1087
+ return `
1088
+ <section id="sec-keywords">
1089
+ ${sectionHead("08", "关键词", "高耗词、匹配类型与质量分")}
1090
+ <div class="card">
1091
+ ${table}
1092
+ ${analysisBlock(section)}
1093
+ </div>
1094
+ </section>`;
1095
+ }
1096
+
1097
+ function renderSearchTerms(data) {
1098
+ const rows = Array.isArray(data.tables?.searchTerms) ? data.tables.searchTerms : [];
1099
+ const currency = currencyOf(data);
1100
+ const section = data.narrative?.sections?.searchTerms;
1101
+ const sorted = topBySpend(rows, 15);
1102
+ const body = sorted
1103
+ .map(
1104
+ (r) => `<tr>
1105
+ <td>${escapeHtml(r.searchQuery || "—")}</td>
1106
+ <td>${escapeHtml(r.keyword || "—")}</td>
1107
+ <td>${escapeHtml(r.deliveredMatchType || "—")}</td>
1108
+ <td class="num">${money(r.spend, currency)}</td>
1109
+ <td class="num">${pct(r.ctr)}</td>
1110
+ <td class="num">${int(r.conversions)}</td>
1111
+ <td class="num">${money(r.costPerConversion, currency)}</td>
1112
+ </tr>`,
1113
+ )
1114
+ .join("");
1115
+ const table =
1116
+ sorted.length > 0
1117
+ ? `<div class="card-title-row"><h3>搜索字词 Top 15</h3><span class="hint">共 ${rows.length} 条搜索字词,按消耗排序</span></div>
1118
+ <div class="table-wrap"><table>
1119
+ <thead><tr><th>搜索字词</th><th>触发关键词</th><th>投放匹配</th><th class="num">消耗</th><th class="num">CTR</th><th class="num">转化</th><th class="num">CPA</th></tr></thead>
1120
+ <tbody>${body}</tbody>
1121
+ </table></div>`
1122
+ : emptyNoteHtml();
1123
+ return `
1124
+ <section id="sec-search-terms">
1125
+ ${sectionHead("09", "搜索字词", "检索意图与否词机会")}
1126
+ <div class="card">
1127
+ ${table}
1128
+ ${analysisBlock(section)}
1129
+ </div>
1130
+ </section>`;
1131
+ }
1132
+
1133
+ function renderRecommendations(data) {
1134
+ const recs = Array.isArray(data.narrative?.recommendations)
1135
+ ? data.narrative.recommendations
1136
+ : [];
1137
+ const cards = recs
1138
+ .map((r, i) => {
1139
+ const num = `<span class="rec-num">${i + 1}</span>`;
1140
+ if (typeof r === "string") {
1141
+ return `<div class="rec-card">${num}<div class="rec-body"><div class="rec-content">${escapeHtml(r)}</div></div></div>`;
1142
+ }
1143
+ const title = r?.title ? `<div class="rec-title">${escapeHtml(r.title)}</div>` : "";
1144
+ return `<div class="rec-card">${num}<div class="rec-body">${title}<div class="rec-content">${escapeHtml(r?.content || "")}</div></div></div>`;
1145
+ })
1146
+ .join("");
1147
+ const body = recs.length > 0 ? `<div class="recommendation-list">${cards}</div>` : emptyNoteHtml();
1148
+ return `
1149
+ <section id="sec-recommendations">
1150
+ ${sectionHead("10", "优化建议汇总", "跨维度归纳优先级,须与各 section 分析一致")}
1151
+ <div class="card">${body}</div>
1152
+ </section>`;
1153
+ }
1154
+
1155
+ function renderFooter(data) {
1156
+ const generatedAt = data.meta?.generatedAt || new Date().toISOString();
1157
+ return `<div class="footer"><span class="brand">siluzan-tso</span> · Bing 广告账户分析报告 · ${escapeHtml(generatedAt)} · 数据来源 bing-analysis</div>`;
1158
+ }
1159
+
1160
+ function styleAxis(extra) {
1161
+ return Object.assign(
1162
+ {
1163
+ axisLine: { lineStyle: { color: "rgba(15,23,42,0.14)" } },
1164
+ axisTick: { show: false },
1165
+ axisLabel: { color: AXIS_COLOR },
1166
+ splitLine: { lineStyle: { color: SPLIT_LINE_COLOR, type: "dashed" } },
1167
+ },
1168
+ extra,
1169
+ );
1170
+ }
1171
+
1172
+ function pieOption(items, nameKey, currency) {
1173
+ return {
1174
+ tooltip: Object.assign(
1175
+ { trigger: "item", formatter: (p) => `${p.name}: ${money(p.value, currency)}` },
1176
+ TOOLTIP_STYLE,
1177
+ ),
1178
+ legend: { bottom: 0, textStyle: { color: AXIS_COLOR } },
1179
+ series: [
1180
+ {
1181
+ type: "pie",
1182
+ radius: ["42%", "70%"],
1183
+ itemStyle: { borderColor: "#ffffff", borderWidth: 3, borderRadius: 8 },
1184
+ data: items.map((d, i) => ({
1185
+ name: d[nameKey] || "—",
1186
+ value: Number(d.spend) || 0,
1187
+ itemStyle: { color: PALETTE[i % PALETTE.length] },
1188
+ })),
1189
+ label: { color: "#0f172a", fontSize: 12 },
1190
+ labelLine: { lineStyle: { color: "rgba(15,23,42,0.25)" } },
1191
+ },
1192
+ ],
1193
+ };
1194
+ }
1195
+
1196
+ function hBarOption(items, nameKey, currency, colorFrom, colorTo) {
1197
+ const top = [...items]
1198
+ .sort((a, b) => (Number(b.spend) || 0) - (Number(a.spend) || 0))
1199
+ .slice(0, 10)
1200
+ .reverse();
1201
+ return {
1202
+ tooltip: Object.assign(
1203
+ { trigger: "axis", formatter: (p) => money(p[0].value, currency) },
1204
+ TOOLTIP_STYLE,
1205
+ ),
1206
+ grid: { left: 140, right: 30, top: 16, bottom: 20 },
1207
+ xAxis: styleAxis({ type: "value" }),
1208
+ yAxis: styleAxis({
1209
+ type: "category",
1210
+ data: top.map((c) => c[nameKey] || "—"),
1211
+ axisLabel: { color: AXIS_COLOR, width: 120, overflow: "truncate" },
1212
+ splitLine: { show: false },
1213
+ }),
1214
+ series: [
1215
+ {
1216
+ type: "bar",
1217
+ data: top.map((c) => Number(c.spend) || 0),
1218
+ barMaxWidth: 18,
1219
+ itemStyle: {
1220
+ borderRadius: [0, 6, 6, 0],
1221
+ color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
1222
+ { offset: 0, color: colorFrom },
1223
+ { offset: 1, color: colorTo },
1224
+ ]),
1225
+ },
1226
+ },
1227
+ ],
1228
+ };
1229
+ }
1230
+
1231
+ function renderCharts(data) {
1232
+ if (typeof echarts === "undefined") return;
1233
+ const currency = currencyOf(data);
1234
+
1235
+ const devices = Array.isArray(data.tables?.devices) ? data.tables.devices : [];
1236
+ const devicesPieEl = document.getElementById("chart-devices-pie");
1237
+ const devicesBarEl = document.getElementById("chart-devices-bar");
1238
+ if (devicesPieEl && devices.length > 0) {
1239
+ echarts.init(devicesPieEl).setOption(pieOption(devices, "deviceType", currency));
1240
+ }
1241
+ if (devicesBarEl && devices.length > 0) {
1242
+ echarts
1243
+ .init(devicesBarEl)
1244
+ .setOption(
1245
+ hBarOption(devices, "deviceType", currency, "rgba(6,182,212,0.55)", PALETTE[2]),
1246
+ );
1247
+ }
1248
+
1249
+ const geoEl = document.getElementById("chart-geographic");
1250
+ const geo = Array.isArray(data.tables?.geographic) ? data.tables.geographic : [];
1251
+ if (geoEl && geo.length > 0) {
1252
+ echarts
1253
+ .init(geoEl)
1254
+ .setOption(
1255
+ hBarOption(geo, "countryOrRegion", currency, "rgba(14,165,233,0.55)", PALETTE[0]),
1256
+ );
1257
+ }
1258
+
1259
+ const ageEl = document.getElementById("chart-audience-age");
1260
+ const age = Array.isArray(data.tables?.audienceAge) ? data.tables.audienceAge : [];
1261
+ if (ageEl && age.length > 0) {
1262
+ echarts.init(ageEl).setOption(pieOption(age, "ageRange", currency));
1263
+ }
1264
+
1265
+ const genderEl = document.getElementById("chart-audience-gender");
1266
+ const gender = Array.isArray(data.tables?.audienceGender)
1267
+ ? data.tables.audienceGender
1268
+ : [];
1269
+ if (genderEl && gender.length > 0) {
1270
+ echarts.init(genderEl).setOption(pieOption(gender, "gender", currency));
1271
+ }
1272
+
1273
+ const campaignsEl = document.getElementById("chart-campaigns");
1274
+ const campaigns = Array.isArray(data.tables?.campaigns) ? data.tables.campaigns : [];
1275
+ if (campaignsEl && campaigns.length > 0) {
1276
+ echarts
1277
+ .init(campaignsEl)
1278
+ .setOption(
1279
+ hBarOption(campaigns, "campaignName", currency, "rgba(99,102,241,0.55)", PALETTE[1]),
1280
+ );
1281
+ }
1282
+
1283
+ window.addEventListener("resize", () => {
1284
+ document
1285
+ .querySelectorAll(".chart-box")
1286
+ .forEach((el) => echarts.getInstanceByDom(el)?.resize());
1287
+ });
1288
+ }
1289
+
1290
+ function setupScrollSpy() {
1291
+ const links = Array.from(document.querySelectorAll(".toc a"));
1292
+ if (links.length === 0 || typeof IntersectionObserver === "undefined") return;
1293
+ const targets = links
1294
+ .map((a) => document.querySelector(a.getAttribute("href")))
1295
+ .filter(Boolean);
1296
+ const setActive = (id) => {
1297
+ links.forEach((a) => a.classList.toggle("active", a.getAttribute("href") === `#${id}`));
1298
+ };
1299
+ const observer = new IntersectionObserver(
1300
+ (entries) => {
1301
+ const visible = entries.filter((e) => e.isIntersecting);
1302
+ if (visible.length > 0) setActive(visible[0].target.id);
1303
+ },
1304
+ { rootMargin: "-20% 0px -70% 0px" },
1305
+ );
1306
+ targets.forEach((el) => observer.observe(el));
1307
+ }
1308
+
1309
+ function render() {
1310
+ const data = window.__BING_PERIOD_REPORT__;
1311
+ const root = document.getElementById("report-root");
1312
+ if (!data) {
1313
+ root.innerHTML =
1314
+ '<div class="card">未找到报告数据(window.__BING_PERIOD_REPORT__ 为空),请通过 `bing-analysis render` 生成本文件。</div>';
1315
+ return;
1316
+ }
1317
+ root.innerHTML = [
1318
+ renderHero(data),
1319
+ renderExecutiveSummary(data),
1320
+ renderDevices(data),
1321
+ renderGeographic(data),
1322
+ renderAudience(data),
1323
+ renderCampaigns(data),
1324
+ renderAdGroups(data),
1325
+ renderAds(data),
1326
+ renderKeywords(data),
1327
+ renderSearchTerms(data),
1328
+ renderRecommendations(data),
1329
+ renderFooter(data),
1330
+ ].join("\n");
1331
+ renderCharts(data);
1332
+ setupScrollSpy();
1333
+ }
1334
+
1335
+ if (document.readyState === "loading") {
1336
+ document.addEventListener("DOMContentLoaded", render);
1337
+ } else {
1338
+ render();
1339
+ }
1340
+ })();
1341
+ </script>
1342
+ </body>
1343
+ </html>