visualize_how_results.py 59 KB

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