@petercjl/taobao-ai-ops 0.1.0 → 0.1.1

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 (76) hide show
  1. package/README.md +29 -22
  2. package/package.json +2 -7
  3. package/skills/taobao-ai-operations/SKILL.md +5 -1
  4. package/src/cli.mjs +16 -6
  5. package/src/components.mjs +73 -0
  6. package/src/core.mjs +5 -1
  7. package/src/runtime.mjs +11 -15
  8. package/src/skill-manager.mjs +4 -4
  9. package/src/update.mjs +5 -5
  10. package/src/workflow.mjs +3 -3
  11. package/suite-manifest.json +163 -10
  12. package/skills/taobao-search-product-form/SKILL.md +0 -74
  13. package/skills/taobao-search-product-form/adapters/codex.json +0 -139
  14. package/skills/taobao-search-product-form/adapters/sealseek.json +0 -24
  15. package/skills/taobao-search-product-form/agents/openai.yaml +0 -3
  16. package/skills/taobao-search-product-form/capabilities.json +0 -148
  17. package/skills/taobao-search-product-form/references/SCHEMA.md +0 -3
  18. package/skills/taobao-search-product-form/references/index.md +0 -10
  19. package/skills/taobao-search-product-form/references/log.md +0 -35
  20. package/skills/taobao-search-product-form/references/queries/evaluate-white-label-forms.md +0 -3
  21. package/skills/taobao-search-product-form/references/queries/identify-advertisers.md +0 -5
  22. package/skills/taobao-search-product-form/references/queries/identify-geography.md +0 -5
  23. package/skills/taobao-search-product-form/references/queries/identify-price-bands.md +0 -3
  24. package/skills/taobao-search-product-form/references/queries/identify-product-forms.md +0 -3
  25. package/skills/taobao-search-product-form/references/queries/identify-title-roots.md +0 -5
  26. package/skills/taobao-search-product-form/references/report-output.md +0 -23
  27. package/skills/taobao-search-product-form/references/source-manifest.md +0 -37
  28. package/skills/taobao-search-product-form/references/title-lexicon.json +0 -55
  29. package/skills/taobao-search-product-form/references/topics/advertiser-analysis-method.md +0 -43
  30. package/skills/taobao-search-product-form/references/topics/geography-analysis-method.md +0 -23
  31. package/skills/taobao-search-product-form/references/topics/price-band-analysis-method.md +0 -27
  32. package/skills/taobao-search-product-form/references/topics/title-root-analysis-method.md +0 -37
  33. package/skills/taobao-search-product-form/references/topics/visual-form-method.md +0 -31
  34. package/skills/taobao-search-product-form/references/topics/white-label-evidence.md +0 -38
  35. package/skills/taobao-search-product-form/references/workflow-ledger.md +0 -28
  36. package/skills/taobao-search-product-form/report_runtime/VERSION +0 -1
  37. package/skills/taobao-search-product-form/report_runtime/renderer.py +0 -485
  38. package/skills/taobao-search-product-form/report_runtime/templates/workbench.html +0 -67
  39. package/skills/taobao-search-product-form/report_runtime/validator.py +0 -117
  40. package/skills/taobao-search-product-form/scripts/analyze.py +0 -119
  41. package/skills/taobao-search-product-form/scripts/analyze_advertisers.py +0 -352
  42. package/skills/taobao-search-product-form/scripts/analyze_geography.py +0 -316
  43. package/skills/taobao-search-product-form/scripts/analyze_price_bands.py +0 -257
  44. package/skills/taobao-search-product-form/scripts/analyze_title_roots.py +0 -327
  45. package/skills/taobao-search-product-form/scripts/assemble_opportunity_evidence.py +0 -322
  46. package/skills/taobao-search-product-form/scripts/build_report_viewmodel.py +0 -822
  47. package/skills/taobao-search-product-form/scripts/build_visual_review_viewmodel.py +0 -177
  48. package/skills/taobao-search-product-form/scripts/clean_search_export.mjs +0 -61
  49. package/skills/taobao-search-product-form/scripts/cluster_visual_forms.py +0 -193
  50. package/skills/taobao-search-product-form/scripts/compile_style_prototypes.py +0 -115
  51. package/skills/taobao-search-product-form/scripts/compile_visual_review.py +0 -60
  52. package/skills/taobao-search-product-form/scripts/compile_visual_selection.py +0 -148
  53. package/skills/taobao-search-product-form/scripts/prepare.py +0 -180
  54. package/skills/taobao-search-product-form/scripts/render_report.py +0 -31
  55. package/skills/taobao-search-product-form/scripts/validate_opportunity_cards.py +0 -84
  56. package/skills/taobao-search-product-form/scripts/validate_report_viewmodel.py +0 -152
  57. package/skills/taobao-search-product-form/scripts/visual_form_pipeline.py +0 -247
  58. package/skills/taobao-search-product-form/scripts/visual_observation_batches.py +0 -117
  59. package/skills/taobao-search-product-form/scripts/workflow_ledger.py +0 -177
  60. package/skills/yuce-category-opportunity-report/SKILL.md +0 -151
  61. package/skills/yuce-category-opportunity-report/adapters/codex.json +0 -113
  62. package/skills/yuce-category-opportunity-report/adapters/sealseek.json +0 -39
  63. package/skills/yuce-category-opportunity-report/agents/openai.yaml +0 -3
  64. package/skills/yuce-category-opportunity-report/assets/chart-views.js +0 -80
  65. package/skills/yuce-category-opportunity-report/assets/package.json +0 -1
  66. package/skills/yuce-category-opportunity-report/assets/report-template.html +0 -62
  67. package/skills/yuce-category-opportunity-report/capabilities.json +0 -48
  68. package/skills/yuce-category-opportunity-report/references/SCHEMA.md +0 -5
  69. package/skills/yuce-category-opportunity-report/references/data-contract.md +0 -40
  70. package/skills/yuce-category-opportunity-report/references/index.md +0 -6
  71. package/skills/yuce-category-opportunity-report/references/log.md +0 -29
  72. package/skills/yuce-category-opportunity-report/references/queries/assess-category-opportunity.md +0 -7
  73. package/skills/yuce-category-opportunity-report/references/source-manifest.md +0 -14
  74. package/skills/yuce-category-opportunity-report/references/topics/category-opportunity-method.md +0 -39
  75. package/skills/yuce-category-opportunity-report/scripts/build_report.py +0 -779
  76. package/skills/yuce-category-opportunity-report/scripts/market_routes.py +0 -139
@@ -1,485 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Versioned Compact Commerce UI ViewModel renderer."""
3
-
4
- from __future__ import annotations
5
-
6
- import base64
7
- import hashlib
8
- import html
9
- import json
10
- import pathlib
11
- import re
12
- from typing import Any
13
-
14
-
15
- ROOT = pathlib.Path(__file__).resolve().parent
16
- CONTRACT = "compact-workbench@1.0"
17
- CLI_VERSION = (ROOT / "VERSION").read_text(encoding="utf-8").strip()
18
-
19
-
20
- def esc(value: Any) -> str:
21
- return html.escape(str(value if value is not None else ""), quote=True)
22
-
23
-
24
- def slug(value: Any) -> str:
25
- candidate = re.sub(r"[^a-zA-Z0-9_-]+", "-", str(value or "")).strip("-")
26
- if not candidate:
27
- raise ValueError(f"Invalid empty identifier: {value!r}")
28
- return candidate
29
-
30
-
31
- def mime_for(blob: bytes) -> str:
32
- if blob.startswith(b"\xff\xd8\xff"):
33
- return "image/jpeg"
34
- if blob.startswith(b"\x89PNG\r\n\x1a\n"):
35
- return "image/png"
36
- if blob.startswith((b"GIF87a", b"GIF89a")):
37
- return "image/gif"
38
- if blob.startswith(b"RIFF") and blob[8:12] == b"WEBP":
39
- return "image/webp"
40
- if blob.startswith(b"<svg") or b"<svg" in blob[:256]:
41
- return "image/svg+xml"
42
- raise ValueError("Unsupported image format; use JPEG, PNG, GIF, WebP, or SVG")
43
-
44
-
45
- def normalize_assets(raw: Any, input_dir: pathlib.Path) -> dict[str, dict[str, str]]:
46
- if raw is None:
47
- return {}
48
- if isinstance(raw, dict):
49
- items = [dict(value, id=key) for key, value in raw.items()]
50
- elif isinstance(raw, list):
51
- items = raw
52
- else:
53
- raise ValueError("assets must be an object or array")
54
- result: dict[str, dict[str, str]] = {}
55
- for item in items:
56
- if not isinstance(item, dict) or not item.get("id") or not item.get("path"):
57
- raise ValueError("each asset requires id and path")
58
- asset_id = str(item["id"])
59
- if asset_id in result:
60
- raise ValueError(f"duplicate asset id: {asset_id}")
61
- value = str(item["path"])
62
- if re.match(r"https?://", value, re.I):
63
- raise ValueError(f"remote rendering asset is forbidden: {asset_id}")
64
- path = pathlib.Path(value).expanduser()
65
- if not path.is_absolute():
66
- path = input_dir / path
67
- path = path.resolve()
68
- if not path.is_file():
69
- raise ValueError(f"asset not found: {asset_id} -> {path}")
70
- blob = path.read_bytes()
71
- mime = mime_for(blob)
72
- result[asset_id] = {
73
- "src": f"data:{mime};base64,{base64.b64encode(blob).decode('ascii')}",
74
- "alt": str(item.get("alt", "")),
75
- "role": str(item.get("role", "content")),
76
- "sha256": hashlib.sha256(blob).hexdigest(),
77
- }
78
- return result
79
-
80
-
81
- class Renderer:
82
- def __init__(self, document: dict[str, Any], assets: dict[str, dict[str, str]]) -> None:
83
- self.document = document
84
- self.assets = assets
85
- self.external_images = str((document.get("meta") or {}).get("external_images", "forbid"))
86
- self.tab_counter = 0
87
-
88
- def render_blocks(self, blocks: Any) -> str:
89
- if blocks is None:
90
- return ""
91
- if not isinstance(blocks, list):
92
- raise ValueError("blocks must be an array")
93
- return "".join(self.render_block(block) for block in blocks)
94
-
95
- def render_block(self, block: Any) -> str:
96
- if not isinstance(block, dict):
97
- raise ValueError("every block must be an object")
98
- kind = block.get("type")
99
- if kind == "conclusion":
100
- return self.render_conclusion(block)
101
- if kind == "alert":
102
- tone = slug(block.get("tone", "warning"))
103
- title = f"<strong>{esc(block.get('title'))}</strong> " if block.get("title") else ""
104
- return f'<section class="alert tone-{tone} searchable">{title}<span>{esc(block.get("text", ""))}</span></section>'
105
- if kind == "metrics":
106
- columns = max(1, min(8, int(block.get("columns", 4))))
107
- variant = slug(block.get("variant", "default"))
108
- items = "".join(self.render_metric(item, variant) for item in block.get("items", []))
109
- return f'<section class="metrics metrics-{columns} variant-{variant}">{items}</section>'
110
- if kind == "grid":
111
- columns = max(1, min(4, int(block.get("columns", 2))))
112
- children = "".join(self.render_block(item) for item in block.get("items", []))
113
- return f'<section class="module-grid grid-{columns}">{children}</section>'
114
- if kind == "card":
115
- title = esc(block.get("title", ""))
116
- meta = esc(block.get("meta", ""))
117
- head = f'<div class="card-head"><h2>{title}</h2><span>{meta}</span></div>' if title or meta else ""
118
- body = self.render_blocks(block.get("blocks", []))
119
- extra = slug(block.get("variant", "default"))
120
- return f'<article class="card card-{extra} searchable">{head}<div class="card-body">{body}</div></article>'
121
- if kind == "list":
122
- ordered = bool(block.get("ordered"))
123
- tag = "ol" if ordered else "ul"
124
- items = "".join(f'<li class="searchable">{esc(item)}</li>' for item in block.get("items", []))
125
- return f'<{tag} class="content-list">{items}</{tag}>'
126
- if kind == "callout":
127
- return f'<div class="callout searchable">{esc(block.get("text", ""))}</div>'
128
- if kind == "text":
129
- values = block.get("paragraphs")
130
- if values is None:
131
- values = [block.get("text", "")]
132
- return '<div class="prose">' + "".join(f'<p class="searchable">{esc(value)}</p>' for value in values) + "</div>"
133
- if kind == "table":
134
- return self.render_table(block)
135
- if kind == "tabs":
136
- return self.render_tabs(block)
137
- if kind == "products":
138
- return self.render_products(block)
139
- if kind == "stats":
140
- items = "".join(f'<div class="mini-stat searchable"><span>{esc(x.get("label", ""))}</span><b>{esc(x.get("value", "—"))}</b></div>' for x in block.get("items", []))
141
- return f'<div class="mini-stats">{items}</div>'
142
- if kind == "badge":
143
- tone = slug(block.get("tone", "info"))
144
- return f'<span class="tag tone-{tone}">{esc(block.get("text", ""))}</span>'
145
- if kind == "button":
146
- label = esc(block.get("label", "打开"))
147
- if block.get("target_view"):
148
- return f'<button class="text-button" type="button" data-open-view="{esc(slug(block["target_view"]))}">{label}</button>'
149
- if block.get("url"):
150
- return f'<button class="text-button" type="button" data-url="{esc(block["url"])}">{label}</button>'
151
- raise ValueError("button requires target_view or url")
152
- if kind == "actions":
153
- rows = []
154
- for index, item in enumerate(block.get("items", []), 1):
155
- rows.append(f'<div class="action-row searchable"><span class="action-rank">{index}</span><div><b>{esc(item.get("title", ""))}</b><p>{esc(item.get("text", ""))}</p></div><span class="tag tone-{esc(slug(item.get("tone", "info")))}">{esc(item.get("label", ""))}</span></div>')
156
- return '<div class="action-list">' + "".join(rows) + "</div>"
157
- if kind == "empty":
158
- return f'<div class="empty">{esc(block.get("text", "暂无数据"))}</div>'
159
- raise ValueError(f"unsupported block type: {kind!r}")
160
-
161
- def render_conclusion(self, block: dict[str, Any]) -> str:
162
- items = block.get("items")
163
- if not isinstance(items, list) or len(items) < 2:
164
- raise ValueError("conclusion requires at least two structured items")
165
- valid_tones = {"info", "positive", "warning", "danger"}
166
- valid_emphasis = {"normal", "strong", "italic", "strong-italic"}
167
- cards = []
168
- for item in items:
169
- if not isinstance(item, dict) or not isinstance(item.get("label"), str) or not item["label"].strip():
170
- raise ValueError("each conclusion item requires a nonempty label")
171
- segments = item.get("segments")
172
- if not isinstance(segments, list) or not segments:
173
- raise ValueError("each conclusion item requires text segments")
174
- tone = item.get("tone", "info")
175
- if tone not in valid_tones:
176
- raise ValueError(f"unsupported conclusion tone: {tone!r}")
177
- rendered_segments = []
178
- for segment in segments:
179
- if not isinstance(segment, dict) or not isinstance(segment.get("text"), str) or not segment["text"]:
180
- raise ValueError("each conclusion segment requires nonempty text")
181
- emphasis = segment.get("emphasis", "normal")
182
- segment_tone = segment.get("tone")
183
- if emphasis not in valid_emphasis or (segment_tone is not None and segment_tone not in valid_tones):
184
- raise ValueError("unsupported conclusion emphasis or tone")
185
- classes = ["conclusion-segment", f"emphasis-{emphasis}"]
186
- if segment_tone is not None:
187
- classes.append(f"conclusion-color-{segment_tone}")
188
- rendered_segments.append(f'<span class="{" ".join(classes)}">{esc(segment["text"])}</span>')
189
- note = f'<div class="conclusion-note">{esc(item["note"])}</div>' if item.get("note") else ""
190
- cards.append(
191
- f'<article class="conclusion-item conclusion-tone-{tone} searchable">'
192
- f'<h3>{esc(item["label"])}</h3>'
193
- f'<p class="conclusion-copy">{"".join(rendered_segments)}</p>{note}</article>'
194
- )
195
- title = esc(block.get("title") or "核心结论")
196
- return f'<section class="card conclusion-card"><div class="card-head"><h2>{title}</h2></div><div class="conclusion-grid">{"".join(cards)}</div></section>'
197
-
198
- def render_metric(self, item: dict[str, Any], variant: str) -> str:
199
- tone = slug(item.get("tone", "info"))
200
- badge = f'<span class="metric-badge tone-{tone}">{esc(item.get("badge"))}</span>' if item.get("badge") else ""
201
- return f'<article class="card metric metric-{variant} searchable"><div class="metric-label"><span>{esc(item.get("label", ""))}</span>{badge}</div><div class="metric-value">{esc(item.get("value", "—"))}</div><div class="metric-note">{esc(item.get("note", ""))}</div></article>'
202
-
203
- def render_table(self, block: dict[str, Any]) -> str:
204
- columns = block.get("columns", [])
205
- if not columns:
206
- raise ValueError("table requires columns")
207
- labels = [column.get("label", column.get("key", "")) if isinstance(column, dict) else column for column in columns]
208
- head = "".join(f"<th>{esc(label)}</th>" for label in labels)
209
- rows = []
210
- for row in block.get("rows", []):
211
- if isinstance(row, dict):
212
- values = [row.get(column.get("key")) if isinstance(column, dict) else row.get(str(column)) for column in columns]
213
- else:
214
- values = row
215
- cells = "".join(f"<td>{self.render_cell(value)}</td>" for value in values)
216
- rows.append(f'<tr class="searchable">{cells}</tr>')
217
- title = esc(block.get("title", ""))
218
- meta = esc(block.get("meta", ""))
219
- headbar = f'<div class="card-head"><h2>{title}</h2><span>{meta}</span></div>' if title or meta else ""
220
- return f'<article class="card table-card">{headbar}<div class="table-wrap"><table class="data-table"><thead><tr>{head}</tr></thead><tbody>{"".join(rows)}</tbody></table></div></article>'
221
-
222
- def render_cell(self, value: Any) -> str:
223
- if not isinstance(value, dict):
224
- return esc(value if value is not None else "—")
225
- text = esc(value.get("text", "—"))
226
- image_url = str(value.get("image_url", "")).strip()
227
- if image_url:
228
- if self.external_images != "allow_https":
229
- raise ValueError("table cell image_url requires meta.external_images=allow_https")
230
- if not re.fullmatch(r'''https://[^\s"'<>]+''', image_url):
231
- raise ValueError("table cell image_url must be an HTTPS URL")
232
- text = (
233
- '<div class="cell-product">'
234
- f'<div class="cell-thumb"><img src="{esc(image_url)}" alt="{esc(value.get("alt") or value.get("text", ""))}" '
235
- 'loading="lazy" referrerpolicy="no-referrer" data-remote-image>'
236
- '<div class="image-empty" hidden>加载失败</div></div>'
237
- f'<span>{text}</span></div>'
238
- )
239
- if value.get("badge"):
240
- text += f' <span class="tag tone-{esc(slug(value.get("tone", "info")))}">{esc(value["badge"])}</span>'
241
- actions = []
242
- if value.get("url"):
243
- actions.append(f'<button class="text-button compact" type="button" data-url="{esc(value["url"])}">{esc(value.get("action_label", "打开"))}</button>')
244
- if value.get("copy_value"):
245
- actions.append(f'<button class="text-button compact copy-button" type="button" data-copy="{esc(value["copy_value"])}">{esc(value.get("copy_label", "复制"))}</button>')
246
- if actions:
247
- text += f'<div class="inline-actions">{"".join(actions)}</div>'
248
- return text
249
-
250
- def render_tabs(self, block: dict[str, Any]) -> str:
251
- self.tab_counter += 1
252
- group = f"tabs-{self.tab_counter}"
253
- tabs = block.get("tabs", [])
254
- if not tabs:
255
- return self.render_block({"type": "empty", "text": block.get("empty_text", "暂无分类")})
256
- buttons = []
257
- panels = []
258
- for index, tab in enumerate(tabs):
259
- tab_id = slug(tab.get("id", f"tab-{index+1}"))
260
- suffix = f" · {esc(tab.get('badge'))}" if tab.get("badge") not in (None, "") else ""
261
- buttons.append(f'<button type="button" data-tab-target="{tab_id}" class="{"active" if index == 0 else ""}">{esc(tab.get("label", tab_id))}{suffix}</button>')
262
- hidden = "" if index == 0 else " hidden"
263
- panels.append(f'<div class="tab-panel" data-tab="{tab_id}"{hidden}>{self.render_blocks(tab.get("blocks", []))}</div>')
264
- title = esc(block.get("title", ""))
265
- meta = esc(block.get("meta", ""))
266
- head = f'<div class="card-head"><h2>{title}</h2><span>{meta}</span></div>' if title or meta else ""
267
- return f'<section class="card tabs-card" data-tabs="{group}">{head}<div class="tab-buttons">{"".join(buttons)}</div>{"".join(panels)}</section>'
268
-
269
- def render_products(self, block: dict[str, Any]) -> str:
270
- columns = max(1, min(4, int(block.get("columns", 3))))
271
- items = block.get("items", [])
272
- if not items:
273
- return '<div class="empty">暂无候选商品</div>'
274
- page_size = block.get("page_size")
275
- if page_size is None:
276
- products = "".join(self.render_product(item) for item in items)
277
- return f'<div class="product-grid product-grid-{columns}">{products}</div>'
278
- page_size = max(1, int(page_size))
279
- options = block.get("sort_options", [])
280
- option_html = "".join(
281
- f'<option value="{esc(option["key"])}" data-direction="{esc(option.get("direction", "desc"))}">{esc(option["label"])}</option>'
282
- for option in options
283
- )
284
- cards = "".join(
285
- f'<div class="gallery-entry" data-source-order="{index}" '
286
- f'data-sort-values="{esc(json.dumps(item.get("sort_values", {}), ensure_ascii=False, separators=(",", ":")))}">'
287
- f'{self.render_product(item)}</div>'
288
- for index, item in enumerate(items)
289
- )
290
- controls = (
291
- '<div class="gallery-controls">'
292
- f'<label>排序<select data-gallery-sort><option value="source">原始顺序</option>{option_html}</select></label>'
293
- f'<span data-gallery-count>{len(items)} 件</span>'
294
- '<div class="gallery-pager"><button type="button" data-page-prev>上一页</button>'
295
- '<span data-page-status></span><button type="button" data-page-next>下一页</button></div>'
296
- '</div>'
297
- )
298
- bottom = ('<div class="gallery-controls gallery-controls-bottom"><span data-gallery-count-bottom></span>'
299
- '<div class="gallery-pager"><button type="button" data-page-prev-bottom>上一页</button>'
300
- '<span data-page-status-bottom></span><button type="button" data-page-next-bottom>下一页</button>'
301
- '</div></div>')
302
- return (f'<section class="product-gallery" data-product-gallery data-page-size="{page_size}">'
303
- f'{controls}<div class="product-grid product-grid-{columns}" data-gallery-grid>{cards}</div>'
304
- f'{bottom}'
305
- '</section>')
306
-
307
- def render_product(self, item: dict[str, Any]) -> str:
308
- asset_id = str(item.get("asset_id", ""))
309
- asset = self.assets.get(asset_id)
310
- image_url = str(item.get("image_url", "")).strip()
311
- item_url = str(item.get("item_url", "")).strip()
312
- if item_url and not re.fullmatch(r'''https://[^\s"'<>]+''', item_url):
313
- raise ValueError("product item_url must be an HTTPS URL")
314
- if asset:
315
- image = (
316
- f'<div class="product-media"><img src="{asset["src"]}" '
317
- f'alt="{esc(item.get("alt") or asset.get("alt", ""))}"></div>'
318
- )
319
- elif image_url:
320
- if self.external_images != "allow_https":
321
- raise ValueError("product image_url requires meta.external_images=allow_https")
322
- if not re.fullmatch(r'''https://[^\s"'<>]+''', image_url):
323
- raise ValueError("product image_url must be an HTTPS URL")
324
- image = (
325
- f'<div class="product-media"><img src="{esc(image_url)}" alt="{esc(item.get("alt") or item.get("title", ""))}" '
326
- 'loading="lazy" referrerpolicy="no-referrer" data-remote-image>'
327
- '<div class="image-empty" hidden>图片加载失败</div></div>'
328
- )
329
- else:
330
- image = '<div class="product-media"><div class="image-empty">图片未提供</div></div>'
331
- if item_url:
332
- image = f'<a class="product-media-link" href="{esc(item_url)}" target="_blank" rel="noopener noreferrer" aria-label="打开商品:{esc(item.get("title", ""))}">{image}</a>'
333
- title = esc(item.get("title", ""))
334
- if item_url:
335
- title = f'<a class="product-title-link" href="{esc(item_url)}" target="_blank" rel="noopener noreferrer">{title}</a>'
336
- tone = slug(item.get("tone", "positive"))
337
- facts = "".join(f'<div class="fact"><span>{esc(fact.get("label", ""))}</span><b>{esc(fact.get("value", "—"))}</b></div>' for fact in item.get("facts", []))
338
- evidence = f'<div class="evidence">{esc(item.get("evidence"))}</div>' if item.get("evidence") else ""
339
- actions = []
340
- if item.get("url"):
341
- actions.append(f'<button class="open-button" type="button" data-url="{esc(item["url"])}">{esc(item.get("action_label", "打开详情"))}</button>')
342
- if item.get("copy_value"):
343
- actions.append(f'<button class="open-button copy-button" type="button" data-copy="{esc(item["copy_value"])}">{esc(item.get("copy_label", "复制链接"))}</button>')
344
- if item_url:
345
- actions.append(f'<button class="open-button copy-button" type="button" data-copy="{esc(item_url)}">复制链接</button>')
346
- action = f'<div class="product-actions">{"".join(actions)}</div>' if actions else ""
347
- return f'<article class="card product-card searchable">{image}<div class="product-body"><div class="product-title">{title}</div><div class="product-meta"><span class="price">{esc(item.get("price", "—"))}</span><span class="tag tone-{tone}">{esc(item.get("badge", ""))}</span></div><div class="facts">{facts}</div>{evidence}{action}</div></article>'
348
-
349
-
350
- def validate_viewmodel(document: Any) -> dict[str, Any]:
351
- if not isinstance(document, dict):
352
- raise ValueError("ViewModel root must be an object")
353
- if document.get("contract") != CONTRACT:
354
- raise ValueError(f"contract must be {CONTRACT}")
355
- meta = document.get("meta")
356
- if not isinstance(meta, dict) or not meta.get("title"):
357
- raise ValueError("meta.title is required")
358
- views = document.get("views")
359
- if not isinstance(views, list) or len(views) < 2:
360
- raise ValueError("views must contain at least two top-level views")
361
- ids = [slug(view.get("id")) for view in views if isinstance(view, dict)]
362
- if len(ids) != len(views):
363
- raise ValueError("every view must be an object with an id")
364
- if len(ids) != len(set(ids)):
365
- raise ValueError("view ids must be unique")
366
- reading_path = (document.get("shell") or {}).get("reading_path", [])
367
- for item in reading_path:
368
- if slug(item.get("view_id")) not in ids:
369
- raise ValueError(f"reading path references unknown view: {item.get('view_id')}")
370
- for view in views:
371
- if not view.get("label") or not view.get("title"):
372
- raise ValueError(f"view {view.get('id')} requires label and title")
373
- if not isinstance(view.get("blocks", []), list):
374
- raise ValueError(f"view {view.get('id')} blocks must be an array")
375
- return document
376
-
377
-
378
- def render_file(input_value: pathlib.Path | str, output_value: pathlib.Path | str) -> dict[str, Any]:
379
- input_path = pathlib.Path(input_value).expanduser().resolve()
380
- output_path = pathlib.Path(output_value).expanduser().resolve()
381
- if not input_path.is_file():
382
- raise ValueError(f"input not found: {input_path}")
383
- if output_path.exists():
384
- raise ValueError(f"refusing to overwrite existing output: {output_path}")
385
- document = validate_viewmodel(json.loads(input_path.read_text(encoding="utf-8")))
386
- assets = normalize_assets(document.get("assets"), input_path.parent)
387
- views = document["views"]
388
- shell = document.get("shell", {})
389
- sidebar_tabs = bool(shell.get("sidebar_tabs"))
390
-
391
- def primary_tabs(view: dict[str, Any]) -> list[dict[str, Any]]:
392
- for block in view.get("blocks", []):
393
- if isinstance(block, dict) and block.get("type") == "tabs":
394
- return block.get("tabs", [])
395
- return []
396
-
397
- nav_groups = []
398
- for view in views:
399
- view_id = slug(view["id"])
400
- children = primary_tabs(view) if sidebar_tabs else []
401
- child_html = ""
402
- if children:
403
- child_buttons = []
404
- for index, tab in enumerate(children):
405
- tab_id = slug(tab.get("id", f"tab-{index + 1}"))
406
- badge = tab.get("badge")
407
- badge_html = f'<span class="nav-child-badge">{esc(badge)}</span>' if badge not in (None, "") else ""
408
- child_buttons.append(
409
- f'<button type="button" class="nav-child" data-child-view="{esc(view_id)}" data-child-tab="{esc(tab_id)}">'
410
- f'<span>{esc(tab.get("label", tab_id))}</span>{badge_html}</button>'
411
- )
412
- child_html = f'<div class="nav-children" data-nav-children="{esc(view_id)}">{"".join(child_buttons)}</div>'
413
- chevron = '<span class="nav-chevron" aria-hidden="true">⌄</span>' if children else ""
414
- nav_groups.append(
415
- f'<div class="nav-group" data-nav-group="{esc(view_id)}">'
416
- f'<button type="button" class="nav-parent" data-view-target="{esc(view_id)}"><span class="nav-dot"></span><span>{esc(view["label"])}</span>{chevron}</button>'
417
- f'{child_html}</div>'
418
- )
419
- nav = "".join(nav_groups)
420
-
421
- reading_items = []
422
- for index, item in enumerate(shell.get("reading_path", []), 1):
423
- tab_attr = f' data-reading-tab="{esc(slug(item["tab_id"]))}"' if item.get("tab_id") else ""
424
- reading_items.append(
425
- f'<button type="button" data-reading-view="{esc(slug(item["view_id"]))}"{tab_attr}>'
426
- f'<span class="reading-step">{index}</span><span>{esc(item["label"])}</span></button>'
427
- )
428
- reading_path = (
429
- '<nav class="reading-path" id="readingPath" aria-label="报告阅读路径">'
430
- '<b>阅读路径</b><div class="reading-items">' + '<span class="reading-arrow">→</span>'.join(reading_items) + '</div></nav>'
431
- if reading_items else ""
432
- )
433
-
434
- renderer = Renderer(document, assets)
435
- rendered_views = []
436
- for index, view in enumerate(views):
437
- hidden = "" if index == 0 else " hidden"
438
- badge = view.get("badge")
439
- badge_html = ""
440
- if badge:
441
- badge_html = f'<span class="tag tone-{esc(slug(badge.get("tone", "info")))}">{esc(badge.get("text", ""))}</span>' if isinstance(badge, dict) else f'<span class="tag tone-info">{esc(badge)}</span>'
442
- heading = f'<div class="page-heading"><div><div class="eyebrow">{esc(view.get("eyebrow", ""))}</div><h1>{esc(view["title"])}</h1><p>{esc(view.get("subtitle", ""))}</p></div>{badge_html}</div>'
443
- content = renderer.render_blocks(view.get("blocks", []))
444
- rendered_views.append(f'<section class="view" data-view="{esc(slug(view["id"]))}" id="view-{esc(slug(view["id"]))}" tabindex="-1"{hidden}>{heading}{content}</section>')
445
-
446
- meta = document["meta"]
447
- status = "".join(f"<div>{esc(line)}</div>" for line in shell.get("status", []))
448
- search = '<input class="search" id="search" type="search" placeholder="{}">'.format(esc(shell.get("search_placeholder", "搜索当前视图"))) if shell.get("search", True) else ""
449
- date = f'<div class="date-chip" id="dateRange">{esc(shell.get("date_range", ""))}</div>' if shell.get("date_range") else ""
450
- replacements = {
451
- "__TITLE__": esc(meta["title"]),
452
- "__CLI_VERSION__": esc(CLI_VERSION),
453
- "__CONTRACT__": esc(CONTRACT),
454
- "__EXTERNAL_IMAGE_POLICY__": esc(str(meta.get("external_images", "forbid"))),
455
- "__BRAND_MARK__": esc(shell.get("brand_mark", "CI")),
456
- "__BRAND_TITLE__": esc(shell.get("brand_title", meta["title"])),
457
- "__BRAND_SUBTITLE__": esc(shell.get("brand_subtitle", "Commerce Insight")),
458
- "__TOP_TITLE__": esc(meta["title"]),
459
- "__TOP_META__": esc(meta.get("subtitle", "")),
460
- "__NAV_LABEL__": esc(shell.get("nav_label", "报告视图")),
461
- "__NAV__": nav,
462
- "__BODY_CLASS__": "sidebar-tabs" if sidebar_tabs else "",
463
- "__READING_PATH__": reading_path,
464
- "__STATUS__": status,
465
- "__TOOLS__": search + date,
466
- "__VIEWS__": "".join(rendered_views),
467
- "__FOOTER__": esc(meta.get("footer", "")),
468
- "__VIEW_IDS__": json.dumps([slug(view["id"]) for view in views], ensure_ascii=False),
469
- }
470
- template = (ROOT / "templates" / "workbench.html").read_text(encoding="utf-8")
471
- for marker, value in replacements.items():
472
- if marker not in template:
473
- raise ValueError(f"template marker missing: {marker}")
474
- template = template.replace(marker, value)
475
- output_path.parent.mkdir(parents=True, exist_ok=True)
476
- output_path.write_text(template, encoding="utf-8")
477
- return {
478
- "ok": True,
479
- "output": str(output_path),
480
- "cli_version": CLI_VERSION,
481
- "contract": CONTRACT,
482
- "views": len(views),
483
- "assets_embedded": len(assets),
484
- "bytes": output_path.stat().st_size,
485
- }
@@ -1,67 +0,0 @@
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">
6
- <meta name="color-scheme" content="light">
7
- <meta name="generator" content="commerce-ui/__CLI_VERSION__">
8
- <meta name="commerce-ui-contract" content="__CONTRACT__">
9
- <meta name="commerce-ui-external-images" content="__EXTERNAL_IMAGE_POLICY__">
10
- <link rel="icon" href="data:,">
11
- <title>__TITLE__</title>
12
- <style>
13
- :root{--primary:#315bd6;--primary-soft:#eaf0ff;--page:#f3f5f7;--panel:#fff;--text:#20242c;--muted:#697386;--line:#dfe4ea;--positive:#16845b;--positive-soft:#eaf8f2;--warning:#9a6500;--warning-soft:#fff7df;--danger:#bd3e3e;--danger-soft:#fff0f0;--info:#315bd6;--info-soft:#eaf0ff;--sidebar:236px;--topbar:68px;--radius:9px;font-family:Inter,-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;font-size:15px;line-height:1.58;color:var(--text);background:var(--page);font-variant-numeric:tabular-nums}
14
- *{box-sizing:border-box}html{scroll-behavior:auto}body{margin:0;background:var(--page)}button,input{font:inherit}button{cursor:pointer}.hidden,[hidden]{display:none!important}:focus-visible{outline:2px solid var(--primary);outline-offset:2px}
15
- .sidebar{position:fixed;inset:0 auto 0 0;width:var(--sidebar);display:flex;flex-direction:column;background:#fafbfc;border-right:1px solid var(--line);z-index:30}.brand{height:var(--topbar);display:flex;align-items:center;gap:11px;padding:0 16px;border-bottom:1px solid var(--line)}.brandmark{width:36px;height:36px;border-radius:9px;display:grid;place-items:center;background:var(--primary);color:#fff;font-weight:800}.brand b{display:block;font-size:15px}.brand small{display:block;font-size:12px;color:var(--muted)}.nav{padding:16px 10px;display:block;overflow:auto}.nav-label{padding:0 9px 7px;color:#8a92a0;font-size:12px;font-weight:700}.nav-group+.nav-group{margin-top:4px}.nav button{width:100%;min-height:40px;border:0;border-radius:7px;padding:8px 10px;display:flex;align-items:center;gap:10px;background:transparent;color:#596274;text-align:left;font-size:13px}.nav button:hover{background:#eef2f7}.nav button.active{background:var(--primary-soft);color:var(--primary);font-weight:750}.nav-parent>span:nth-child(2){min-width:0;flex:1}.nav-dot{width:7px;height:7px;border-radius:2px;background:currentColor;flex:none}.nav-chevron{margin-left:auto;color:#9aa2af;transition:transform .15s}.nav-group.active .nav-chevron{transform:rotate(180deg)}.nav-children{display:none;margin:3px 0 6px 16px;padding-left:9px;border-left:1px solid #d8dee8}.nav-group.active .nav-children{display:grid;gap:2px}.nav .nav-child{min-height:34px;padding:6px 8px;font-size:12px}.nav-child>span:first-child{min-width:0;flex:1}.nav-child-badge{color:#8a92a0;font-size:12px;font-weight:600}.nav-child.active .nav-child-badge{color:inherit}.side-status{margin:auto 12px 16px;padding:13px 12px;border-top:1px solid var(--line);color:var(--muted);font-size:12px}.side-status div+div{margin-top:4px}.side-status div:first-child{color:var(--text);font-weight:700}
16
- .app{min-height:100vh;margin-left:var(--sidebar)}.topbar{height:var(--topbar);position:sticky;top:0;z-index:20;padding:0 22px;display:flex;align-items:center;justify-content:space-between;gap:16px;background:rgba(255,255,255,.96);backdrop-filter:blur(8px);border-bottom:1px solid var(--line)}.identity b{display:block;font-size:15px}.identity span{display:block;color:var(--muted);font-size:12px}.tools{display:flex;align-items:center;gap:8px}.search{height:38px;min-width:240px;padding:0 12px;border:1px solid var(--line);border-radius:8px;background:#fafbfc;color:var(--text)}.date-chip{height:38px;display:flex;align-items:center;padding:0 12px;border:1px solid var(--line);border-radius:8px;background:#fff;color:#596274;font-size:13px;white-space:nowrap}.menu-toggle{display:none;width:38px;height:38px;border:1px solid var(--line);border-radius:8px;background:#fff}.reading-path{position:sticky;top:var(--actual-topbar,var(--topbar));z-index:19;min-height:48px;padding:8px 22px;display:flex;align-items:center;gap:13px;background:rgba(250,251,252,.97);backdrop-filter:blur(8px);border-bottom:1px solid var(--line)}.reading-path>b{flex:none;color:#596274;font-size:12px}.reading-items{min-width:0;display:flex;align-items:center;gap:8px;overflow-x:auto;scrollbar-width:thin}.reading-path button{flex:none;min-height:31px;padding:4px 9px;border:1px solid #d8dee8;border-radius:999px;background:#fff;color:#596274;font-size:12px;white-space:nowrap}.reading-path button.active{border-color:#bdcafa;background:var(--primary-soft);color:var(--primary);font-weight:700}.reading-step{display:inline-grid;place-items:center;width:18px;height:18px;margin-right:5px;border-radius:50%;background:#eef1f5;font-size:12px}.reading-path button.active .reading-step{background:#dbe5ff}.reading-arrow{color:#a4acb8;font-size:12px}
17
- main{max-width:1420px;margin:0 auto;padding:22px}.view{outline:none}.page-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:13px}.eyebrow{color:var(--primary);font-size:12px;font-weight:750;margin-bottom:3px}.page-heading h1{margin:0;font-size:25px;line-height:1.25}.page-heading p{margin:5px 0 0;color:var(--muted);font-size:13px;max-width:900px}.tag{display:inline-flex;align-items:center;min-height:27px;padding:3px 9px;border-radius:999px;font-size:12px;font-weight:700;white-space:nowrap}.tone-info{background:var(--info-soft);color:var(--info)}.tone-positive{background:var(--positive-soft);color:var(--positive)}.tone-warning{background:var(--warning-soft);color:var(--warning)}.tone-danger{background:var(--danger-soft);color:var(--danger)}
18
- .alert{margin-bottom:10px;padding:11px 13px;border:1px solid #ecd08d;border-radius:var(--radius);background:var(--warning-soft);color:#72531a;font-size:13px}.alert strong{color:#57400f}.alert.tone-info{border-color:#cbd7f6}.alert.tone-positive{border-color:#a7dcc8}.alert.tone-danger{border-color:#efb8b8}.card{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);box-shadow:0 1px 2px rgba(25,34,49,.03);min-width:0}.card-head{min-height:49px;padding:0 13px;display:flex;align-items:center;justify-content:space-between;gap:12px;border-bottom:1px solid #edf0f3}.card-head h2{font-size:15px;margin:0}.card-head span{color:var(--muted);font-size:12px}.card-body{padding:13px}
19
- .metrics{display:grid;gap:9px;margin-bottom:10px}.metrics-1{grid-template-columns:1fr}.metrics-2{grid-template-columns:repeat(2,minmax(0,1fr))}.metrics-3{grid-template-columns:repeat(3,minmax(0,1fr))}.metrics-4{grid-template-columns:repeat(4,minmax(0,1fr))}.metrics-5{grid-template-columns:repeat(5,minmax(0,1fr))}.metrics-6{grid-template-columns:repeat(6,minmax(0,1fr))}.metrics-7{grid-template-columns:repeat(7,minmax(0,1fr))}.metrics-8{grid-template-columns:repeat(8,minmax(0,1fr))}.metric{padding:13px;min-height:108px}.metric-label{display:flex;justify-content:space-between;gap:6px;color:var(--muted);font-size:12px}.metric-badge{font-size:12px;font-weight:700;background:transparent!important}.metric-value{margin:10px 0 5px;font-size:27px;font-weight:780;line-height:1.15}.metric-note{color:var(--muted);font-size:12px}.variant-funnel .metric{min-height:82px;border-top:3px solid var(--primary)}.variant-funnel .metric-value{font-size:22px;margin:7px 0 0}.module-grid{display:grid;gap:10px;margin-bottom:10px}.grid-1{grid-template-columns:1fr}.grid-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-4{grid-template-columns:repeat(4,minmax(0,1fr))}.content-list{margin:0;padding-left:21px;font-size:13px;color:#4f596a}.content-list li+li{margin-top:7px}.card>.card-body>.content-list{padding-left:20px}ol.content-list{list-style:none;counter-reset:rank;padding:0}ol.content-list li{counter-increment:rank;padding:10px 0;border-bottom:1px solid #edf0f3;margin:0}ol.content-list li:last-child{border-bottom:0}ol.content-list li:before{content:counter(rank);display:inline-block;min-width:20px;color:var(--primary);font-weight:800}.callout{padding:14px;border-left:4px solid var(--primary);background:#f6f8ff;border-radius:0 8px 8px 0;font-size:14px}.prose p{font-size:13px;margin:0}.prose p+p{margin-top:8px}.mini-stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:7px}.mini-stat{padding:8px;border-radius:7px;background:#f7f8fa}.mini-stat span{display:block;color:var(--muted);font-size:12px}.mini-stat b{font-size:17px}.text-button{border:0;background:transparent;color:var(--primary);padding:9px 0 0;font-size:13px;font-weight:700}.text-button.compact{padding-top:4px;font-size:12px}
20
- .action-list{margin:-13px}.action-row{display:grid;grid-template-columns:28px 1fr auto;gap:9px;padding:12px 13px;border-bottom:1px solid #edf0f3}.action-row:last-child{border:0}.action-rank{width:28px;height:28px;border-radius:7px;background:var(--primary-soft);display:grid;place-items:center;color:var(--primary);font-weight:700;font-size:12px}.action-row b{font-size:13px}.action-row p{font-size:12px;color:var(--muted);margin:3px 0 0}
21
- .conclusion-card{margin-bottom:10px}.conclusion-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:10px;padding:13px}.conclusion-item{min-width:0;padding:14px;border:1px solid var(--line);border-top:3px solid var(--info);border-radius:8px;background:#fff;overflow-wrap:anywhere}.conclusion-item.conclusion-tone-positive{border-top-color:var(--positive)}.conclusion-item.conclusion-tone-warning{border-top-color:var(--warning)}.conclusion-item.conclusion-tone-danger{border-top-color:var(--danger)}.conclusion-item h3{margin:0 0 9px;font-size:13px;font-weight:800;color:var(--info)}.conclusion-item.conclusion-tone-positive h3{color:var(--positive)}.conclusion-item.conclusion-tone-warning h3{color:var(--warning)}.conclusion-item.conclusion-tone-danger h3{color:var(--danger)}.conclusion-copy{margin:0;font-size:16px;line-height:1.55;color:var(--text)}.conclusion-segment.emphasis-strong,.conclusion-segment.emphasis-strong-italic{font-weight:800}.conclusion-segment.emphasis-italic,.conclusion-segment.emphasis-strong-italic{font-style:italic}.conclusion-color-info{color:var(--info)}.conclusion-color-positive{color:var(--positive)}.conclusion-color-warning{color:var(--warning)}.conclusion-color-danger{color:var(--danger)}.conclusion-note{margin-top:9px;color:var(--muted);font-size:12px;line-height:1.5}
22
- .tabs-card{margin-bottom:10px}.tab-buttons{display:flex;gap:7px;flex-wrap:wrap;padding:12px}.sidebar-tabs .tab-buttons{display:none}.sidebar-tabs .tabs-card>.card-head{margin-bottom:12px}.tab-buttons button{min-height:36px;padding:7px 10px;border:1px solid var(--line);border-radius:7px;background:#fff;color:#596274;font-size:13px}.tab-buttons button.active{border-color:#bdcafa;background:var(--primary-soft);color:var(--primary);font-weight:700}.tab-panel{padding:0 13px 13px}.tab-panel>.card:first-child{margin-bottom:10px;background:#f7f8fa;border:0;box-shadow:none}.tab-panel>.card:first-child .card-head{border:0;min-height:44px}.tab-panel>.card:first-child .card-body{padding-top:0}
23
- .product-grid{display:grid;gap:10px;align-items:start}.product-grid-1{grid-template-columns:1fr}.product-grid-2{grid-template-columns:repeat(2,minmax(0,1fr))}.product-grid-3{grid-template-columns:repeat(3,minmax(0,1fr))}.product-grid-4{grid-template-columns:repeat(4,minmax(0,1fr))}.product-card{overflow:hidden}.product-media-link{display:block;color:inherit;text-decoration:none}.product-media-link:hover .product-media{outline:2px solid var(--primary);outline-offset:-2px}.product-title-link{color:inherit;text-decoration:none}.product-title-link:hover{color:var(--primary);text-decoration:underline}.product-media{position:relative;width:100%;aspect-ratio:var(--product-image-aspect,1/1);overflow:hidden;background:#eef1f4;transition:aspect-ratio .15s ease}.product-card .product-media img,.product-card .product-media>.image-empty{display:block;width:100%;height:100%;aspect-ratio:auto;object-fit:contain;background:#eef1f4}.product-card .product-media>.image-empty{display:grid;place-items:center;color:var(--muted);font-size:13px}.product-body{padding:12px}.product-title{min-height:43px;font-size:13px;font-weight:750;overflow-wrap:anywhere}.product-meta{display:flex;justify-content:space-between;align-items:baseline;gap:8px;margin:9px 0}.price{font-size:20px;color:#c85213;font-weight:800}.facts{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px;margin-bottom:9px}.fact{padding:7px;background:#f7f8fa;border-radius:7px}.fact span{display:block;color:var(--muted);font-size:12px}.fact b{display:block;font-size:13px;overflow-wrap:anywhere}.evidence{padding:8px;border-radius:7px;background:#f5f2ff;color:#544a78;font-size:12px;margin-bottom:8px}.product-actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px}.open-button{width:100%;min-height:36px;border:1px solid #cbd5f5;border-radius:7px;background:#fff;color:var(--primary);font-size:13px;font-weight:700}.copy-button{border-color:#d8dde6;color:#596274}.inline-actions{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
24
- .table-card{margin-bottom:10px}.table-wrap{overflow:auto}.data-table{width:100%;border-collapse:collapse;font-size:13px}.data-table th{height:44px;padding:0 12px;text-align:left;background:#f7f8fa;color:#667082;border-bottom:1px solid var(--line);font-size:12px;white-space:nowrap}.data-table td{padding:10px 12px;border-bottom:1px solid #edf0f3;vertical-align:top}.data-table tr:hover td{background:#fafcff}.cell-product{display:flex;align-items:center;gap:9px;min-width:250px}.cell-thumb{width:48px;height:48px;flex:none;border-radius:7px;overflow:hidden;background:#eef1f4}.cell-thumb img,.cell-thumb .image-empty{width:48px;height:48px;aspect-ratio:1;object-fit:cover;font-size:12px;text-align:center}.empty{padding:24px;text-align:center;color:var(--muted);font-size:13px}.footer-note{padding:14px 0;text-align:right;color:var(--muted);font-size:12px}
25
- @media(max-width:1180px){.metrics-5,.metrics-6,.metrics-7,.metrics-8{grid-template-columns:repeat(3,1fr)}.product-grid-3,.product-grid-4{grid-template-columns:repeat(2,1fr)}}
26
- @media(max-width:900px){.sidebar{transform:translateX(-100%);transition:transform .2s}.sidebar.open{transform:translateX(0);box-shadow:0 12px 34px rgba(25,34,49,.18)}.app{margin-left:0}.menu-toggle{display:inline-grid;place-items:center}.topbar{padding:0 14px}.reading-path{padding-inline:14px}.grid-2,.grid-3,.grid-4{grid-template-columns:1fr}.metrics-4{grid-template-columns:repeat(2,1fr)}}
27
- @media(max-width:600px){main{padding:14px}.topbar{height:auto;min-height:68px;flex-wrap:wrap;padding-block:9px}.tools{width:100%}.search{min-width:0;flex:1}.date-chip{font-size:12px}.reading-path{align-items:flex-start;gap:8px}.reading-path>b{padding-top:6px}.metrics-2,.metrics-3,.metrics-4,.metrics-5,.metrics-6,.metrics-7,.metrics-8{grid-template-columns:repeat(2,1fr)}.product-grid-2,.product-grid-3,.product-grid-4{grid-template-columns:1fr}.mini-stats{grid-template-columns:repeat(2,1fr)}.page-heading{display:block}.page-heading>.tag{margin-top:8px}}
28
- .gallery-controls{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;padding:10px 12px;margin:12px 0;background:#fff;border:1px solid #dce2ea;border-radius:9px;color:var(--muted);font-size:13px}.gallery-controls label,.gallery-pager{display:flex;align-items:center;gap:8px}.gallery-controls select,.gallery-pager button{padding:7px 10px;border:1px solid #cbd5e1;border-radius:7px;background:#fff;color:var(--text)}.gallery-pager button:disabled{opacity:.45;cursor:not-allowed}.gallery-entry{min-width:0}.gallery-controls-bottom{margin-top:14px}
29
- @media print{.sidebar,.tools,.menu-toggle,.text-button,.open-button,.reading-path,.gallery-controls{display:none!important}.app{margin:0}.topbar{position:static;height:auto;padding:0 0 12px}.view,.view[hidden]{display:block!important;margin-bottom:22px}.view+.view{break-before:page}.tab-panel,.tab-panel[hidden]{display:block!important}.tab-buttons{display:none}.gallery-entry[hidden]{display:block!important}.card,.alert,.product-card{break-inside:avoid}main{max-width:none;padding:0}}
30
- </style>
31
- </head>
32
- <body class="__BODY_CLASS__">
33
- <aside class="sidebar" id="sidebar" aria-label="报告导航">
34
- <div class="brand"><div class="brandmark">__BRAND_MARK__</div><div><b>__BRAND_TITLE__</b><small>__BRAND_SUBTITLE__</small></div></div>
35
- <nav class="nav" id="nav"><div class="nav-label">__NAV_LABEL__</div>__NAV__</nav>
36
- <div class="side-status" id="sideStatus">__STATUS__</div>
37
- </aside>
38
- <div class="app">
39
- <header class="topbar"><div style="display:flex;align-items:center;gap:10px"><button class="menu-toggle" id="menuToggle" type="button" aria-label="打开导航">☰</button><div class="identity"><b>__TOP_TITLE__</b><span>__TOP_META__</span></div></div><div class="tools">__TOOLS__</div></header>
40
- __READING_PATH__
41
- <main id="workspace">__VIEWS__<div class="footer-note">__FOOTER__</div></main>
42
- </div>
43
- <script>
44
- const viewIds=__VIEW_IDS__,q=(s,r=document)=>r.querySelector(s),qa=(s,r=document)=>[...r.querySelectorAll(s)];
45
- const navButtons=qa('[data-view-target]'),childButtons=qa('[data-child-view]'),readingButtons=qa('[data-reading-view]');
46
- function activateTab(viewId,tabId){const view=q(`[data-view="${CSS.escape(viewId)}"]`);if(!view)return null;const target=tabId?q(`[data-tab-target="${CSS.escape(tabId)}"]`,view):q('[data-tab-target].active',view)||q('[data-tab-target]',view);if(!target)return null;const group=target.closest('[data-tabs]'),buttons=qa('[data-tab-target]',group),panels=qa('[data-tab]',group);buttons.forEach(button=>button.classList.toggle('active',button===target));panels.forEach(panel=>panel.hidden=panel.dataset.tab!==target.dataset.tabTarget);childButtons.forEach(button=>button.classList.toggle('active',button.dataset.childView===viewId&&button.dataset.childTab===target.dataset.tabTarget));return target.dataset.tabTarget}
47
- function showView(id,{tabId=null,historyMode='push',focus=true}={}){const valid=viewIds.includes(id)?id:viewIds[0];qa('[data-view]').forEach(v=>{const on=v.dataset.view===valid;v.hidden=!on;v.classList.toggle('active',on)});navButtons.forEach(b=>{const on=b.dataset.viewTarget===valid;b.classList.toggle('active',on);b.setAttribute('aria-current',on?'page':'false')});qa('[data-nav-group]').forEach(group=>group.classList.toggle('active',group.dataset.navGroup===valid));const activeTab=activateTab(valid,tabId),activeView=q(`[data-view="${CSS.escape(valid)}"]`);qa('[data-tab]:not([hidden]) [data-product-gallery]',activeView).forEach(gallery=>updateGallery(gallery,{reset:true}));readingButtons.forEach(button=>button.classList.toggle('active',button.dataset.readingView===valid));const hash=`#${valid}${activeTab?`/${activeTab}`:''}`;if(historyMode==='push'&&location.hash!==hash)history.pushState({view:valid,tab:activeTab},'',hash);if(historyMode==='replace')history.replaceState({view:valid,tab:activeTab},'',hash);q('#sidebar').classList.remove('open');window.scrollTo(0,0);if(focus)activeView?.focus({preventScroll:true});return valid}
48
- navButtons.forEach(b=>b.addEventListener('click',()=>showView(b.dataset.viewTarget)));
49
- childButtons.forEach(b=>b.addEventListener('click',()=>{const viewId=b.dataset.childView,tabId=b.dataset.childTab;showView(viewId,{tabId});const view=q(`[data-view="${CSS.escape(viewId)}"]`),panel=q(`[data-tab="${CSS.escape(tabId)}"]`,view),section=panel?.closest('.tabs-card');if(section)window.scrollTo({top:section.getBoundingClientRect().top+window.scrollY-115,behavior:'auto'})}));
50
- readingButtons.forEach(b=>b.addEventListener('click',()=>showView(b.dataset.readingView,{tabId:b.dataset.readingTab||null})));
51
- qa('[data-tabs]').forEach(group=>{const buttons=qa('[data-tab-target]',group);buttons.forEach(button=>button.addEventListener('click',()=>{const view=button.closest('[data-view]');showView(view.dataset.view,{tabId:button.dataset.tabTarget,focus:false})}))});
52
- async function copyText(value){try{if(navigator.clipboard&&window.isSecureContext){await navigator.clipboard.writeText(value);return true}}catch(error){}const area=document.createElement('textarea');area.value=value;area.setAttribute('readonly','');area.style.position='fixed';area.style.opacity='0';document.body.appendChild(area);area.select();let ok=false;try{ok=document.execCommand('copy')}catch(error){}area.remove();return ok}
53
- document.addEventListener('click',async event=>{const open=event.target.closest('[data-open-view]');if(open)showView(open.dataset.openView);const copy=event.target.closest('[data-copy]');if(copy){const original=copy.dataset.originalLabel||copy.textContent;copy.dataset.originalLabel=original;const ok=await copyText(copy.dataset.copy);copy.textContent=ok?'已复制':'复制失败';window.setTimeout(()=>{copy.textContent=original},1400);return}const link=event.target.closest('[data-url]');if(link)window.open(link.dataset.url,'_blank','noopener,noreferrer')});
54
- function hashTarget(){const [view,tab]=decodeURIComponent(location.hash.slice(1)).split('/');return{view,tab}}
55
- const initial=hashTarget();showView(initial.view,{tabId:initial.tab,historyMode:'replace',focus:false});window.addEventListener('popstate',()=>{const target=hashTarget();showView(target.view,{tabId:target.tab,historyMode:'none',focus:false})});
56
- function updateGallery(gallery,{reset=false}={}){const grid=q('[data-gallery-grid]',gallery),entries=qa('.gallery-entry',grid),select=q('[data-gallery-sort]',gallery),key=select.value,selected=select.selectedOptions[0],direction=selected?.dataset.direction==='asc'?1:-1,term=(q('#search')?.value||'').trim().toLowerCase();if(reset)gallery.dataset.page='1';const ordered=[...entries].sort((a,b)=>{if(key==='source')return Number(a.dataset.sourceOrder)-Number(b.dataset.sourceOrder);const av=JSON.parse(a.dataset.sortValues||'{}')[key],bv=JSON.parse(b.dataset.sortValues||'{}')[key];if(av==null&&bv==null)return Number(a.dataset.sourceOrder)-Number(b.dataset.sourceOrder);if(av==null)return 1;if(bv==null)return -1;return (Number(av)-Number(bv))*direction||Number(a.dataset.sourceOrder)-Number(b.dataset.sourceOrder)});ordered.forEach(entry=>grid.appendChild(entry));const matches=ordered.filter(entry=>!term||entry.textContent.toLowerCase().includes(term)),size=Number(gallery.dataset.pageSize)||50,pages=Math.max(1,Math.ceil(matches.length/size)),page=Math.min(pages,Math.max(1,Number(gallery.dataset.page)||1));gallery.dataset.page=String(page);ordered.forEach(entry=>entry.hidden=true);matches.slice((page-1)*size,page*size).forEach(entry=>entry.hidden=false);for(const attr of ['data-gallery-count','data-gallery-count-bottom']){const node=q(`[${attr}]`,gallery);if(node)node.textContent=`${matches.length} 件${term?'匹配商品':''}`};for(const attr of ['data-page-status','data-page-status-bottom']){const node=q(`[${attr}]`,gallery);if(node)node.textContent=`第 ${page} / ${pages} 页`};for(const attr of ['data-page-prev','data-page-prev-bottom']){const node=q(`[${attr}]`,gallery);if(node)node.disabled=page<=1};for(const attr of ['data-page-next','data-page-next-bottom']){const node=q(`[${attr}]`,gallery);if(node)node.disabled=page>=pages}}
57
- qa('[data-product-gallery]').forEach(gallery=>{updateGallery(gallery);q('[data-gallery-sort]',gallery).addEventListener('change',()=>updateGallery(gallery,{reset:true}));for(const attr of ['data-page-prev','data-page-prev-bottom'])q(`[${attr}]`,gallery).addEventListener('click',()=>{gallery.dataset.page=String(Number(gallery.dataset.page)-1);updateGallery(gallery);window.scrollTo({top:gallery.getBoundingClientRect().top+window.scrollY-110,behavior:'smooth'})});for(const attr of ['data-page-next','data-page-next-bottom'])q(`[${attr}]`,gallery).addEventListener('click',()=>{gallery.dataset.page=String(Number(gallery.dataset.page)+1);updateGallery(gallery);window.scrollTo({top:gallery.getBoundingClientRect().top+window.scrollY-110,behavior:'smooth'})})});
58
- const search=q('#search');if(search)search.addEventListener('input',()=>{const term=search.value.trim().toLowerCase(),active=q('[data-view]:not([hidden])');qa('.searchable',active).filter(node=>!node.closest('[data-product-gallery]')).forEach(node=>node.classList.toggle('hidden',Boolean(term)&&!node.textContent.toLowerCase().includes(term)));qa('[data-product-gallery]',active).forEach(gallery=>updateGallery(gallery,{reset:true}))});
59
- q('#menuToggle').addEventListener('click',()=>q('#sidebar').classList.toggle('open'));document.addEventListener('keydown',event=>{if((event.metaKey||event.ctrlKey)&&event.key.toLowerCase()==='k'&&search){event.preventDefault();search.focus()}if(event.key==='Escape')q('#sidebar').classList.remove('open')});
60
- function syncProductImageAspect(image){if(!image.naturalWidth||!image.naturalHeight)return;const media=image.closest('.product-media');if(!media)return;media.style.setProperty('--product-image-aspect',`${image.naturalWidth}/${image.naturalHeight}`);media.dataset.imageAspect=`${image.naturalWidth}:${image.naturalHeight}`}
61
- qa('.product-media img').forEach(image=>{if(image.complete&&image.naturalWidth)syncProductImageAspect(image);else image.addEventListener('load',()=>syncProductImageAspect(image),{once:true})});
62
- qa('img[data-remote-image]').forEach(image=>image.addEventListener('error',()=>{image.hidden=true;const fallback=image.nextElementSibling;if(fallback)fallback.hidden=false},{once:true}));
63
- function syncTopbarHeight(){const topbar=q('.topbar');if(topbar)document.documentElement.style.setProperty('--actual-topbar',`${topbar.offsetHeight}px`)}syncTopbarHeight();window.addEventListener('resize',syncTopbarHeight);if(window.ResizeObserver)new ResizeObserver(syncTopbarHeight).observe(q('.topbar'));
64
- window.commerceUI={contract:'__CONTRACT__',version:'__CLI_VERSION__',viewIds,showView,activateTab,visibleViews:()=>qa('[data-view]').filter(v=>!v.hidden).map(v=>v.dataset.view)};
65
- </script>
66
- </body>
67
- </html>