visualize_how_results_v2.py 61 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """
  4. How解构结果可视化脚本 V3
  5. 改进版:
  6. - 使用标签页展示多个帖子
  7. - 参考 visualize_inspiration_points.py 的帖子详情展示
  8. - 分层可折叠的匹配结果
  9. - V3: 使用新的输入目录 当前帖子_how解构结果_v2
  10. """
  11. import json
  12. from pathlib import Path
  13. from typing import Dict, List
  14. import sys
  15. import html as html_module
  16. # 添加项目根目录到路径
  17. project_root = Path(__file__).parent.parent.parent
  18. sys.path.insert(0, str(project_root))
  19. def get_relation_color(relation: str) -> str:
  20. """根据关系类型返回对应的颜色"""
  21. color_map = {
  22. "same": "#10b981", # 绿色 - 同义
  23. "contains": "#3b82f6", # 蓝色 - 包含
  24. "contained_by": "#8b5cf6", # 紫色 - 被包含
  25. "coordinate": "#f59e0b", # 橙色 - 同级
  26. "overlap": "#ec4899", # 粉色 - 部分重叠
  27. "related": "#6366f1", # 靛蓝 - 相关
  28. "unrelated": "#9ca3af" # 灰色 - 无关
  29. }
  30. return color_map.get(relation, "#9ca3af")
  31. def get_relation_label(relation: str) -> str:
  32. """返回关系类型的中文标签"""
  33. label_map = {
  34. "same": "同义",
  35. "contains": "包含",
  36. "contained_by": "被包含",
  37. "coordinate": "同级",
  38. "overlap": "部分重叠",
  39. "related": "相关",
  40. "unrelated": "无关"
  41. }
  42. return label_map.get(relation, relation)
  43. def generate_historical_post_card_html(post_detail: Dict, inspiration_point: Dict) -> str:
  44. """生成历史帖子的紧凑卡片HTML"""
  45. title = post_detail.get("title", "无标题")
  46. body_text = post_detail.get("body_text", "")
  47. images = post_detail.get("images", [])
  48. like_count = post_detail.get("like_count", 0)
  49. collect_count = post_detail.get("collect_count", 0)
  50. comment_count = post_detail.get("comment_count", 0)
  51. author = post_detail.get("channel_account_name", "")
  52. link = post_detail.get("link", "#")
  53. publish_time = post_detail.get("publish_time", "")
  54. # 获取灵感点信息
  55. point_name = inspiration_point.get("点的名称", "")
  56. point_desc = inspiration_point.get("点的描述", "")
  57. # 准备详情数据(用于模态框)
  58. import json
  59. post_detail_data = {
  60. "title": title,
  61. "body_text": body_text,
  62. "images": images,
  63. "like_count": like_count,
  64. "comment_count": comment_count,
  65. "collect_count": collect_count,
  66. "author": author,
  67. "publish_time": publish_time,
  68. "link": link
  69. }
  70. post_data_json = json.dumps(post_detail_data, ensure_ascii=False)
  71. post_data_json_escaped = html_module.escape(post_data_json)
  72. # 截取正文预览(前80个字符)
  73. body_preview = body_text[:80] + "..." if len(body_text) > 80 else body_text
  74. # 生成缩略图
  75. thumbnail_html = ""
  76. if images:
  77. thumbnail_html = f'<img src="{images[0]}" alt="Post thumbnail" class="historical-post-thumbnail" loading="lazy">'
  78. html = f'''
  79. <div class="historical-post-card" data-post-data='{post_data_json_escaped}' onclick="showPostDetail(this)">
  80. <div class="historical-post-image">
  81. {thumbnail_html}
  82. {f'<div class="post-card-image-count">{len(images)}</div>' if len(images) > 1 else ''}
  83. </div>
  84. <div class="historical-post-content">
  85. <div class="historical-post-title">{html_module.escape(title)}</div>
  86. <div class="historical-inspiration-info">
  87. <span class="inspiration-type-badge-small">灵感点</span>
  88. <span class="inspiration-name-small">{html_module.escape(point_name)}</span>
  89. </div>
  90. <div class="historical-inspiration-desc">{html_module.escape(point_desc[:100])}{"..." if len(point_desc) > 100 else ""}</div>
  91. <div class="historical-post-meta">
  92. <span class="historical-post-time">📅 {publish_time}</span>
  93. </div>
  94. <div class="historical-post-stats">
  95. <span>❤ {like_count}</span>
  96. <span>⭐ {collect_count}</span>
  97. <a href="{link}" target="_blank" class="view-link" onclick="event.stopPropagation()">查看原帖 →</a>
  98. </div>
  99. </div>
  100. </div>
  101. '''
  102. return html
  103. def generate_post_detail_html(post_data: Dict, post_idx: int) -> str:
  104. """生成帖子详情HTML(紧凑的卡片样式,点击可展开)"""
  105. post_detail = post_data.get("帖子详情", {})
  106. title = post_detail.get("title", "无标题")
  107. body_text = post_detail.get("body_text", "")
  108. images = post_detail.get("images", [])
  109. like_count = post_detail.get("like_count", 0)
  110. comment_count = post_detail.get("comment_count", 0)
  111. collect_count = post_detail.get("collect_count", 0)
  112. author = post_detail.get("channel_account_name", "")
  113. publish_time = post_detail.get("publish_time", "")
  114. link = post_detail.get("link", "")
  115. post_id = post_data.get("帖子id", f"post-{post_idx}")
  116. # 准备详情数据(用于模态框)
  117. import json
  118. post_detail_data = {
  119. "title": title,
  120. "body_text": body_text,
  121. "images": images,
  122. "like_count": like_count,
  123. "comment_count": comment_count,
  124. "collect_count": collect_count,
  125. "author": author,
  126. "publish_time": publish_time,
  127. "link": link
  128. }
  129. post_data_json = json.dumps(post_detail_data, ensure_ascii=False)
  130. post_data_json_escaped = html_module.escape(post_data_json)
  131. # 生成缩略图HTML
  132. thumbnail_html = ""
  133. if images and len(images) > 0:
  134. # 使用第一张图片作为缩略图
  135. thumbnail_html = f'<img src="{images[0]}" class="post-card-thumbnail" alt="缩略图">'
  136. else:
  137. thumbnail_html = '<div class="post-card-thumbnail-placeholder">📄</div>'
  138. # 截断正文用于预览
  139. body_preview = body_text[:80] + "..." if len(body_text) > 80 else body_text
  140. html = f'''
  141. <div class="post-card-compact" data-post-data='{post_data_json_escaped}' onclick="showPostDetail(this)">
  142. <div class="post-card-image">
  143. {thumbnail_html}
  144. {f'<div class="post-card-image-count">📷 {len(images)}</div>' if len(images) > 1 else ''}
  145. </div>
  146. <div class="post-card-content">
  147. <div class="post-card-title">{html_module.escape(title)}</div>
  148. <div class="post-card-preview">{html_module.escape(body_preview) if body_preview else "暂无正文"}</div>
  149. <div class="post-card-meta">
  150. <span class="post-card-author">👤 {html_module.escape(author)}</span>
  151. <span class="post-card-time">📅 {publish_time}</span>
  152. </div>
  153. <div class="post-card-stats">
  154. <span>👍 {like_count}</span>
  155. <span>💬 {comment_count if comment_count else 0}</span>
  156. <span>⭐ {collect_count if collect_count else 0}</span>
  157. </div>
  158. </div>
  159. </div>
  160. '''
  161. return html
  162. def generate_inspiration_detail_html(inspiration_point: Dict) -> str:
  163. """生成灵感点详情HTML"""
  164. name = inspiration_point.get("名称", "")
  165. desc = inspiration_point.get("描述", "")
  166. features = inspiration_point.get("特征列表", [])
  167. features_html = "".join([
  168. f'<span class="feature-tag">{html_module.escape(f)}</span>'
  169. for f in features
  170. ])
  171. html = f'''
  172. <div class="inspiration-detail-card">
  173. <div class="inspiration-header">
  174. <span class="inspiration-type-badge">灵感点</span>
  175. <h3 class="inspiration-name">{html_module.escape(name)}</h3>
  176. </div>
  177. <div class="inspiration-description">
  178. <div class="desc-label">描述:</div>
  179. <div class="desc-text">{html_module.escape(desc)}</div>
  180. </div>
  181. <div class="inspiration-features">
  182. <div class="features-label">特征列表:</div>
  183. <div class="features-tags">{features_html}</div>
  184. </div>
  185. </div>
  186. '''
  187. return html
  188. def load_feature_category_mapping() -> Dict:
  189. """加载特征名称到分类的映射"""
  190. script_dir = Path(__file__).parent
  191. project_root = script_dir.parent.parent
  192. mapping_file = project_root / "data" / "data_1118" / "特征名称_分类映射.json"
  193. try:
  194. with open(mapping_file, "r", encoding="utf-8") as f:
  195. return json.load(f)
  196. except Exception as e:
  197. print(f"警告: 无法加载特征分类映射文件: {e}")
  198. return {}
  199. def load_feature_source_mapping() -> Dict:
  200. """加载特征名称到帖子来源的映射"""
  201. script_dir = Path(__file__).parent
  202. project_root = script_dir.parent.parent
  203. mapping_file = project_root / "data" / "data_1118" / "特征名称_帖子来源.json"
  204. try:
  205. with open(mapping_file, "r", encoding="utf-8") as f:
  206. data = json.load(f)
  207. # 转换为便于查询的格式: {特征名称: [来源列表]}
  208. result = {}
  209. for feature_type in ["灵感点", "关键点", "目的点"]:
  210. if feature_type in data:
  211. for item in data[feature_type]:
  212. feature_name = item.get("特征名称")
  213. if feature_name:
  214. result[feature_name] = item.get("特征来源", [])
  215. return result
  216. except Exception as e:
  217. print(f"警告: 无法加载特征来源映射文件: {e}")
  218. return {}
  219. def generate_match_results_html(how_steps: List[Dict], feature_idx: int, insp_idx: int, post_idx: int, category_mapping: Dict = None, source_mapping: Dict = None) -> str:
  220. """生成可折叠的匹配结果HTML(兼容新旧数据格式)"""
  221. if not how_steps or len(how_steps) == 0:
  222. return ""
  223. step = how_steps[0]
  224. features = step.get("特征列表", [])
  225. if feature_idx >= len(features):
  226. return ""
  227. feature_data = features[feature_idx]
  228. feature_name = feature_data.get("特征名称", "")
  229. match_results = feature_data.get("匹配结果", [])
  230. if category_mapping is None:
  231. category_mapping = {}
  232. # 检测数据格式(新格式使用中文字段"分数",旧格式使用英文"score")
  233. is_new_format = False
  234. if match_results and len(match_results) > 0:
  235. first_match = match_results[0].get("匹配结果", {})
  236. is_new_format = "分数" in first_match
  237. # 按分数排序(兼容新旧格式)
  238. if is_new_format:
  239. sorted_matches = sorted(match_results, key=lambda x: x.get("匹配结果", {}).get("分数", 0), reverse=True)
  240. else:
  241. sorted_matches = sorted(match_results, key=lambda x: x.get("匹配结果", {}).get("score", 0), reverse=True)
  242. # 统计信息(新格式不显示relation统计,只显示总数)
  243. if is_new_format:
  244. stats_html = f'<span class="stat-badge" style="background: #667eea;">总匹配数: {len(match_results)}</span>'
  245. else:
  246. # 旧格式:统计匹配类型
  247. relation_counts = {}
  248. for match in match_results:
  249. relation = match.get("匹配结果", {}).get("relation", "unrelated")
  250. relation_counts[relation] = relation_counts.get(relation, 0) + 1
  251. # 生成统计信息
  252. stats_items = []
  253. for relation, count in sorted(relation_counts.items(), key=lambda x: x[1], reverse=True):
  254. label = get_relation_label(relation)
  255. color = get_relation_color(relation)
  256. stats_items.append(f'<span class="stat-badge" style="background: {color};">{label}: {count}</span>')
  257. stats_html = "".join(stats_items)
  258. # 生成匹配项
  259. matches_html = ""
  260. for i, match in enumerate(sorted_matches):
  261. persona_name = match.get("人设特征名称", "")
  262. match_result = match.get("匹配结果", {})
  263. # 兼容新旧格式
  264. if is_new_format:
  265. score = match_result.get("分数", 0.0)
  266. explanation = match_result.get("说明", "")
  267. # 新格式根据分数设置颜色
  268. if score >= 0.7:
  269. color = "#10b981" # 绿色 - 高相关
  270. label = "高相关"
  271. elif score >= 0.5:
  272. color = "#3b82f6" # 蓝色 - 中相关
  273. label = "中相关"
  274. elif score >= 0.3:
  275. color = "#f59e0b" # 橙色 - 低相关
  276. label = "低相关"
  277. else:
  278. color = "#9ca3af" # 灰色 - 弱相关
  279. label = "弱相关"
  280. else:
  281. relation = match_result.get("relation", "unrelated")
  282. score = match_result.get("score", 0.0)
  283. explanation = match_result.get("explanation", "")
  284. color = get_relation_color(relation)
  285. label = get_relation_label(relation)
  286. match_id = f"post-{post_idx}-insp-{insp_idx}-feat-{feature_idx}-match-{i}"
  287. # 获取该人设特征的分类信息
  288. # 需要在三个类型中查找该特征
  289. categories_html = ""
  290. if category_mapping and persona_name:
  291. found_categories = None
  292. # 依次在灵感点、关键点、目的点中查找
  293. for persona_type in ["灵感点", "关键点", "目的点"]:
  294. if persona_type in category_mapping:
  295. type_mapping = category_mapping[persona_type]
  296. if persona_name in type_mapping:
  297. found_categories = type_mapping[persona_name].get("所属分类", [])
  298. break
  299. if found_categories:
  300. # 简洁样式:[大类/中类/小类]
  301. categories_reversed = list(reversed(found_categories))
  302. categories_text = "/".join(categories_reversed)
  303. categories_html = f'<span class="category-simple">[{html_module.escape(categories_text)}]</span>'
  304. # 获取该人设特征的历史帖子来源
  305. historical_posts_html = ""
  306. if source_mapping and persona_name and persona_name in source_mapping:
  307. source_list = source_mapping[persona_name]
  308. if source_list:
  309. historical_cards = []
  310. for source_item in source_list:
  311. post_detail = source_item.get("帖子详情", {})
  312. if post_detail:
  313. card_html = generate_historical_post_card_html(post_detail, source_item)
  314. historical_cards.append(card_html)
  315. if historical_cards:
  316. historical_posts_html = f'''
  317. <div class="historical-posts-section">
  318. <h4 class="historical-posts-title">历史帖子来源</h4>
  319. <div class="historical-posts-grid">
  320. {"".join(historical_cards)}
  321. </div>
  322. </div>
  323. '''
  324. matches_html += f'''
  325. <div class="match-item-collapsible">
  326. <div class="match-header" onclick="toggleMatch('{match_id}')">
  327. <div class="match-header-left">
  328. <span class="expand-icon" id="{match_id}-icon">▶</span>
  329. <span class="persona-name">{categories_html} {html_module.escape(persona_name)}</span>
  330. <span class="relation-badge" style="background: {color};">{label}</span>
  331. <span class="score-badge">分数: {score:.2f}</span>
  332. </div>
  333. </div>
  334. <div class="match-content" id="{match_id}-content" style="display: none;">
  335. <div class="match-explanation">{html_module.escape(explanation)}</div>
  336. {historical_posts_html}
  337. </div>
  338. </div>
  339. '''
  340. section_id = f"post-{post_idx}-insp-{insp_idx}-feat-{feature_idx}-section"
  341. html = f'''
  342. <div class="match-results-section">
  343. <div class="match-section-header collapsible-header" onclick="toggleFeatureSection('{section_id}')">
  344. <div class="header-left">
  345. <span class="expand-icon" id="{section_id}-icon">▼</span>
  346. <h4>匹配结果: {html_module.escape(feature_name)}</h4>
  347. </div>
  348. <div class="match-stats">{stats_html}</div>
  349. </div>
  350. <div class="matches-list" id="{section_id}-content">
  351. {matches_html}
  352. </div>
  353. </div>
  354. '''
  355. return html
  356. def generate_toc_html(post_data: Dict, post_idx: int) -> str:
  357. """生成目录导航HTML"""
  358. how_result = post_data.get("how解构结果", {})
  359. inspiration_list = how_result.get("灵感点列表", [])
  360. toc_items = []
  361. # 帖子详情
  362. toc_items.append(f'<div class="toc-item toc-level-1" onclick="scrollToSection(\'post-{post_idx}-detail\')"><span class="toc-badge toc-badge-post">帖子详情</span> 帖子信息</div>')
  363. # 灵感点
  364. for insp_idx, inspiration_point in enumerate(inspiration_list):
  365. name = inspiration_point.get("名称", f"灵感点 {insp_idx + 1}")
  366. name_short = name[:18] + "..." if len(name) > 18 else name
  367. toc_items.append(f'<div class="toc-item toc-level-1" onclick="scrollToSection(\'post-{post_idx}-insp-{insp_idx}\')"><span class="toc-badge toc-badge-inspiration">灵感点</span> {html_module.escape(name_short)}</div>')
  368. # 特征列表
  369. how_steps = inspiration_point.get("how步骤列表", [])
  370. if how_steps:
  371. features = how_steps[0].get("特征列表", [])
  372. for feat_idx, feature_data in enumerate(features):
  373. feature_name = feature_data.get("特征名称", f"特征 {feat_idx + 1}")
  374. toc_items.append(f'<div class="toc-item toc-level-2" onclick="scrollToSection(\'post-{post_idx}-feat-{insp_idx}-{feat_idx}\')"><span class="toc-badge toc-badge-feature">特征</span> {html_module.escape(feature_name)}</div>')
  375. return f'''
  376. <div class="toc-container">
  377. <div class="toc-header">目录导航</div>
  378. <div class="toc-content">
  379. {"".join(toc_items)}
  380. </div>
  381. </div>
  382. '''
  383. def generate_post_content_html(post_data: Dict, post_idx: int, category_mapping: Dict = None, source_mapping: Dict = None) -> str:
  384. """生成单个帖子的完整内容HTML"""
  385. # 生成目录
  386. toc_html = generate_toc_html(post_data, post_idx)
  387. # 1. 帖子详情
  388. post_detail_html = generate_post_detail_html(post_data, post_idx)
  389. # 2. 灵感点详情和匹配结果
  390. how_result = post_data.get("how解构结果", {})
  391. inspiration_list = how_result.get("灵感点列表", [])
  392. # 生成所有灵感点的详情HTML(只包含灵感点详情,不包含匹配结果)
  393. inspirations_detail_html = ""
  394. for insp_idx, inspiration_point in enumerate(inspiration_list):
  395. inspiration_detail = generate_inspiration_detail_html(inspiration_point)
  396. inspirations_detail_html += f'''
  397. <div id="post-{post_idx}-insp-{insp_idx}" class="inspiration-detail-item content-section">
  398. {inspiration_detail}
  399. </div>
  400. '''
  401. # 生成所有匹配结果HTML,按照how步骤分组
  402. all_matches_html = ""
  403. for insp_idx, inspiration_point in enumerate(inspiration_list):
  404. inspiration_name = inspiration_point.get("名称", f"灵感点 {insp_idx + 1}")
  405. how_steps = inspiration_point.get("how步骤列表", [])
  406. if how_steps:
  407. # 为每个灵感点创建一个区域
  408. for step_idx, step in enumerate(how_steps):
  409. step_name = step.get("步骤名称", f"步骤 {step_idx + 1}")
  410. features = step.get("特征列表", [])
  411. # 生成该步骤下所有特征的匹配结果
  412. features_html = ""
  413. for feat_idx, feature_data in enumerate(features):
  414. match_html = generate_match_results_html([step], feat_idx, insp_idx, post_idx, category_mapping, source_mapping)
  415. features_html += f'<div id="post-{post_idx}-feat-{insp_idx}-{feat_idx}" class="feature-match-wrapper">{match_html}</div>'
  416. # 生成步骤区域(可折叠)
  417. step_section_id = f"post-{post_idx}-step-{insp_idx}-{step_idx}"
  418. all_matches_html += f'''
  419. <div class="step-section">
  420. <div class="step-header collapsible-header" onclick="toggleStepSection('{step_section_id}')">
  421. <div class="header-left">
  422. <span class="expand-icon" id="{step_section_id}-icon">▼</span>
  423. <h3 class="step-name">{html_module.escape(step_name)}</h3>
  424. </div>
  425. <span class="step-inspiration-name">来自: {html_module.escape(inspiration_name)}</span>
  426. </div>
  427. <div class="step-features-list" id="{step_section_id}-content">
  428. {features_html}
  429. </div>
  430. </div>
  431. '''
  432. html = f'''
  433. <div class="content-with-toc">
  434. {toc_html}
  435. <div class="main-content">
  436. <!-- 第一个框:左右分栏(帖子详情 + 灵感点详情) -->
  437. <div class="top-section-box">
  438. <div class="two-column-layout">
  439. <div class="left-column">
  440. <div id="post-{post_idx}-detail" class="post-detail-wrapper">
  441. {post_detail_html}
  442. </div>
  443. </div>
  444. <div class="right-column">
  445. <div class="inspirations-detail-wrapper">
  446. {inspirations_detail_html}
  447. </div>
  448. </div>
  449. </div>
  450. </div>
  451. <!-- 下面:所有匹配结果 -->
  452. <div class="matches-section">
  453. {all_matches_html}
  454. </div>
  455. </div>
  456. </div>
  457. '''
  458. return html
  459. def generate_combined_html(posts_data: List[Dict], category_mapping: Dict = None, source_mapping: Dict = None) -> str:
  460. """生成包含所有帖子的单一HTML(带标签页)"""
  461. # 生成标签页按钮
  462. tabs_html = ""
  463. for i, post in enumerate(posts_data):
  464. post_detail = post.get("帖子详情", {})
  465. title = post_detail.get("title", "无标题")
  466. active_class = "active" if i == 0 else ""
  467. tabs_html += f'<button class="tab-button {active_class}" onclick="openTab(event, \'post-{i}\')">{html_module.escape(title)}</button>\n'
  468. # 生成标签页内容
  469. contents_html = ""
  470. for i, post in enumerate(posts_data):
  471. active_class = "active" if i == 0 else ""
  472. content = generate_post_content_html(post, i, category_mapping, source_mapping)
  473. contents_html += f'''
  474. <div id="post-{i}" class="tab-content {active_class}">
  475. {content}
  476. </div>
  477. '''
  478. html = f'''
  479. <!DOCTYPE html>
  480. <html lang="zh-CN">
  481. <head>
  482. <meta charset="UTF-8">
  483. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  484. <title>How解构结果可视化</title>
  485. <style>
  486. * {{
  487. margin: 0;
  488. padding: 0;
  489. box-sizing: border-box;
  490. }}
  491. body {{
  492. font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
  493. background: #f5f5f5;
  494. color: #333;
  495. line-height: 1.6;
  496. }}
  497. .container {{
  498. max-width: 1600px;
  499. margin: 0 auto;
  500. background: white;
  501. min-height: 100vh;
  502. }}
  503. .header {{
  504. background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  505. color: white;
  506. padding: 30px;
  507. text-align: center;
  508. }}
  509. .header h1 {{
  510. font-size: 32px;
  511. font-weight: bold;
  512. margin-bottom: 10px;
  513. }}
  514. .header p {{
  515. font-size: 16px;
  516. opacity: 0.9;
  517. }}
  518. /* 标签页样式 */
  519. .tabs-container {{
  520. display: flex;
  521. background: #f9fafb;
  522. border-bottom: 2px solid #e5e7eb;
  523. overflow-x: auto;
  524. position: sticky;
  525. top: 0;
  526. z-index: 100;
  527. box-shadow: 0 2px 4px rgba(0,0,0,0.05);
  528. }}
  529. .tab-button {{
  530. flex: 1;
  531. min-width: 200px;
  532. padding: 18px 30px;
  533. background: transparent;
  534. border: none;
  535. border-bottom: 3px solid transparent;
  536. cursor: pointer;
  537. font-size: 15px;
  538. font-weight: 500;
  539. color: #6b7280;
  540. transition: all 0.3s;
  541. white-space: nowrap;
  542. overflow: hidden;
  543. text-overflow: ellipsis;
  544. }}
  545. .tab-button:hover {{
  546. background: #f3f4f6;
  547. color: #374151;
  548. }}
  549. .tab-button.active {{
  550. color: #667eea;
  551. border-bottom-color: #667eea;
  552. background: white;
  553. }}
  554. .tab-content {{
  555. display: none;
  556. padding: 30px;
  557. }}
  558. .tab-content.active {{
  559. display: block;
  560. animation: fadeIn 0.3s;
  561. }}
  562. @keyframes fadeIn {{
  563. from {{ opacity: 0; transform: translateY(10px); }}
  564. to {{ opacity: 1; transform: translateY(0); }}
  565. }}
  566. /* 目录和主内容布局 */
  567. .content-with-toc {{
  568. display: grid;
  569. grid-template-columns: 280px 1fr;
  570. gap: 30px;
  571. align-items: start;
  572. }}
  573. .toc-container {{
  574. position: sticky;
  575. top: 80px;
  576. background: white;
  577. border: 1px solid #e5e7eb;
  578. border-radius: 12px;
  579. overflow: hidden;
  580. max-height: calc(100vh - 100px);
  581. display: flex;
  582. flex-direction: column;
  583. }}
  584. .toc-header {{
  585. padding: 15px 20px;
  586. background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  587. color: white;
  588. font-weight: 600;
  589. font-size: 16px;
  590. }}
  591. .toc-content {{
  592. padding: 10px;
  593. overflow-y: auto;
  594. flex: 1;
  595. }}
  596. .toc-item {{
  597. padding: 10px 15px;
  598. margin: 4px 0;
  599. cursor: pointer;
  600. border-radius: 6px;
  601. font-size: 14px;
  602. transition: all 0.2s;
  603. user-select: none;
  604. }}
  605. .toc-item:hover {{
  606. background: #f3f4f6;
  607. }}
  608. .toc-item.active {{
  609. background: #e0e7ff;
  610. color: #667eea;
  611. font-weight: 600;
  612. }}
  613. .toc-level-1 {{
  614. color: #111827;
  615. font-weight: 500;
  616. }}
  617. .toc-level-2 {{
  618. color: #6b7280;
  619. font-size: 13px;
  620. padding-left: 30px;
  621. }}
  622. .toc-badge {{
  623. display: inline-block;
  624. padding: 2px 8px;
  625. border-radius: 10px;
  626. font-size: 11px;
  627. font-weight: 600;
  628. margin-right: 6px;
  629. }}
  630. .toc-badge-post {{
  631. background: #dbeafe;
  632. color: #1e40af;
  633. }}
  634. .toc-badge-inspiration {{
  635. background: #fef3c7;
  636. color: #92400e;
  637. }}
  638. .toc-badge-feature {{
  639. background: #e0e7ff;
  640. color: #3730a3;
  641. }}
  642. .main-content {{
  643. min-width: 0;
  644. display: flex;
  645. flex-direction: column;
  646. gap: 30px;
  647. }}
  648. /* 顶部框:包含帖子详情和灵感点详情 */
  649. .top-section-box {{
  650. background: white;
  651. border: 1px solid #e5e7eb;
  652. border-radius: 12px;
  653. padding: 25px;
  654. box-shadow: 0 2px 8px rgba(0,0,0,0.05);
  655. }}
  656. /* 顶部框内的两栏布局 */
  657. .two-column-layout {{
  658. display: grid;
  659. grid-template-columns: 380px 1fr;
  660. gap: 25px;
  661. align-items: start;
  662. }}
  663. .left-column {{
  664. position: relative;
  665. }}
  666. .post-detail-wrapper {{
  667. /* 帖子详情区域 */
  668. }}
  669. .right-column {{
  670. min-width: 0;
  671. }}
  672. .inspirations-detail-wrapper {{
  673. display: flex;
  674. flex-direction: column;
  675. gap: 20px;
  676. max-height: 600px;
  677. overflow-y: auto;
  678. padding-right: 10px;
  679. }}
  680. .inspiration-detail-item {{
  681. /* 单个灵感点详情 */
  682. }}
  683. /* 匹配结果区域 */
  684. .matches-section {{
  685. display: flex;
  686. flex-direction: column;
  687. gap: 20px;
  688. }}
  689. /* 步骤区域 */
  690. .step-section {{
  691. background: white;
  692. border: 1px solid #e5e7eb;
  693. border-radius: 12px;
  694. overflow: hidden;
  695. box-shadow: 0 2px 8px rgba(0,0,0,0.05);
  696. }}
  697. .step-header {{
  698. padding: 20px 25px;
  699. background: linear-gradient(135deg, #f9fafb 0%, #ffffff 100%);
  700. border-bottom: 2px solid #e5e7eb;
  701. display: flex;
  702. justify-content: space-between;
  703. align-items: center;
  704. }}
  705. .step-name {{
  706. font-size: 18px;
  707. color: #111827;
  708. font-weight: 600;
  709. margin: 0;
  710. }}
  711. .step-inspiration-name {{
  712. font-size: 14px;
  713. color: #6b7280;
  714. font-style: italic;
  715. }}
  716. .step-features-list {{
  717. padding: 20px;
  718. display: flex;
  719. flex-direction: column;
  720. gap: 15px;
  721. }}
  722. .feature-match-wrapper {{
  723. /* 特征匹配容器 */
  724. }}
  725. .content-section {{
  726. scroll-margin-top: 80px;
  727. }}
  728. /* 帖子详情卡片(紧凑样式) */
  729. .post-card-compact {{
  730. display: grid;
  731. grid-template-columns: 200px 1fr;
  732. gap: 20px;
  733. background: white;
  734. border: 1px solid #e5e7eb;
  735. border-radius: 12px;
  736. overflow: hidden;
  737. padding: 20px;
  738. cursor: pointer;
  739. transition: all 0.3s;
  740. margin-bottom: 30px;
  741. }}
  742. .post-card-compact:hover {{
  743. box-shadow: 0 4px 12px rgba(0,0,0,0.1);
  744. transform: translateY(-2px);
  745. border-color: #667eea;
  746. }}
  747. .post-card-image {{
  748. position: relative;
  749. display: flex;
  750. align-items: center;
  751. justify-content: center;
  752. }}
  753. .post-card-thumbnail {{
  754. width: 100%;
  755. height: 150px;
  756. object-fit: contain;
  757. border-radius: 8px;
  758. background: #f9fafb;
  759. }}
  760. .post-card-thumbnail-placeholder {{
  761. width: 100%;
  762. height: 150px;
  763. display: flex;
  764. align-items: center;
  765. justify-content: center;
  766. background: #f3f4f6;
  767. color: #9ca3af;
  768. font-size: 48px;
  769. border-radius: 8px;
  770. }}
  771. .post-card-image-count {{
  772. position: absolute;
  773. bottom: 8px;
  774. right: 8px;
  775. background: rgba(0, 0, 0, 0.7);
  776. color: white;
  777. padding: 4px 8px;
  778. border-radius: 12px;
  779. font-size: 11px;
  780. font-weight: 600;
  781. }}
  782. .post-card-content {{
  783. display: flex;
  784. flex-direction: column;
  785. gap: 12px;
  786. }}
  787. .post-card-title {{
  788. font-size: 18px;
  789. font-weight: 600;
  790. color: #111827;
  791. line-height: 1.4;
  792. overflow: hidden;
  793. text-overflow: ellipsis;
  794. display: -webkit-box;
  795. -webkit-line-clamp: 2;
  796. -webkit-box-orient: vertical;
  797. }}
  798. .post-card-preview {{
  799. font-size: 14px;
  800. color: #6b7280;
  801. line-height: 1.6;
  802. overflow: hidden;
  803. text-overflow: ellipsis;
  804. display: -webkit-box;
  805. -webkit-line-clamp: 2;
  806. -webkit-box-orient: vertical;
  807. }}
  808. .post-card-meta {{
  809. display: flex;
  810. gap: 15px;
  811. font-size: 13px;
  812. color: #9ca3af;
  813. }}
  814. .post-card-stats {{
  815. display: flex;
  816. gap: 15px;
  817. font-size: 13px;
  818. color: #6b7280;
  819. font-weight: 500;
  820. }}
  821. /* 灵感点详情卡片样式保持不变 */
  822. .inspiration-detail-card {{
  823. background: linear-gradient(135deg, #f9fafb 0%, #ffffff 100%);
  824. padding: 25px;
  825. border-bottom: 2px solid #e5e7eb;
  826. }}
  827. .inspiration-header {{
  828. margin-bottom: 15px;
  829. display: flex;
  830. align-items: center;
  831. gap: 10px;
  832. }}
  833. .inspiration-type-badge {{
  834. display: inline-block;
  835. padding: 4px 12px;
  836. background: #fef3c7;
  837. color: #92400e;
  838. border-radius: 12px;
  839. font-size: 12px;
  840. font-weight: 600;
  841. }}
  842. .inspiration-name {{
  843. font-size: 20px;
  844. color: #111827;
  845. font-weight: 600;
  846. margin: 0;
  847. }}
  848. .inspiration-description {{
  849. margin-bottom: 15px;
  850. }}
  851. .desc-label {{
  852. font-weight: 600;
  853. color: #6b7280;
  854. font-size: 13px;
  855. margin-bottom: 8px;
  856. }}
  857. .desc-text {{
  858. color: #4b5563;
  859. font-size: 14px;
  860. line-height: 1.7;
  861. }}
  862. .inspiration-features {{
  863. margin-top: 15px;
  864. }}
  865. .features-label {{
  866. font-weight: 600;
  867. color: #6b7280;
  868. font-size: 13px;
  869. margin-bottom: 8px;
  870. }}
  871. .features-tags {{
  872. display: flex;
  873. flex-wrap: wrap;
  874. gap: 8px;
  875. }}
  876. .feature-tag {{
  877. padding: 5px 12px;
  878. background: #667eea;
  879. color: white;
  880. border-radius: 16px;
  881. font-size: 13px;
  882. font-weight: 500;
  883. }}
  884. /* 匹配结果部分 */
  885. .match-results-section {{
  886. padding: 25px;
  887. border-bottom: 1px solid #e5e7eb;
  888. }}
  889. .match-results-section:last-child {{
  890. border-bottom: none;
  891. }}
  892. .match-section-header {{
  893. display: flex;
  894. justify-content: space-between;
  895. align-items: center;
  896. margin-bottom: 20px;
  897. padding-bottom: 15px;
  898. border-bottom: 2px solid #e5e7eb;
  899. }}
  900. .collapsible-header {{
  901. cursor: pointer;
  902. user-select: none;
  903. transition: background 0.2s;
  904. padding: 10px;
  905. margin: -10px;
  906. border-radius: 6px;
  907. }}
  908. .collapsible-header:hover {{
  909. background: #f9fafb;
  910. }}
  911. .header-left {{
  912. display: flex;
  913. align-items: center;
  914. gap: 10px;
  915. }}
  916. .match-section-header h4 {{
  917. font-size: 18px;
  918. color: #111827;
  919. margin: 0;
  920. }}
  921. .match-stats {{
  922. display: flex;
  923. gap: 10px;
  924. flex-wrap: wrap;
  925. }}
  926. .stat-badge {{
  927. padding: 4px 10px;
  928. border-radius: 12px;
  929. color: white;
  930. font-size: 12px;
  931. font-weight: 600;
  932. }}
  933. .matches-list {{
  934. display: flex;
  935. flex-direction: column;
  936. gap: 8px;
  937. }}
  938. .match-item-collapsible {{
  939. border: 1px solid #e5e7eb;
  940. border-radius: 8px;
  941. overflow: hidden;
  942. background: white;
  943. transition: box-shadow 0.2s;
  944. }}
  945. .match-item-collapsible:hover {{
  946. box-shadow: 0 2px 8px rgba(0,0,0,0.08);
  947. }}
  948. .match-header {{
  949. padding: 12px 16px;
  950. cursor: pointer;
  951. user-select: none;
  952. transition: background 0.2s;
  953. }}
  954. .match-header:hover {{
  955. background: #f9fafb;
  956. }}
  957. .match-header-left {{
  958. display: flex;
  959. align-items: center;
  960. gap: 10px;
  961. }}
  962. .expand-icon {{
  963. color: #9ca3af;
  964. font-size: 12px;
  965. transition: transform 0.2s;
  966. }}
  967. .expand-icon.expanded {{
  968. transform: rotate(90deg);
  969. }}
  970. .persona-name {{
  971. font-weight: 600;
  972. font-size: 14px;
  973. color: #111827;
  974. }}
  975. .relation-badge {{
  976. padding: 3px 10px;
  977. border-radius: 12px;
  978. color: white;
  979. font-size: 11px;
  980. font-weight: 600;
  981. }}
  982. .score-badge {{
  983. padding: 3px 10px;
  984. border-radius: 12px;
  985. background: #e5e7eb;
  986. color: #374151;
  987. font-size: 11px;
  988. font-weight: 600;
  989. }}
  990. .match-content {{
  991. padding: 16px;
  992. background: #f9fafb;
  993. border-top: 1px solid #e5e7eb;
  994. }}
  995. .match-explanation {{
  996. font-size: 13px;
  997. color: #4b5563;
  998. line-height: 1.7;
  999. margin-bottom: 16px;
  1000. }}
  1001. /* 历史帖子来源区域 */
  1002. .historical-posts-section {{
  1003. margin-top: 20px;
  1004. padding-top: 20px;
  1005. border-top: 2px solid #e5e7eb;
  1006. }}
  1007. .historical-posts-title {{
  1008. font-size: 14px;
  1009. font-weight: 600;
  1010. color: #374151;
  1011. margin-bottom: 12px;
  1012. }}
  1013. .historical-posts-grid {{
  1014. display: grid;
  1015. grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
  1016. gap: 16px;
  1017. }}
  1018. .historical-post-card {{
  1019. display: grid;
  1020. grid-template-columns: 120px 1fr;
  1021. gap: 12px;
  1022. background: white;
  1023. border: 1px solid #e5e7eb;
  1024. border-radius: 8px;
  1025. padding: 12px;
  1026. transition: all 0.2s;
  1027. cursor: pointer;
  1028. }}
  1029. .historical-post-card:hover {{
  1030. border-color: #667eea;
  1031. box-shadow: 0 2px 8px rgba(102, 126, 234, 0.1);
  1032. transform: translateY(-2px);
  1033. }}
  1034. .historical-post-image {{
  1035. position: relative;
  1036. }}
  1037. .historical-post-thumbnail {{
  1038. width: 100%;
  1039. height: 100px;
  1040. object-fit: contain;
  1041. border-radius: 6px;
  1042. background: #f9fafb;
  1043. }}
  1044. .historical-post-content {{
  1045. display: flex;
  1046. flex-direction: column;
  1047. gap: 8px;
  1048. }}
  1049. .historical-post-title {{
  1050. font-size: 13px;
  1051. font-weight: 600;
  1052. color: #1f2937;
  1053. line-height: 1.4;
  1054. overflow: hidden;
  1055. display: -webkit-box;
  1056. -webkit-line-clamp: 2;
  1057. -webkit-box-orient: vertical;
  1058. }}
  1059. .historical-inspiration-info {{
  1060. display: flex;
  1061. align-items: center;
  1062. gap: 6px;
  1063. }}
  1064. .inspiration-type-badge-small {{
  1065. padding: 2px 6px;
  1066. border-radius: 4px;
  1067. background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
  1068. color: white;
  1069. font-size: 10px;
  1070. font-weight: 600;
  1071. }}
  1072. .inspiration-name-small {{
  1073. font-size: 11px;
  1074. color: #667eea;
  1075. font-weight: 500;
  1076. }}
  1077. .historical-inspiration-desc {{
  1078. font-size: 11px;
  1079. color: #6b7280;
  1080. line-height: 1.5;
  1081. overflow: hidden;
  1082. display: -webkit-box;
  1083. -webkit-line-clamp: 2;
  1084. -webkit-box-orient: vertical;
  1085. }}
  1086. .historical-post-meta {{
  1087. margin: 4px 0;
  1088. }}
  1089. .historical-post-time {{
  1090. font-size: 11px;
  1091. color: #9ca3af;
  1092. }}
  1093. .historical-post-stats {{
  1094. display: flex;
  1095. align-items: center;
  1096. gap: 10px;
  1097. font-size: 11px;
  1098. color: #9ca3af;
  1099. }}
  1100. .view-link {{
  1101. margin-left: auto;
  1102. color: #667eea;
  1103. text-decoration: none;
  1104. font-weight: 500;
  1105. }}
  1106. .view-link:hover {{
  1107. color: #764ba2;
  1108. }}
  1109. .category-simple {{
  1110. color: #9ca3af;
  1111. font-size: 12px;
  1112. font-weight: 400;
  1113. margin-right: 6px;
  1114. }}
  1115. /* 帖子详情模态框 */
  1116. .post-detail-modal {{
  1117. display: none;
  1118. position: fixed;
  1119. z-index: 1000;
  1120. left: 0;
  1121. top: 0;
  1122. width: 100%;
  1123. height: 100%;
  1124. background: rgba(0, 0, 0, 0.7);
  1125. overflow: auto;
  1126. animation: fadeIn 0.3s;
  1127. }}
  1128. .post-detail-modal.active {{
  1129. display: flex;
  1130. align-items: center;
  1131. justify-content: center;
  1132. padding: 40px 20px;
  1133. }}
  1134. .post-detail-content {{
  1135. position: relative;
  1136. background: white;
  1137. border-radius: 16px;
  1138. max-width: 900px;
  1139. width: 100%;
  1140. max-height: 90vh;
  1141. overflow-y: auto;
  1142. box-shadow: 0 20px 60px rgba(0,0,0,0.3);
  1143. }}
  1144. .post-detail-close {{
  1145. position: sticky;
  1146. top: 20px;
  1147. right: 20px;
  1148. float: right;
  1149. font-size: 36px;
  1150. font-weight: 300;
  1151. color: #9ca3af;
  1152. background: white;
  1153. border: none;
  1154. cursor: pointer;
  1155. width: 40px;
  1156. height: 40px;
  1157. border-radius: 50%;
  1158. display: flex;
  1159. align-items: center;
  1160. justify-content: center;
  1161. transition: all 0.2s;
  1162. z-index: 10;
  1163. box-shadow: 0 2px 8px rgba(0,0,0,0.1);
  1164. }}
  1165. .post-detail-close:hover {{
  1166. color: #ef4444;
  1167. background: #fee2e2;
  1168. transform: rotate(90deg);
  1169. }}
  1170. .post-detail-header {{
  1171. padding: 40px 40px 20px 40px;
  1172. border-bottom: 2px solid #e5e7eb;
  1173. }}
  1174. .post-detail-title {{
  1175. font-size: 28px;
  1176. font-weight: bold;
  1177. color: #111827;
  1178. line-height: 1.4;
  1179. margin-bottom: 15px;
  1180. }}
  1181. .post-detail-meta {{
  1182. display: flex;
  1183. justify-content: space-between;
  1184. align-items: center;
  1185. color: #6b7280;
  1186. font-size: 14px;
  1187. gap: 20px;
  1188. }}
  1189. .post-detail-author {{
  1190. font-weight: 500;
  1191. }}
  1192. .post-detail-time {{
  1193. color: #9ca3af;
  1194. }}
  1195. .post-detail-stats {{
  1196. display: flex;
  1197. gap: 15px;
  1198. font-weight: 500;
  1199. }}
  1200. .post-detail-body {{
  1201. padding: 30px 40px;
  1202. }}
  1203. .post-detail-desc {{
  1204. font-size: 15px;
  1205. color: #4b5563;
  1206. line-height: 1.8;
  1207. margin-bottom: 25px;
  1208. white-space: pre-wrap;
  1209. }}
  1210. .post-detail-images {{
  1211. display: grid;
  1212. grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
  1213. gap: 15px;
  1214. margin-top: 20px;
  1215. }}
  1216. .post-detail-image {{
  1217. width: 100%;
  1218. border-radius: 8px;
  1219. object-fit: cover;
  1220. cursor: pointer;
  1221. transition: transform 0.2s;
  1222. }}
  1223. .post-detail-image:hover {{
  1224. transform: scale(1.02);
  1225. }}
  1226. .post-detail-footer {{
  1227. padding: 20px 40px 30px 40px;
  1228. border-top: 1px solid #e5e7eb;
  1229. text-align: center;
  1230. }}
  1231. .post-detail-link {{
  1232. display: inline-block;
  1233. padding: 12px 30px;
  1234. background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  1235. color: white;
  1236. text-decoration: none;
  1237. border-radius: 8px;
  1238. font-weight: 500;
  1239. transition: all 0.3s;
  1240. }}
  1241. .post-detail-link:hover {{
  1242. transform: translateY(-2px);
  1243. box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
  1244. }}
  1245. /* 响应式 */
  1246. @media (max-width: 1400px) {{
  1247. .content-with-toc {{
  1248. grid-template-columns: 240px 1fr;
  1249. }}
  1250. .two-column-layout {{
  1251. grid-template-columns: 320px 1fr;
  1252. }}
  1253. }}
  1254. @media (max-width: 1200px) {{
  1255. .content-with-toc {{
  1256. grid-template-columns: 200px 1fr;
  1257. }}
  1258. .two-column-layout {{
  1259. grid-template-columns: 280px 1fr;
  1260. }}
  1261. }}
  1262. @media (max-width: 1024px) {{
  1263. .content-with-toc {{
  1264. grid-template-columns: 1fr;
  1265. }}
  1266. .toc-container {{
  1267. position: relative;
  1268. top: 0;
  1269. max-height: 300px;
  1270. }}
  1271. .two-column-layout {{
  1272. grid-template-columns: 1fr;
  1273. }}
  1274. .inspirations-detail-wrapper {{
  1275. max-height: none;
  1276. }}
  1277. .post-card-compact {{
  1278. grid-template-columns: 150px 1fr;
  1279. }}
  1280. }}
  1281. @media (max-width: 768px) {{
  1282. .header {{
  1283. padding: 20px;
  1284. }}
  1285. .header h1 {{
  1286. font-size: 24px;
  1287. }}
  1288. .tab-button {{
  1289. min-width: 150px;
  1290. padding: 15px 20px;
  1291. }}
  1292. .tab-content {{
  1293. padding: 15px;
  1294. }}
  1295. .post-info-section {{
  1296. padding: 20px;
  1297. }}
  1298. }}
  1299. </style>
  1300. </head>
  1301. <body>
  1302. <div class="container">
  1303. <div class="header">
  1304. <h1>How 解构结果可视化</h1>
  1305. <p>灵感点特征匹配分析</p>
  1306. </div>
  1307. <div class="tabs-container">
  1308. {tabs_html}
  1309. </div>
  1310. {contents_html}
  1311. </div>
  1312. <!-- 帖子详情模态框 -->
  1313. <div id="postDetailModal" class="post-detail-modal" onclick="closePostDetail(event)"></div>
  1314. <script>
  1315. function openTab(evt, tabId) {{
  1316. var tabContents = document.getElementsByClassName("tab-content");
  1317. for (var i = 0; i < tabContents.length; i++) {{
  1318. tabContents[i].classList.remove("active");
  1319. }}
  1320. var tabButtons = document.getElementsByClassName("tab-button");
  1321. for (var i = 0; i < tabButtons.length; i++) {{
  1322. tabButtons[i].classList.remove("active");
  1323. }}
  1324. document.getElementById(tabId).classList.add("active");
  1325. evt.currentTarget.classList.add("active");
  1326. }}
  1327. function scrollToSection(sectionId) {{
  1328. var element = document.getElementById(sectionId);
  1329. if (element) {{
  1330. element.scrollIntoView({{ behavior: 'smooth', block: 'start' }});
  1331. // 更新目录项的active状态
  1332. var tocItems = document.querySelectorAll('.toc-item');
  1333. tocItems.forEach(function(item) {{
  1334. item.classList.remove('active');
  1335. }});
  1336. event.currentTarget.classList.add('active');
  1337. }}
  1338. }}
  1339. function toggleMatch(matchId) {{
  1340. var content = document.getElementById(matchId + '-content');
  1341. var icon = document.getElementById(matchId + '-icon');
  1342. if (content.style.display === 'none') {{
  1343. content.style.display = 'block';
  1344. icon.classList.add('expanded');
  1345. }} else {{
  1346. content.style.display = 'none';
  1347. icon.classList.remove('expanded');
  1348. }}
  1349. }}
  1350. function toggleFeatureSection(sectionId) {{
  1351. var content = document.getElementById(sectionId + '-content');
  1352. var icon = document.getElementById(sectionId + '-icon');
  1353. if (content.style.display === 'none') {{
  1354. content.style.display = 'flex';
  1355. icon.textContent = '▼';
  1356. }} else {{
  1357. content.style.display = 'none';
  1358. icon.textContent = '▶';
  1359. }}
  1360. }}
  1361. function toggleStepSection(sectionId) {{
  1362. var content = document.getElementById(sectionId + '-content');
  1363. var icon = document.getElementById(sectionId + '-icon');
  1364. if (content.style.display === 'none') {{
  1365. content.style.display = 'flex';
  1366. icon.textContent = '▼';
  1367. }} else {{
  1368. content.style.display = 'none';
  1369. icon.textContent = '▶';
  1370. }}
  1371. }}
  1372. function showPostDetail(element) {{
  1373. const postDataStr = element.dataset.postData;
  1374. if (!postDataStr) return;
  1375. try {{
  1376. const postData = JSON.parse(postDataStr);
  1377. // 生成图片HTML
  1378. let imagesHtml = '';
  1379. if (postData.images && postData.images.length > 0) {{
  1380. imagesHtml = postData.images.map(img =>
  1381. `<img src="${{img}}" class="post-detail-image" alt="图片">`
  1382. ).join('');
  1383. }} else {{
  1384. imagesHtml = '<div style="text-align: center; color: #9ca3af; padding: 40px;">暂无图片</div>';
  1385. }}
  1386. const modalHtml = `
  1387. <div class="post-detail-content" onclick="event.stopPropagation()">
  1388. <button class="post-detail-close" onclick="closePostDetail()">×</button>
  1389. <div class="post-detail-header">
  1390. <div class="post-detail-title">${{postData.title || '无标题'}}</div>
  1391. <div class="post-detail-meta">
  1392. <div>
  1393. <span class="post-detail-author">👤 ${{postData.author}}</span>
  1394. <span class="post-detail-time"> · 📅 ${{postData.publish_time}}</span>
  1395. </div>
  1396. <div class="post-detail-stats">
  1397. <span>👍 ${{postData.like_count}}</span>
  1398. <span>💬 ${{postData.comment_count}}</span>
  1399. <span>⭐ ${{postData.collect_count}}</span>
  1400. </div>
  1401. </div>
  1402. </div>
  1403. <div class="post-detail-body">
  1404. ${{postData.body_text ? `<div class="post-detail-desc">${{postData.body_text}}</div>` : '<div style="color: #9ca3af;">暂无正文</div>'}}
  1405. <div class="post-detail-images">
  1406. ${{imagesHtml}}
  1407. </div>
  1408. </div>
  1409. <div class="post-detail-footer">
  1410. <a href="${{postData.link}}" target="_blank" class="post-detail-link">
  1411. 在小红书查看完整内容 →
  1412. </a>
  1413. </div>
  1414. </div>
  1415. `;
  1416. let modal = document.getElementById('postDetailModal');
  1417. if (!modal) {{
  1418. modal = document.createElement('div');
  1419. modal.id = 'postDetailModal';
  1420. modal.className = 'post-detail-modal';
  1421. modal.onclick = closePostDetail;
  1422. document.body.appendChild(modal);
  1423. }}
  1424. modal.innerHTML = modalHtml;
  1425. modal.classList.add('active');
  1426. document.body.style.overflow = 'hidden';
  1427. }} catch (e) {{
  1428. console.error('解析帖子数据失败:', e);
  1429. }}
  1430. }}
  1431. function closePostDetail(event) {{
  1432. if (event && event.target !== event.currentTarget) return;
  1433. const modal = document.getElementById('postDetailModal');
  1434. if (modal) {{
  1435. modal.classList.remove('active');
  1436. document.body.style.overflow = '';
  1437. }}
  1438. }}
  1439. function moveImage(postId, direction) {{
  1440. var carousel = document.querySelector(`[data-post-id="${{postId}}"]`);
  1441. var track = document.getElementById(postId + '-track');
  1442. var totalImages = parseInt(carousel.getAttribute('data-total-images'));
  1443. if (!carousel.currentIndex) {{
  1444. carousel.currentIndex = 0;
  1445. }}
  1446. carousel.currentIndex = (carousel.currentIndex + direction + totalImages) % totalImages;
  1447. track.style.transform = `translateX(-${{carousel.currentIndex * 100}}%)`;
  1448. updateIndicators(postId, carousel.currentIndex);
  1449. }}
  1450. function jumpToImage(postId, index) {{
  1451. var carousel = document.querySelector(`[data-post-id="${{postId}}"]`);
  1452. var track = document.getElementById(postId + '-track');
  1453. carousel.currentIndex = index;
  1454. track.style.transform = `translateX(-${{index * 100}}%)`;
  1455. updateIndicators(postId, index);
  1456. }}
  1457. function updateIndicators(postId, activeIndex) {{
  1458. var indicators = document.querySelectorAll(`#${{postId}}-indicators .indicator`);
  1459. indicators.forEach((indicator, i) => {{
  1460. if (i === activeIndex) {{
  1461. indicator.classList.add('active');
  1462. }} else {{
  1463. indicator.classList.remove('active');
  1464. }}
  1465. }});
  1466. }}
  1467. // 目录激活状态追踪
  1468. function updateTocActiveState() {{
  1469. const mainContent = document.querySelector('.main-content');
  1470. if (!mainContent) return;
  1471. const sections = document.querySelectorAll('.post-detail-wrapper, .inspiration-detail-card, .step-section');
  1472. const tocItems = document.querySelectorAll('.toc-item');
  1473. let currentActive = null;
  1474. const scrollTop = mainContent.scrollTop;
  1475. const windowHeight = window.innerHeight;
  1476. // 找到当前在视口中的section
  1477. sections.forEach(section => {{
  1478. const rect = section.getBoundingClientRect();
  1479. const sectionTop = rect.top;
  1480. const sectionBottom = rect.bottom;
  1481. // 如果section的顶部在视口上半部分,认为它是当前激活的
  1482. if (sectionTop < windowHeight / 2 && sectionBottom > 0) {{
  1483. currentActive = section.id;
  1484. }}
  1485. }});
  1486. // 更新目录项的激活状态
  1487. tocItems.forEach(item => {{
  1488. const targetId = item.getAttribute('onclick')?.match(/'([^']+)'/)?.[1];
  1489. if (targetId === currentActive) {{
  1490. item.classList.add('active');
  1491. }} else {{
  1492. item.classList.remove('active');
  1493. }}
  1494. }});
  1495. }}
  1496. // 监听滚动事件
  1497. document.addEventListener('DOMContentLoaded', function() {{
  1498. const mainContent = document.querySelector('.main-content');
  1499. if (mainContent) {{
  1500. mainContent.addEventListener('scroll', function() {{
  1501. // 使用节流避免频繁触发
  1502. if (this.scrollTimeout) clearTimeout(this.scrollTimeout);
  1503. this.scrollTimeout = setTimeout(updateTocActiveState, 50);
  1504. }});
  1505. // 初始化时更新一次
  1506. updateTocActiveState();
  1507. }}
  1508. }});
  1509. </script>
  1510. </body>
  1511. </html>
  1512. '''
  1513. return html
  1514. def main():
  1515. """主函数"""
  1516. script_dir = Path(__file__).parent
  1517. project_root = script_dir.parent.parent
  1518. data_dir = project_root / "data" / "data_1118"
  1519. # V3: 更新输入目录为 当前帖子_how解构结果_v2
  1520. input_dir = data_dir / "当前帖子_how解构结果_v2"
  1521. output_file = data_dir / "当前帖子_how解构结果_v2_可视化.html"
  1522. print(f"读取 how 解构结果: {input_dir}")
  1523. # 加载特征分类映射
  1524. print(f"加载特征分类映射...")
  1525. category_mapping = load_feature_category_mapping()
  1526. print(f"已加载 {sum(len(v) for v in category_mapping.values())} 个特征分类")
  1527. # 加载特征来源映射
  1528. print(f"加载特征来源映射...")
  1529. source_mapping = load_feature_source_mapping()
  1530. print(f"已加载 {len(source_mapping)} 个特征的来源信息")
  1531. json_files = list(input_dir.glob("*_how_v2_*.json"))
  1532. print(f"找到 {len(json_files)} 个文件\n")
  1533. posts_data = []
  1534. for i, file_path in enumerate(json_files, 1):
  1535. print(f"读取文件 [{i}/{len(json_files)}]: {file_path.name}")
  1536. with open(file_path, "r", encoding="utf-8") as f:
  1537. post_data = json.load(f)
  1538. posts_data.append(post_data)
  1539. print(f"\n生成合并的 HTML...")
  1540. html_content = generate_combined_html(posts_data, category_mapping, source_mapping)
  1541. print(f"保存到: {output_file}")
  1542. with open(output_file, "w", encoding="utf-8") as f:
  1543. f.write(html_content)
  1544. print(f"\n完成! 可视化文件已保存")
  1545. print(f"请在浏览器中打开: {output_file}")
  1546. if __name__ == "__main__":
  1547. main()