visualize_how_results.py 138 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753
  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. from script.data_processing.path_config import PathConfig
  19. # ============ 相似度阈值配置 ============
  20. SIMILARITY_THRESHOLD_SAME = 0.8 # >= 此值为"相同"
  21. SIMILARITY_THRESHOLD_SIMILAR = 0.6 # >= 此值为"相似",< SAME阈值
  22. # < SIMILAR阈值 为"无关"
  23. # 相似度对应的颜色
  24. SIMILARITY_COLOR_SAME = "#10b981" # 绿色
  25. SIMILARITY_COLOR_SIMILAR = "#f59e0b" # 橙色
  26. SIMILARITY_COLOR_UNRELATED = "#9ca3af" # 灰色
  27. def get_similarity_status(similarity: float) -> tuple:
  28. """根据相似度返回状态标签和颜色
  29. Returns:
  30. tuple: (label, color, css_class)
  31. """
  32. if similarity >= SIMILARITY_THRESHOLD_SAME:
  33. return ("相同", SIMILARITY_COLOR_SAME, "same")
  34. elif similarity >= SIMILARITY_THRESHOLD_SIMILAR:
  35. return ("相似", SIMILARITY_COLOR_SIMILAR, "similar")
  36. else:
  37. return ("无关", SIMILARITY_COLOR_UNRELATED, "unrelated")
  38. # 注意:已改用基于相似度的显示方式,不再使用关系类型
  39. # def get_relation_color(relation: str) -> str:
  40. # """根据关系类型返回对应的颜色"""
  41. # color_map = {
  42. # "same": "#10b981", # 绿色 - 同义
  43. # "contains": "#3b82f6", # 蓝色 - 包含
  44. # "contained_by": "#8b5cf6", # 紫色 - 被包含
  45. # "coordinate": "#f59e0b", # 橙色 - 同级
  46. # "overlap": "#ec4899", # 粉色 - 部分重叠
  47. # "related": "#6366f1", # 靛蓝 - 相关
  48. # "unrelated": "#9ca3af" # 灰色 - 无关
  49. # }
  50. # return color_map.get(relation, "#9ca3af")
  51. #
  52. #
  53. # def get_relation_label(relation: str) -> str:
  54. # """返回关系类型的中文标签"""
  55. # label_map = {
  56. # "same": "同义",
  57. # "contains": "包含",
  58. # "contained_by": "被包含",
  59. # "coordinate": "同级",
  60. # "overlap": "部分重叠",
  61. # "related": "相关",
  62. # "unrelated": "无关"
  63. # }
  64. # return label_map.get(relation, relation)
  65. def generate_historical_post_card_html(post_detail: Dict, inspiration_point: Dict) -> str:
  66. """生成历史帖子的紧凑卡片HTML"""
  67. title = post_detail.get("title", "无标题")
  68. body_text = post_detail.get("body_text", "")
  69. images = post_detail.get("images", [])
  70. like_count = post_detail.get("like_count", 0)
  71. collect_count = post_detail.get("collect_count", 0)
  72. comment_count = post_detail.get("comment_count", 0)
  73. author = post_detail.get("channel_account_name", "")
  74. link = post_detail.get("link", "#")
  75. publish_time = post_detail.get("publish_time", "")
  76. # 获取灵感点信息
  77. point_name = inspiration_point.get("点的名称", "")
  78. point_desc = inspiration_point.get("点的描述", "")
  79. # 准备详情数据(用于模态框)
  80. import json
  81. post_detail_data = {
  82. "title": title,
  83. "body_text": body_text,
  84. "images": images,
  85. "like_count": like_count,
  86. "comment_count": comment_count,
  87. "collect_count": collect_count,
  88. "author": author,
  89. "publish_time": publish_time,
  90. "link": link
  91. }
  92. post_data_json = json.dumps(post_detail_data, ensure_ascii=False)
  93. post_data_json_escaped = html_module.escape(post_data_json)
  94. # 截取正文预览(前80个字符)
  95. body_preview = body_text[:80] + "..." if len(body_text) > 80 else body_text
  96. # 生成缩略图
  97. thumbnail_html = ""
  98. if images:
  99. thumbnail_html = f'<img src="{images[0]}" alt="Post thumbnail" class="historical-post-thumbnail" loading="lazy">'
  100. html = f'''
  101. <div class="historical-post-card" data-post-data='{post_data_json_escaped}' onclick="showPostDetail(this)">
  102. <div class="historical-post-image">
  103. {thumbnail_html}
  104. {f'<div class="post-card-image-count">{len(images)}</div>' if len(images) > 1 else ''}
  105. </div>
  106. <div class="historical-post-content">
  107. <div class="historical-post-title">{html_module.escape(title)}</div>
  108. <div class="historical-inspiration-info">
  109. <span class="inspiration-type-badge-small">灵感点</span>
  110. <span class="inspiration-name-small">{html_module.escape(point_name)}</span>
  111. </div>
  112. <div class="historical-inspiration-desc">{html_module.escape(point_desc[:100])}{"..." if len(point_desc) > 100 else ""}</div>
  113. <div class="historical-post-meta">
  114. <span class="historical-post-time">📅 {publish_time}</span>
  115. </div>
  116. <div class="historical-post-stats">
  117. <span>❤ {like_count}</span>
  118. <span>⭐ {collect_count}</span>
  119. <a href="{link}" target="_blank" class="view-link" onclick="event.stopPropagation()">查看原帖 →</a>
  120. </div>
  121. </div>
  122. </div>
  123. '''
  124. return html
  125. def generate_post_detail_html(post_data: Dict, post_idx: int) -> str:
  126. """生成帖子详情HTML(紧凑的卡片样式,点击可展开)"""
  127. post_detail = post_data.get("帖子详情", {})
  128. title = post_detail.get("title", "无标题")
  129. body_text = post_detail.get("body_text", "")
  130. images = post_detail.get("images", [])
  131. like_count = post_detail.get("like_count", 0)
  132. comment_count = post_detail.get("comment_count", 0)
  133. collect_count = post_detail.get("collect_count", 0)
  134. author = post_detail.get("channel_account_name", "")
  135. publish_time = post_detail.get("publish_time", "")
  136. link = post_detail.get("link", "")
  137. post_id = post_data.get("帖子id", f"post-{post_idx}")
  138. # 准备详情数据(用于模态框)
  139. import json
  140. post_detail_data = {
  141. "title": title,
  142. "body_text": body_text,
  143. "images": images,
  144. "like_count": like_count,
  145. "comment_count": comment_count,
  146. "collect_count": collect_count,
  147. "author": author,
  148. "publish_time": publish_time,
  149. "link": link
  150. }
  151. post_data_json = json.dumps(post_detail_data, ensure_ascii=False)
  152. post_data_json_escaped = html_module.escape(post_data_json)
  153. # 生成缩略图HTML
  154. thumbnail_html = ""
  155. if images and len(images) > 0:
  156. # 使用第一张图片作为缩略图,添加懒加载
  157. thumbnail_html = f'<img src="{images[0]}" class="post-card-thumbnail" alt="缩略图" loading="lazy">'
  158. else:
  159. thumbnail_html = '<div class="post-card-thumbnail-placeholder">📄</div>'
  160. # 截断正文用于预览
  161. body_preview = body_text[:80] + "..." if len(body_text) > 80 else body_text
  162. html = f'''
  163. <div class="post-card-compact" data-post-data='{post_data_json_escaped}' onclick="showPostDetail(this)">
  164. <div class="post-card-image">
  165. {thumbnail_html}
  166. {f'<div class="post-card-image-count">📷 {len(images)}</div>' if len(images) > 1 else ''}
  167. </div>
  168. <div class="post-card-content">
  169. <div class="post-card-title">{html_module.escape(title)}</div>
  170. <div class="post-card-preview">{html_module.escape(body_preview) if body_preview else "暂无正文"}</div>
  171. <div class="post-card-meta">
  172. <span class="post-card-author">👤 {html_module.escape(author)}</span>
  173. <span class="post-card-time">📅 {publish_time}</span>
  174. </div>
  175. <div class="post-card-stats">
  176. <span>👍 {like_count}</span>
  177. <span>💬 {comment_count if comment_count else 0}</span>
  178. <span>⭐ {collect_count if collect_count else 0}</span>
  179. </div>
  180. </div>
  181. </div>
  182. '''
  183. return html
  184. def generate_inspiration_detail_html(inspiration_point: Dict, feature_status_map: Dict[str, str] = None, point_type: str = "灵感点") -> str:
  185. """生成点详情HTML
  186. Args:
  187. inspiration_point: 点数据
  188. feature_status_map: 特征名称到状态的映射 {特征名称: "相同"|"相似"|"无关"}
  189. point_type: 点类型(灵感点/关键点/目的点)
  190. """
  191. name = inspiration_point.get("名称", "")
  192. desc = inspiration_point.get("描述", "")
  193. features = inspiration_point.get("特征列表", [])
  194. if feature_status_map is None:
  195. feature_status_map = {}
  196. # 计算该灵感点的整体结论
  197. feature_statuses = []
  198. features_html_list = []
  199. for f in features:
  200. feature_name = f if isinstance(f, str) else f.get("特征名称", "")
  201. weight = f.get("权重", 1.0) if isinstance(f, dict) else 1.0
  202. # 获取该特征的状态
  203. status = feature_status_map.get(feature_name, "无关")
  204. feature_statuses.append(status)
  205. if status == "相同":
  206. status_class = "feature-same"
  207. status_label = "相同"
  208. elif status == "相似":
  209. status_class = "feature-similar"
  210. status_label = "相似"
  211. else:
  212. status_class = "feature-unrelated"
  213. status_label = "无关"
  214. features_html_list.append(
  215. f'<span class="feature-tag {status_class}">'
  216. f'<span class="feature-status-label">{status_label}</span> '
  217. f'{html_module.escape(feature_name)}'
  218. f'</span>'
  219. )
  220. features_html = "".join(features_html_list)
  221. # 计算灵感点结论
  222. has_same = "相同" in feature_statuses
  223. has_similar = "相似" in feature_statuses
  224. has_unrelated = "无关" in feature_statuses
  225. if not has_unrelated:
  226. # 没有无关的 -> 找到
  227. insp_conclusion = "找到"
  228. insp_conclusion_class = "insp-conclusion-found"
  229. elif has_same or has_similar:
  230. # 有相同或相似,但也有无关 -> 部分找到
  231. insp_conclusion = "部分找到"
  232. insp_conclusion_class = "insp-conclusion-partial"
  233. else:
  234. # 都是无关 -> 都找不到
  235. insp_conclusion = "都找不到"
  236. insp_conclusion_class = "insp-conclusion-not-found"
  237. # 根据点类型设置图标
  238. point_icons = {
  239. "灵感点": "💡",
  240. "关键点": "🔑",
  241. "目的点": "🎯"
  242. }
  243. point_icon = point_icons.get(point_type, "💡")
  244. html = f'''
  245. <div class="inspiration-detail-card">
  246. <div class="inspiration-header">
  247. <span class="inspiration-type-badge">{point_icon} {point_type}</span>
  248. <h3 class="inspiration-name">{html_module.escape(name)}</h3>
  249. <span class="inspiration-conclusion {insp_conclusion_class}">{insp_conclusion}</span>
  250. </div>
  251. <div class="inspiration-description">
  252. <div class="desc-label">描述:</div>
  253. <div class="desc-text">{html_module.escape(desc)}</div>
  254. </div>
  255. <div class="inspiration-features">
  256. <div class="features-label">特征列表:</div>
  257. <div class="features-tags">{features_html}</div>
  258. </div>
  259. </div>
  260. '''
  261. return html
  262. def load_feature_category_mapping(config: PathConfig) -> Dict:
  263. """加载特征名称到分类的映射"""
  264. mapping_file = config.feature_category_mapping_file
  265. try:
  266. with open(mapping_file, "r", encoding="utf-8") as f:
  267. return json.load(f)
  268. except Exception as e:
  269. print(f"警告: 无法加载特征分类映射文件: {e}")
  270. return {}
  271. def load_feature_source_mapping(config: PathConfig) -> Dict:
  272. """加载特征名称到帖子来源的映射"""
  273. mapping_file = config.feature_source_mapping_file
  274. try:
  275. with open(mapping_file, "r", encoding="utf-8") as f:
  276. data = json.load(f)
  277. # 转换为便于查询的格式: {特征名称: [来源列表]}
  278. result = {}
  279. for feature_type in ["灵感点", "关键点", "目的点"]:
  280. if feature_type in data:
  281. for item in data[feature_type]:
  282. feature_name = item.get("特征名称")
  283. if feature_name:
  284. result[feature_name] = item.get("特征来源", [])
  285. return result
  286. except Exception as e:
  287. print(f"警告: 无法加载特征来源映射文件: {e}")
  288. return {}
  289. def generate_single_match_html(match: Dict, match_idx: int, post_idx: int, insp_idx: int, feature_idx: int, category_mapping: Dict = None, source_mapping: Dict = None, current_point_type: str = "灵感点") -> str:
  290. """生成单个匹配项的HTML
  291. Args:
  292. match: 单个匹配数据
  293. match_idx: 匹配项索引
  294. post_idx: 帖子索引
  295. insp_idx: 灵感点索引
  296. feature_idx: 特征索引
  297. category_mapping: 特征分类映射
  298. source_mapping: 特征来源映射
  299. current_point_type: 当前点的类型(灵感点/关键点/目的点)
  300. """
  301. persona_name = match.get("人设特征名称", "")
  302. feature_type = match.get("特征类型", "")
  303. feature_categories = match.get("特征分类", [])
  304. persona_level = match.get("人设特征层级", "")
  305. match_result = match.get("匹配结果", {})
  306. similarity = match_result.get("相似度", 0.0)
  307. explanation = match_result.get("说明", "")
  308. # 根据相似度确定颜色和标签
  309. label, color, _ = get_similarity_status(similarity)
  310. match_id = f"post-{post_idx}-insp-{insp_idx}-feat-{feature_idx}-match-{match_idx}"
  311. # 判断是否同层级匹配
  312. is_same_level = (persona_level == current_point_type)
  313. same_level_class = "match-same-level" if is_same_level else ""
  314. # 生成合并的层级-类型标签
  315. combined_badge_html = ""
  316. if persona_level and feature_type:
  317. combined_text = f"{persona_level}-{feature_type}"
  318. # 同层级用特殊样式
  319. if is_same_level:
  320. combined_badge_html = f'<span class="feature-combined-badge combined-badge-same">[{html_module.escape(combined_text)}]</span>'
  321. else:
  322. combined_badge_html = f'<span class="feature-combined-badge">[{html_module.escape(combined_text)}]</span>'
  323. categories_badge_html = ""
  324. if feature_categories:
  325. categories_text = " / ".join(feature_categories)
  326. categories_badge_html = f'<span class="feature-category-badge">{html_module.escape(categories_text)}</span>'
  327. # 获取该人设特征的分类信息
  328. categories_html = ""
  329. if category_mapping and persona_name:
  330. found_categories = None
  331. # 依次在灵感点、关键点、目的点中查找
  332. for persona_type in ["灵感点", "关键点", "目的点"]:
  333. if persona_type in category_mapping:
  334. type_mapping = category_mapping[persona_type]
  335. if persona_name in type_mapping:
  336. found_categories = type_mapping[persona_name].get("所属分类", [])
  337. break
  338. if found_categories:
  339. # 简洁样式:[大类/中类/小类]
  340. categories_reversed = list(reversed(found_categories))
  341. categories_text = "/".join(categories_reversed)
  342. categories_html = f'<span class="category-simple">[{html_module.escape(categories_text)}]</span>'
  343. # 获取该人设特征的历史帖子来源
  344. historical_posts_html = ""
  345. if source_mapping and persona_name and persona_name in source_mapping:
  346. source_list = source_mapping[persona_name]
  347. if source_list:
  348. historical_cards = []
  349. for source_item in source_list:
  350. post_detail = source_item.get("帖子详情", {})
  351. if post_detail:
  352. card_html = generate_historical_post_card_html(post_detail, source_item)
  353. historical_cards.append(card_html)
  354. if historical_cards:
  355. historical_posts_html = f'''
  356. <div class="historical-posts-section">
  357. <h4 class="historical-posts-title">历史帖子来源</h4>
  358. <div class="historical-posts-grid">
  359. {"".join(historical_cards)}
  360. </div>
  361. </div>
  362. '''
  363. # 生成历史帖子HTML
  364. historical_posts_html = ""
  365. if source_mapping and persona_name and persona_name in source_mapping:
  366. source_list = source_mapping[persona_name]
  367. if source_list:
  368. for source_item in source_list[:5]: # 最多5个
  369. post_detail = source_item.get("帖子详情", {})
  370. if post_detail:
  371. card_html = generate_historical_post_card_html(post_detail, source_item)
  372. historical_posts_html += card_html
  373. # 将数据编码到data属性中
  374. import html as html_encode
  375. data_explanation = html_encode.escape(explanation)
  376. data_historical = html_encode.escape(historical_posts_html)
  377. # 生成紧凑的匹配项HTML(可点击,弹出模态框)
  378. html = f'''
  379. <div class="match-item-compact {same_level_class}"
  380. data-persona-name="{html_module.escape(persona_name)}"
  381. data-feature-type="{html_module.escape(feature_type)}"
  382. data-persona-level="{html_module.escape(persona_level)}"
  383. data-similarity="{similarity}"
  384. data-label="{label}"
  385. data-explanation="{data_explanation}"
  386. data-historical-posts="{data_historical}"
  387. onclick="showMatchDetail(this)">
  388. {combined_badge_html}
  389. <span class="persona-name">{html_module.escape(persona_name)}</span>
  390. <span class="score-badge">相似度: {similarity:.2f}</span>
  391. <span class="relation-badge" style="background: {color};">{label}</span>
  392. </div>
  393. '''
  394. return html
  395. 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, current_point_type: str = "灵感点", feature_number: int = 1) -> str:
  396. """生成可折叠的匹配结果HTML
  397. Args:
  398. how_steps: how步骤列表
  399. feature_idx: 特征索引
  400. insp_idx: 灵感点索引
  401. post_idx: 帖子索引
  402. category_mapping: 特征分类映射
  403. source_mapping: 特征来源映射
  404. current_point_type: 当前点的类型(灵感点/关键点/目的点)
  405. feature_number: 特征序号(从1开始)
  406. """
  407. if not how_steps or len(how_steps) == 0:
  408. return ""
  409. step = how_steps[0]
  410. features = step.get("特征列表", [])
  411. if feature_idx >= len(features):
  412. return ""
  413. feature_data = features[feature_idx]
  414. feature_name = feature_data.get("特征名称", "")
  415. feature_weight = feature_data.get("权重", 1.0)
  416. match_results = feature_data.get("匹配结果", [])
  417. if category_mapping is None:
  418. category_mapping = {}
  419. # 按相似度排序
  420. sorted_matches = sorted(match_results, key=lambda x: x.get("匹配结果", {}).get("相似度", 0), reverse=True)
  421. # 找出最高相似度,确定状态
  422. max_similarity = 0.0
  423. if match_results:
  424. max_similarity = max(match.get("匹配结果", {}).get("相似度", 0) for match in match_results)
  425. # 根据最高相似度确定状态
  426. status, _, status_suffix = get_similarity_status(max_similarity)
  427. status_class = f"status-{status_suffix}"
  428. found_status_html = f'<span class="feature-match-status {status_class}">{status}</span>'
  429. # 统计相似度分布
  430. same_label = f"相同 (≥{SIMILARITY_THRESHOLD_SAME})"
  431. similar_label = f"相似 ({SIMILARITY_THRESHOLD_SIMILAR}-{SIMILARITY_THRESHOLD_SAME})"
  432. unrelated_label = f"无关 (<{SIMILARITY_THRESHOLD_SIMILAR})"
  433. similarity_ranges = {same_label: 0, similar_label: 0, unrelated_label: 0}
  434. for match in match_results:
  435. similarity = match.get("匹配结果", {}).get("相似度", 0)
  436. status_label, _, _ = get_similarity_status(similarity)
  437. if status_label == "相同":
  438. similarity_ranges[same_label] += 1
  439. elif status_label == "相似":
  440. similarity_ranges[similar_label] += 1
  441. else:
  442. similarity_ranges[unrelated_label] += 1
  443. # 生成统计信息
  444. stats_items = []
  445. range_colors = {
  446. same_label: SIMILARITY_COLOR_SAME,
  447. similar_label: SIMILARITY_COLOR_SIMILAR,
  448. unrelated_label: SIMILARITY_COLOR_UNRELATED
  449. }
  450. for range_name, count in similarity_ranges.items():
  451. if count > 0:
  452. color = range_colors[range_name]
  453. stats_items.append(f'<span class="stat-badge" style="background: {color};">{range_name}: {count}</span>')
  454. stats_html = "".join(stats_items)
  455. # 按人设特征层级分组匹配项
  456. level_groups = {
  457. "灵感点": {"标签": [], "分类": []},
  458. "关键点": {"标签": [], "分类": []},
  459. "目的点": {"标签": [], "分类": []}
  460. }
  461. for i, match in enumerate(sorted_matches):
  462. persona_level = match.get("人设特征层级", "")
  463. feature_type = match.get("特征类型", "")
  464. if persona_level in level_groups and feature_type in ["标签", "分类"]:
  465. level_groups[persona_level][feature_type].append((i, match))
  466. # 生成分组的匹配项HTML
  467. matches_html = ""
  468. # 按层级顺序显示:灵感点 -> 关键点 -> 目的点
  469. level_index = 1
  470. for level_name in ["灵感点", "关键点", "目的点"]:
  471. level_data = level_groups[level_name]
  472. total_count = len(level_data["标签"]) + len(level_data["分类"])
  473. if total_count == 0:
  474. continue
  475. # 生成该层级的折叠区域
  476. level_section_id = f"post-{post_idx}-{current_point_type}-{insp_idx}-feat-{feature_idx}-level-{level_name}"
  477. # 找出该层级的最高分匹配
  478. all_level_matches = level_data["标签"] + level_data["分类"]
  479. top_match = None
  480. max_similarity = 0
  481. for _, match in all_level_matches:
  482. similarity = match.get("匹配结果", {}).get("相似度", 0)
  483. if similarity > max_similarity:
  484. max_similarity = similarity
  485. top_match = match
  486. # 生成最高分特征信息
  487. top_match_html = ""
  488. if top_match:
  489. top_persona_name = top_match.get("人设特征名称", "")
  490. top_feature_type = top_match.get("特征类型", "")
  491. similarity_label, similarity_color, _ = get_similarity_status(max_similarity)
  492. top_match_html = f'''
  493. <div class="level-top-match">
  494. <span class="top-match-label">最高:</span>
  495. <span class="top-match-type">[{html_module.escape(top_feature_type)}]</span>
  496. <span class="top-match-name">{html_module.escape(top_persona_name)}</span>
  497. <span class="top-match-score" style="background: {similarity_color}; color: white;">{max_similarity:.2f} {similarity_label}</span>
  498. </div>
  499. '''
  500. # 计算该层级的相似度分布
  501. level_stats = {"相同": 0, "相似": 0, "无关": 0}
  502. for _, match in all_level_matches:
  503. similarity = match.get("匹配结果", {}).get("相似度", 0)
  504. stat_label, _, _ = get_similarity_status(similarity)
  505. level_stats[stat_label] += 1
  506. # 生成统计标签
  507. level_stats_html = ""
  508. if level_stats["相同"] > 0:
  509. level_stats_html += f'<span class="stat-badge" style="background: #10b981;">相同: {level_stats["相同"]}</span>'
  510. if level_stats["相似"] > 0:
  511. level_stats_html += f'<span class="stat-badge" style="background: #f59e0b;">相似: {level_stats["相似"]}</span>'
  512. if level_stats["无关"] > 0:
  513. level_stats_html += f'<span class="stat-badge" style="background: #9ca3af;">无关: {level_stats["无关"]}</span>'
  514. matches_html += f'''
  515. <div class="level-group-section">
  516. <div class="level-group-header" onclick="toggleMatchGroup(event, '{level_section_id}')">
  517. <div class="level-header-left">
  518. <span class="expand-icon" id="{level_section_id}-icon">▶</span>
  519. <h4 class="level-group-title">{feature_number}.{level_index} 匹配人设{level_name} ({total_count})</h4>
  520. <div class="level-stats">{level_stats_html}</div>
  521. </div>
  522. {top_match_html}
  523. </div>
  524. <div class="level-group-content" id="{level_section_id}-content" style="display: none;">
  525. '''
  526. # 该层级下的标签分组
  527. subgroup_index = 1
  528. if level_data["标签"]:
  529. group_id = f"post-{post_idx}-{current_point_type}-{insp_idx}-feat-{feature_idx}-level-{level_name}-label"
  530. group_matches_html = ""
  531. # 找出标签中的最高分
  532. tag_top_match = None
  533. tag_max_similarity = 0
  534. for i, match in level_data["标签"]:
  535. similarity = match.get("匹配结果", {}).get("相似度", 0)
  536. if similarity > tag_max_similarity:
  537. tag_max_similarity = similarity
  538. tag_top_match = match
  539. match_html = generate_single_match_html(
  540. match, i, post_idx, insp_idx, feature_idx,
  541. category_mapping, source_mapping, current_point_type
  542. )
  543. group_matches_html += match_html
  544. # 生成标签最高分信息
  545. tag_top_html = ""
  546. if tag_top_match:
  547. tag_persona_name = tag_top_match.get("人设特征名称", "")
  548. tag_persona_level = tag_top_match.get("人设特征层级", "")
  549. tag_feature_type = tag_top_match.get("特征类型", "")
  550. tag_label, tag_color, _ = get_similarity_status(tag_max_similarity)
  551. # 生成层级-类型标签
  552. tag_combined = f"[{tag_persona_level}-{tag_feature_type}]" if tag_persona_level and tag_feature_type else ""
  553. tag_top_html = f'''
  554. <div class="subgroup-top-match">
  555. <span class="subgroup-top-label">最高:</span>
  556. <span class="subgroup-top-combined">{tag_combined}</span>
  557. <span class="subgroup-top-name">{html_module.escape(tag_persona_name)}</span>
  558. <span class="subgroup-top-score" style="background: {tag_color}; color: white;">{tag_max_similarity:.2f} {tag_label}</span>
  559. </div>
  560. '''
  561. matches_html += f'''
  562. <div class="match-subgroup-section">
  563. <div class="match-subgroup-header" onclick="toggleMatchGroup(event, '{group_id}')">
  564. <div class="subgroup-header-left">
  565. <span class="expand-icon" id="{group_id}-icon">▼</span>
  566. <h5 class="match-subgroup-title">{feature_number}.{level_index}.{subgroup_index} 标签 ({len(level_data["标签"])})</h5>
  567. </div>
  568. {tag_top_html}
  569. </div>
  570. <div class="match-subgroup-content" id="{group_id}-content">
  571. {group_matches_html}
  572. </div>
  573. </div>
  574. '''
  575. subgroup_index += 1
  576. # 该层级下的分类分组
  577. if level_data["分类"]:
  578. group_id = f"post-{post_idx}-{current_point_type}-{insp_idx}-feat-{feature_idx}-level-{level_name}-category"
  579. group_matches_html = ""
  580. # 找出分类中的最高分
  581. cat_top_match = None
  582. cat_max_similarity = 0
  583. for i, match in level_data["分类"]:
  584. similarity = match.get("匹配结果", {}).get("相似度", 0)
  585. if similarity > cat_max_similarity:
  586. cat_max_similarity = similarity
  587. cat_top_match = match
  588. match_html = generate_single_match_html(
  589. match, i, post_idx, insp_idx, feature_idx,
  590. category_mapping, source_mapping, current_point_type
  591. )
  592. group_matches_html += match_html
  593. # 生成分类最高分信息
  594. cat_top_html = ""
  595. if cat_top_match:
  596. cat_persona_name = cat_top_match.get("人设特征名称", "")
  597. cat_persona_level = cat_top_match.get("人设特征层级", "")
  598. cat_feature_type = cat_top_match.get("特征类型", "")
  599. cat_label, cat_color, _ = get_similarity_status(cat_max_similarity)
  600. # 生成层级-类型标签
  601. cat_combined = f"[{cat_persona_level}-{cat_feature_type}]" if cat_persona_level and cat_feature_type else ""
  602. cat_top_html = f'''
  603. <div class="subgroup-top-match">
  604. <span class="subgroup-top-label">最高:</span>
  605. <span class="subgroup-top-combined">{cat_combined}</span>
  606. <span class="subgroup-top-name">{html_module.escape(cat_persona_name)}</span>
  607. <span class="subgroup-top-score" style="background: {cat_color}; color: white;">{cat_max_similarity:.2f} {cat_label}</span>
  608. </div>
  609. '''
  610. matches_html += f'''
  611. <div class="match-subgroup-section">
  612. <div class="match-subgroup-header" onclick="toggleMatchGroup(event, '{group_id}')">
  613. <div class="subgroup-header-left">
  614. <span class="expand-icon" id="{group_id}-icon">▼</span>
  615. <h5 class="match-subgroup-title">{feature_number}.{level_index}.{subgroup_index} 分类 ({len(level_data["分类"])})</h5>
  616. </div>
  617. {cat_top_html}
  618. </div>
  619. <div class="match-subgroup-content" id="{group_id}-content">
  620. {group_matches_html}
  621. </div>
  622. </div>
  623. '''
  624. subgroup_index += 1
  625. matches_html += '''
  626. </div>
  627. </div>
  628. '''
  629. level_index += 1
  630. section_id = f"post-{post_idx}-insp-{insp_idx}-feat-{feature_idx}-section"
  631. # 找出所有匹配中的最高分
  632. overall_top_match = None
  633. overall_max_similarity = 0
  634. for match in match_results:
  635. similarity = match.get("匹配结果", {}).get("相似度", 0)
  636. if similarity > overall_max_similarity:
  637. overall_max_similarity = similarity
  638. overall_top_match = match
  639. # 生成最高分信息
  640. overall_top_html = ""
  641. if overall_top_match:
  642. top_persona_name = overall_top_match.get("人设特征名称", "")
  643. top_feature_type = overall_top_match.get("特征类型", "")
  644. top_persona_level = overall_top_match.get("人设特征层级", "")
  645. top_label, top_color, _ = get_similarity_status(overall_max_similarity)
  646. overall_top_html = f'''
  647. <div class="overall-top-match">
  648. <span class="overall-top-label">最高:</span>
  649. <span class="overall-top-combined">[{html_module.escape(top_persona_level)}-{html_module.escape(top_feature_type)}]</span>
  650. <span class="overall-top-name">{html_module.escape(top_persona_name)}</span>
  651. <span class="overall-top-score" style="background: {top_color}; color: white;">{overall_max_similarity:.2f} {top_label}</span>
  652. </div>
  653. '''
  654. html = f'''
  655. <div class="match-results-section">
  656. <div class="match-section-header collapsible-header" onclick="toggleFeatureSection(event, '{section_id}')">
  657. <div class="header-left">
  658. <span class="expand-icon" id="{section_id}-icon">▼</span>
  659. <h4>{feature_number}. 匹配结果: {html_module.escape(feature_name)}</h4>
  660. {found_status_html}
  661. </div>
  662. <div class="match-stats">{stats_html}</div>
  663. {overall_top_html}
  664. </div>
  665. <div class="matches-list" id="{section_id}-content">
  666. {matches_html}
  667. </div>
  668. </div>
  669. '''
  670. return html
  671. def generate_toc_html(post_data: Dict, post_idx: int, feature_status_map: Dict[str, str] = None, overall_conclusion: str = "") -> str:
  672. """生成目录导航HTML
  673. Args:
  674. post_data: 帖子数据
  675. post_idx: 帖子索引
  676. feature_status_map: 特征名称到状态的映射 {特征名称: "相同"|"相似"|"无关"}
  677. overall_conclusion: 整体结论
  678. """
  679. how_result = post_data.get("how解构结果", {})
  680. if feature_status_map is None:
  681. feature_status_map = {}
  682. toc_items = []
  683. # 帖子详情
  684. 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>')
  685. # 处理不同类型的点
  686. point_types = [
  687. ("灵感点列表", "灵感点", "toc-badge-inspiration", "💡"),
  688. ("目的点列表", "目的点", "toc-badge-purpose", "🎯"),
  689. ("关键点列表", "关键点", "toc-badge-key", "🔑")
  690. ]
  691. for list_key, point_name, badge_class, icon in point_types:
  692. point_list = how_result.get(list_key, [])
  693. if not point_list:
  694. continue
  695. # 点类型分组标题
  696. toc_items.append(f'<div class="toc-item toc-level-1 toc-group-header"><span class="toc-badge {badge_class}">{icon} {point_name}</span></div>')
  697. for point_idx, point in enumerate(point_list):
  698. name = point.get("名称", f"{point_name} {point_idx + 1}")
  699. name_short = name[:18] + "..." if len(name) > 18 else name
  700. # 计算该点的整体状态
  701. how_steps = point.get("how步骤列表", [])
  702. point_status = "无关"
  703. has_same = False
  704. has_similar = False
  705. if how_steps:
  706. features = how_steps[0].get("特征列表", [])
  707. for feature_data in features:
  708. feature_name = feature_data.get("特征名称", "")
  709. status = feature_status_map.get(feature_name, "无关")
  710. if status == "相同":
  711. has_same = True
  712. break
  713. elif status == "相似":
  714. has_similar = True
  715. if has_same:
  716. point_status = "找到"
  717. point_status_class = "toc-point-found"
  718. elif has_similar:
  719. point_status = "部分找到"
  720. point_status_class = "toc-point-partial"
  721. else:
  722. point_status = "都找不到"
  723. point_status_class = "toc-point-notfound"
  724. else:
  725. point_status_class = "toc-point-notfound"
  726. toc_items.append(f'<div class="toc-item toc-level-2 {point_status_class}" onclick="scrollToSection(\'post-{post_idx}-point-{list_key}-{point_idx}\')"><span class="toc-badge {badge_class}">{point_name}</span> {html_module.escape(name_short)} <span class="toc-point-status">[{point_status}]</span></div>')
  727. # 特征列表
  728. if how_steps:
  729. features = how_steps[0].get("特征列表", [])
  730. for feat_idx, feature_data in enumerate(features):
  731. feature_name = feature_data.get("特征名称", f"特征 {feat_idx + 1}")
  732. # 获取状态
  733. status = feature_status_map.get(feature_name, "无关")
  734. if status == "相同":
  735. status_class = "toc-feature-same"
  736. status_label = "相同"
  737. elif status == "相似":
  738. status_class = "toc-feature-similar"
  739. status_label = "相似"
  740. else:
  741. status_class = "toc-feature-unrelated"
  742. status_label = "无关"
  743. toc_items.append(f'<div class="toc-item toc-level-3 {status_class}" onclick="scrollToSection(\'post-{post_idx}-feat-{point_idx}-{feat_idx}\')"><span class="toc-badge toc-badge-feature">特征</span> {html_module.escape(feature_name)} <span class="toc-feature-status">[{status_label}]</span></div>')
  744. # 整体结论HTML
  745. conclusion_html = ""
  746. if overall_conclusion:
  747. if overall_conclusion == "找到":
  748. conclusion_class = "conclusion-found"
  749. conclusion_icon = "✓"
  750. elif overall_conclusion == "部分找到":
  751. conclusion_class = "conclusion-partial"
  752. conclusion_icon = "~"
  753. else: # 都找不到
  754. conclusion_class = "conclusion-not-found"
  755. conclusion_icon = "✗"
  756. conclusion_html = f'''
  757. <div class="toc-conclusion {conclusion_class}">
  758. <span class="conclusion-icon">{conclusion_icon}</span>
  759. <span class="conclusion-text">{overall_conclusion}</span>
  760. </div>
  761. '''
  762. return f'''
  763. <div class="toc-container">
  764. <div class="toc-header">目录导航</div>
  765. {conclusion_html}
  766. <div class="toc-content">
  767. {"".join(toc_items)}
  768. </div>
  769. </div>
  770. '''
  771. def generate_post_content_html(post_data: Dict, post_idx: int, category_mapping: Dict = None, source_mapping: Dict = None) -> str:
  772. """生成单个帖子的完整内容HTML"""
  773. # 2. 灵感点详情和匹配结果
  774. how_result = post_data.get("how解构结果", {})
  775. inspiration_list = how_result.get("灵感点列表", [])
  776. # 先计算所有特征的状态(基于最高相似度)
  777. feature_status_map = {} # {特征名称: "相同"|"相似"|"无关"}
  778. for inspiration_point in inspiration_list:
  779. how_steps = inspiration_point.get("how步骤列表", [])
  780. if how_steps:
  781. features = how_steps[0].get("特征列表", [])
  782. for feature_data in features:
  783. feature_name = feature_data.get("特征名称", "")
  784. match_results = feature_data.get("匹配结果", [])
  785. # 找出最高相似度
  786. max_similarity = 0.0
  787. if match_results:
  788. max_similarity = max(match.get("匹配结果", {}).get("相似度", 0) for match in match_results)
  789. # 根据最高相似度确定状态
  790. status_label, _, _ = get_similarity_status(max_similarity)
  791. feature_status_map[feature_name] = status_label
  792. # 1. 帖子详情
  793. post_detail_html = generate_post_detail_html(post_data, post_idx)
  794. # 生成所有灵感点的详情HTML(传入状态映射)
  795. inspirations_detail_html = ""
  796. for insp_idx, inspiration_point in enumerate(inspiration_list):
  797. inspiration_detail = generate_inspiration_detail_html(inspiration_point, feature_status_map)
  798. inspirations_detail_html += f'''
  799. <div id="post-{post_idx}-insp-{insp_idx}" class="inspiration-detail-item content-section">
  800. {inspiration_detail}
  801. </div>
  802. '''
  803. # 生成所有匹配结果HTML,按照how步骤分组
  804. all_matches_html = ""
  805. for insp_idx, inspiration_point in enumerate(inspiration_list):
  806. inspiration_name = inspiration_point.get("名称", f"灵感点 {insp_idx + 1}")
  807. how_steps = inspiration_point.get("how步骤列表", [])
  808. if how_steps:
  809. # 为每个灵感点创建一个区域
  810. for step_idx, step in enumerate(how_steps):
  811. step_name = step.get("步骤名称", f"步骤 {step_idx + 1}")
  812. features = step.get("特征列表", [])
  813. # 生成该步骤下所有特征的匹配结果
  814. features_html = ""
  815. for feat_idx, feature_data in enumerate(features):
  816. match_html = generate_match_results_html([step], feat_idx, insp_idx, post_idx, category_mapping, source_mapping, "灵感点", feat_idx + 1)
  817. features_html += f'<div id="post-{post_idx}-feat-{insp_idx}-{feat_idx}" class="feature-match-wrapper">{match_html}</div>'
  818. # 生成步骤区域(可折叠)
  819. step_section_id = f"post-{post_idx}-step-{insp_idx}-{step_idx}"
  820. all_matches_html += f'''
  821. <div class="step-section">
  822. <div class="step-header collapsible-header" onclick="toggleStepSection('{step_section_id}')">
  823. <div class="header-left">
  824. <span class="expand-icon" id="{step_section_id}-icon">▼</span>
  825. <h3 class="step-name">{html_module.escape(step_name)}</h3>
  826. </div>
  827. <span class="step-inspiration-name">来自: {html_module.escape(inspiration_name)}</span>
  828. </div>
  829. <div class="step-features-list" id="{step_section_id}-content">
  830. {features_html}
  831. </div>
  832. </div>
  833. '''
  834. html = f'''
  835. <div class="post-content-wrapper">
  836. <!-- 第一个框:左右分栏(帖子详情 + 灵感点详情) -->
  837. <div class="top-section-box">
  838. <div class="two-column-layout">
  839. <div class="left-column">
  840. <div id="post-{post_idx}-detail" class="post-detail-wrapper">
  841. {post_detail_html}
  842. </div>
  843. </div>
  844. <div class="right-column">
  845. <div class="inspirations-detail-wrapper">
  846. {inspirations_detail_html}
  847. </div>
  848. </div>
  849. </div>
  850. </div>
  851. <!-- 下面:所有匹配结果 -->
  852. <div class="matches-section">
  853. {all_matches_html}
  854. </div>
  855. </div>
  856. '''
  857. return html
  858. def generate_combined_html(posts_data: List[Dict], category_mapping: Dict = None, source_mapping: Dict = None) -> str:
  859. """生成包含所有帖子的单一HTML(左边目录,右边内容)"""
  860. # 生成统一的目录(包含所有帖子)
  861. all_toc_items = []
  862. for post_idx, post in enumerate(posts_data):
  863. post_detail = post.get("帖子详情", {})
  864. title = post_detail.get("title", "无标题")
  865. post_id = post_detail.get("post_id", f"post_{post_idx}")
  866. # 获取发布时间并格式化
  867. publish_timestamp = post_detail.get("publish_timestamp", 0)
  868. if publish_timestamp:
  869. from datetime import datetime
  870. # publish_timestamp 是毫秒级时间戳,需要除以1000
  871. date_str = datetime.fromtimestamp(publish_timestamp / 1000).strftime("%Y-%m-%d")
  872. else:
  873. date_str = "未知日期"
  874. # 帖子标题作为一级目录(可折叠),在标题前显示日期
  875. all_toc_items.append(f'''
  876. <div class="toc-item toc-level-0 toc-post-header collapsed" data-post-id="{post_idx}" onclick="toggleTocPost(event, {post_idx})">
  877. <span class="toc-expand-icon">▼</span>
  878. <div class="toc-item-content">
  879. <span style="color: #666; font-size: 0.9em;">{date_str}</span> {html_module.escape(title[:30])}...
  880. </div>
  881. </div>
  882. <div class="toc-children hidden" id="toc-post-{post_idx}-children">
  883. ''')
  884. # 获取特征状态映射
  885. feature_status_map = {}
  886. how_result = post.get("how解构结果", {})
  887. # 先收集所有特征状态
  888. for point_list_key in ["灵感点列表", "目的点列表", "关键点列表"]:
  889. point_list = how_result.get(point_list_key, [])
  890. for point in point_list:
  891. how_steps = point.get("how步骤列表", [])
  892. if how_steps:
  893. features = how_steps[0].get("特征列表", [])
  894. for feature_data in features:
  895. feature_name = feature_data.get("特征名称", "")
  896. match_results = feature_data.get("匹配结果", [])
  897. max_similarity = 0.0
  898. if match_results:
  899. max_similarity = max(match.get("匹配结果", {}).get("相似度", 0) for match in match_results)
  900. status_label, _, _ = get_similarity_status(max_similarity)
  901. feature_status_map[feature_name] = status_label
  902. # 生成点类型目录
  903. point_types = [
  904. ("灵感点列表", "灵感点", "toc-badge-inspiration", "💡"),
  905. ("目的点列表", "目的点", "toc-badge-purpose", "🎯"),
  906. ("关键点列表", "关键点", "toc-badge-key", "🔑")
  907. ]
  908. for list_key, point_name, badge_class, icon in point_types:
  909. point_list = how_result.get(list_key, [])
  910. if not point_list:
  911. continue
  912. # 点类型分组标题(可折叠),去掉"列表"两字
  913. group_id = f"post-{post_idx}-{list_key}"
  914. all_toc_items.append(f'''
  915. <div class="toc-item toc-level-1 toc-group-header collapsed" onclick="toggleTocGroup(event, '{group_id}')">
  916. <span class="toc-expand-icon">▼</span>
  917. <div class="toc-item-content">
  918. {icon} {point_name}
  919. </div>
  920. </div>
  921. <div class="toc-children hidden" id="toc-{group_id}-children">
  922. ''')
  923. for point_idx, point in enumerate(point_list):
  924. name = point.get("名称", f"{point_name} {point_idx + 1}")
  925. name_short = name[:18] + "..." if len(name) > 18 else name
  926. # 计算该点的状态
  927. how_steps = point.get("how步骤列表", [])
  928. point_status = "无关"
  929. has_same = False
  930. has_similar = False
  931. if how_steps:
  932. features = how_steps[0].get("特征列表", [])
  933. has_unrelated = False
  934. for feature_data in features:
  935. feature_name = feature_data.get("特征名称", "")
  936. status = feature_status_map.get(feature_name, "无关")
  937. if status == "相同":
  938. has_same = True
  939. elif status == "相似":
  940. has_similar = True
  941. elif status == "无关":
  942. has_unrelated = True
  943. # 统一使用与详情页相同的逻辑
  944. if not has_unrelated:
  945. # 没有无关的 -> 找到
  946. point_status = "找到"
  947. point_status_class = "toc-point-found"
  948. elif has_same or has_similar:
  949. # 有相同或相似,但也有无关 -> 部分找到
  950. point_status = "部分找到"
  951. point_status_class = "toc-point-partial"
  952. else:
  953. # 都是无关 -> 都找不到
  954. point_status = "都找不到"
  955. point_status_class = "toc-point-notfound"
  956. else:
  957. point_status = "都找不到"
  958. point_status_class = "toc-point-notfound"
  959. # 点项(可折叠,点击切换到该点的视图)
  960. # 确保ID唯一:包含list_key以区分不同类型的点
  961. point_item_id = f"post-{post_idx}-{list_key}-point-{point_idx}"
  962. point_view_id = f"view-post-{post_idx}-point-{list_key}-{point_idx}"
  963. all_toc_items.append(f'''
  964. <div class="toc-item toc-level-2 {point_status_class} collapsed" onclick="toggleTocPoint(event, '{point_item_id}', '{point_view_id}')">
  965. <span class="toc-expand-icon">▼</span>
  966. <div class="toc-item-content">
  967. [{point_name}] {html_module.escape(name_short)}
  968. <span class="toc-point-status">[{point_status}]</span>
  969. </div>
  970. </div>
  971. <div class="toc-children hidden" id="toc-{point_item_id}-children">
  972. ''')
  973. # 特征列表
  974. if how_steps:
  975. features = how_steps[0].get("特征列表", [])
  976. for feat_idx, feature_data in enumerate(features):
  977. feature_name = feature_data.get("特征名称", f"特征 {feat_idx + 1}")
  978. status = feature_status_map.get(feature_name, "无关")
  979. if status == "相同":
  980. status_class = "toc-feature-same"
  981. status_label = "相同"
  982. elif status == "相似":
  983. status_class = "toc-feature-similar"
  984. status_label = "相似"
  985. else:
  986. status_class = "toc-feature-unrelated"
  987. status_label = "无关"
  988. # 特征项(点击直接跳转,不展开子项)
  989. all_toc_items.append(f'''
  990. <div class="toc-item toc-level-3 {status_class}" onclick="clickFeature(event, '{point_view_id}', 'post-{post_idx}-feat-{point_idx}-{feat_idx}')">
  991. <div class="toc-item-content">
  992. [特征] {html_module.escape(feature_name)}
  993. <span class="toc-feature-status">[{status_label}]</span>
  994. </div>
  995. </div>
  996. ''')
  997. # 关闭点项的children
  998. all_toc_items.append('</div>')
  999. # 关闭点类型分组的children
  1000. all_toc_items.append('</div>')
  1001. # 关闭帖子的children
  1002. all_toc_items.append('</div>')
  1003. # 生成所有独立的内容视图
  1004. all_content_views = []
  1005. for post_idx, post in enumerate(posts_data):
  1006. post_detail = post.get("帖子详情", {})
  1007. how_result = post.get("how解构结果", {})
  1008. # 1. 生成"仅帖子详情"的视图
  1009. post_detail_html = generate_post_detail_html(post, post_idx)
  1010. post_only_view = f'''
  1011. <div class="content-view" id="view-post-{post_idx}-detail" style="display: none;">
  1012. <div class="post-content-wrapper">
  1013. <div class="top-section-box">
  1014. <div id="post-{post_idx}-detail" class="post-detail-wrapper">
  1015. {post_detail_html}
  1016. </div>
  1017. </div>
  1018. </div>
  1019. </div>
  1020. '''
  1021. all_content_views.append(post_only_view)
  1022. # 2. 为每个点生成独立视图(包含帖子详情+点详情+匹配结果)
  1023. for point_list_key in ["灵感点列表", "目的点列表", "关键点列表"]:
  1024. point_list = how_result.get(point_list_key, [])
  1025. # 提取点类型(去掉"列表")
  1026. point_type = point_list_key.replace("列表", "")
  1027. for point_idx, point in enumerate(point_list):
  1028. # 计算该点的特征状态映射
  1029. point_feature_status_map = {}
  1030. how_steps = point.get("how步骤列表", [])
  1031. if how_steps:
  1032. features = how_steps[0].get("特征列表", [])
  1033. for feature_data in features:
  1034. feature_name = feature_data.get("特征名称", "")
  1035. match_results = feature_data.get("匹配结果", [])
  1036. max_similarity = 0.0
  1037. if match_results:
  1038. max_similarity = max(match.get("匹配结果", {}).get("相似度", 0) for match in match_results)
  1039. status_label, _, _ = get_similarity_status(max_similarity)
  1040. point_feature_status_map[feature_name] = status_label
  1041. # 生成点的详情HTML,传入特征状态映射和点类型
  1042. point_detail_html = generate_inspiration_detail_html(point, point_feature_status_map, point_type)
  1043. # 生成该点的所有特征匹配结果
  1044. point_name = point.get("名称", f"点 {point_idx + 1}")
  1045. matches_html = ""
  1046. if how_steps:
  1047. for step_idx, step in enumerate(how_steps):
  1048. step_name = step.get("步骤名称", f"步骤 {step_idx + 1}")
  1049. features = step.get("特征列表", [])
  1050. features_html = ""
  1051. for feat_idx, feature_data in enumerate(features):
  1052. match_html = generate_match_results_html([step], feat_idx, point_idx, post_idx, category_mapping, source_mapping, point_list_key.replace("列表", ""), feat_idx + 1)
  1053. features_html += f'<div id="post-{post_idx}-feat-{point_idx}-{feat_idx}" class="feature-match-wrapper">{match_html}</div>'
  1054. step_section_id = f"post-{post_idx}-step-{point_idx}-{step_idx}"
  1055. matches_html += f'''
  1056. <div class="step-section">
  1057. <div class="step-header collapsible-header" onclick="toggleStepSection('{step_section_id}')">
  1058. <div class="header-left">
  1059. <span class="expand-icon" id="{step_section_id}-icon">▼</span>
  1060. <h3 class="step-name">{html_module.escape(step_name)}</h3>
  1061. </div>
  1062. <span class="step-inspiration-name">来自: {html_module.escape(point_name)}</span>
  1063. </div>
  1064. <div class="step-features-list" id="{step_section_id}-content">
  1065. {features_html}
  1066. </div>
  1067. </div>
  1068. '''
  1069. # 组合成完整视图
  1070. point_view_id = f"view-post-{post_idx}-point-{point_list_key}-{point_idx}"
  1071. point_view = f'''
  1072. <div class="content-view" id="{point_view_id}" style="display: none;">
  1073. <div class="post-content-wrapper">
  1074. <div class="top-section-box">
  1075. <div class="two-column-layout">
  1076. <div class="left-column">
  1077. <div class="post-detail-wrapper">
  1078. {post_detail_html}
  1079. </div>
  1080. </div>
  1081. <div class="right-column">
  1082. <div class="inspirations-detail-wrapper">
  1083. <div id="post-{post_idx}-point-{point_list_key}-{point_idx}" class="inspiration-detail-item content-section">
  1084. {point_detail_html}
  1085. </div>
  1086. </div>
  1087. </div>
  1088. </div>
  1089. </div>
  1090. <div class="matches-section">
  1091. {matches_html}
  1092. </div>
  1093. </div>
  1094. </div>
  1095. '''
  1096. all_content_views.append(point_view)
  1097. # 组合所有内容视图
  1098. all_contents_html = "\n".join(all_content_views)
  1099. # 组合目录HTML
  1100. toc_items_html = "\n".join(all_toc_items)
  1101. html = f'''
  1102. <!DOCTYPE html>
  1103. <html lang="zh-CN">
  1104. <head>
  1105. <meta charset="UTF-8">
  1106. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  1107. <title>How解构结果可视化</title>
  1108. <style>
  1109. * {{
  1110. margin: 0;
  1111. padding: 0;
  1112. box-sizing: border-box;
  1113. }}
  1114. body {{
  1115. font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
  1116. background: #f5f5f5;
  1117. color: #333;
  1118. line-height: 1.6;
  1119. }}
  1120. /* 两栏布局容器 */
  1121. .page-container {{
  1122. display: flex;
  1123. height: 100vh;
  1124. overflow: hidden;
  1125. }}
  1126. /* 左侧目录 */
  1127. .left-sidebar {{
  1128. width: 420px;
  1129. background: white;
  1130. border-right: 2px solid #e5e7eb;
  1131. display: flex;
  1132. flex-direction: column;
  1133. overflow: hidden;
  1134. }}
  1135. /* 右侧内容区 */
  1136. .right-content {{
  1137. flex: 1;
  1138. overflow-y: auto;
  1139. background: #f5f5f5;
  1140. padding: 0;
  1141. position: relative;
  1142. }}
  1143. /* 吸顶面包屑导航 */
  1144. .breadcrumb-nav {{
  1145. position: sticky;
  1146. top: 0;
  1147. left: 0;
  1148. right: 0;
  1149. background: white;
  1150. border-bottom: 2px solid #e5e7eb;
  1151. padding: 12px 30px;
  1152. display: flex;
  1153. align-items: center;
  1154. gap: 8px;
  1155. font-size: 14px;
  1156. z-index: 100;
  1157. box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
  1158. }}
  1159. .breadcrumb-item {{
  1160. color: #667eea;
  1161. cursor: pointer;
  1162. transition: all 0.2s;
  1163. font-weight: 500;
  1164. }}
  1165. .breadcrumb-item:hover {{
  1166. color: #5568d3;
  1167. text-decoration: underline;
  1168. }}
  1169. .breadcrumb-separator {{
  1170. color: #9ca3af;
  1171. user-select: none;
  1172. }}
  1173. .content-view {{
  1174. padding: 30px;
  1175. width: 100%;
  1176. height: 100%;
  1177. }}
  1178. .content-view.active {{
  1179. display: block !important;
  1180. }}
  1181. .post-content-wrapper {{
  1182. max-width: 1600px;
  1183. margin: 0 auto;
  1184. display: flex;
  1185. flex-direction: column;
  1186. gap: 30px;
  1187. }}
  1188. .container {{
  1189. max-width: 1600px;
  1190. margin: 0 auto;
  1191. background: white;
  1192. min-height: 100vh;
  1193. }}
  1194. .header {{
  1195. background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  1196. color: white;
  1197. padding: 20px 30px;
  1198. text-align: center;
  1199. }}
  1200. .header h1 {{
  1201. font-size: 28px;
  1202. font-weight: bold;
  1203. margin-bottom: 5px;
  1204. }}
  1205. .header p {{
  1206. font-size: 14px;
  1207. opacity: 0.9;
  1208. }}
  1209. .toc-header {{
  1210. padding: 15px 20px;
  1211. background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  1212. color: white;
  1213. font-weight: 600;
  1214. font-size: 16px;
  1215. border-bottom: 2px solid #5a67d8;
  1216. }}
  1217. .toc-content {{
  1218. padding: 10px;
  1219. overflow-y: auto;
  1220. flex: 1;
  1221. }}
  1222. .toc-level-0 {{
  1223. color: #111827;
  1224. font-weight: 700;
  1225. font-size: 15px;
  1226. background: #f9fafb;
  1227. margin-top: 8px;
  1228. margin-bottom: 4px;
  1229. }}
  1230. .toc-post-header {{
  1231. font-weight: 700;
  1232. }}
  1233. .toc-badge-info {{
  1234. background: #dbeafe;
  1235. color: #1e40af;
  1236. }}
  1237. .toc-item {{
  1238. padding: 10px 15px;
  1239. margin: 4px 0;
  1240. cursor: pointer;
  1241. border-radius: 6px;
  1242. font-size: 14px;
  1243. transition: all 0.2s;
  1244. user-select: none;
  1245. display: flex;
  1246. align-items: center;
  1247. gap: 8px;
  1248. }}
  1249. .toc-item:hover {{
  1250. background: #f9fafb;
  1251. }}
  1252. .toc-item.active {{
  1253. color: #667eea;
  1254. font-weight: 600;
  1255. }}
  1256. .toc-item.active-ancestor {{
  1257. color: #667eea;
  1258. }}
  1259. .toc-expand-icon {{
  1260. font-size: 10px;
  1261. transition: transform 0.2s;
  1262. color: #9ca3af;
  1263. flex-shrink: 0;
  1264. }}
  1265. .toc-item.collapsed .toc-expand-icon {{
  1266. transform: rotate(-90deg);
  1267. }}
  1268. .toc-item-content {{
  1269. flex: 1;
  1270. min-width: 0;
  1271. }}
  1272. /* 子项隐藏 */
  1273. .toc-children {{
  1274. display: block;
  1275. }}
  1276. .toc-children.hidden {{
  1277. display: none;
  1278. }}
  1279. .toc-level-1 {{
  1280. color: #111827;
  1281. font-weight: 500;
  1282. padding-left: 20px;
  1283. }}
  1284. .toc-level-2 {{
  1285. color: #6b7280;
  1286. font-size: 13px;
  1287. padding-left: 40px;
  1288. }}
  1289. .toc-level-3 {{
  1290. padding-left: 60px;
  1291. font-size: 12px;
  1292. color: #9ca3af;
  1293. }}
  1294. .toc-level-4 {{
  1295. padding-left: 80px;
  1296. font-size: 11px;
  1297. color: #9ca3af;
  1298. }}
  1299. .toc-feature-status {{
  1300. font-weight: 500;
  1301. font-size: 10px;
  1302. padding: 2px 6px;
  1303. border-radius: 4px;
  1304. margin-left: auto;
  1305. background: #f3f4f6;
  1306. color: #6b7280;
  1307. }}
  1308. /* 目录整体结论 */
  1309. .toc-conclusion {{
  1310. padding: 15px 20px;
  1311. margin: 10px;
  1312. border-radius: 8px;
  1313. display: flex;
  1314. align-items: center;
  1315. gap: 10px;
  1316. font-weight: 600;
  1317. font-size: 14px;
  1318. }}
  1319. .toc-conclusion.conclusion-found {{
  1320. background: #d1fae5;
  1321. color: #065f46;
  1322. border: 2px solid #10b981;
  1323. }}
  1324. .toc-conclusion.conclusion-partial {{
  1325. background: #fed7aa;
  1326. color: #92400e;
  1327. border: 2px solid #f59e0b;
  1328. }}
  1329. .toc-conclusion.conclusion-not-found {{
  1330. background: #fee2e2;
  1331. color: #991b1b;
  1332. border: 2px solid #ef4444;
  1333. }}
  1334. .conclusion-icon {{
  1335. font-size: 18px;
  1336. font-weight: 700;
  1337. }}
  1338. .conclusion-text {{
  1339. flex: 1;
  1340. }}
  1341. .toc-badge {{
  1342. display: inline-block;
  1343. font-size: 12px;
  1344. margin-right: 6px;
  1345. opacity: 0.8;
  1346. }}
  1347. .toc-group-header {{
  1348. font-weight: 600;
  1349. cursor: pointer !important;
  1350. }}
  1351. .toc-point-status {{
  1352. font-size: 10px;
  1353. font-weight: 500;
  1354. padding: 2px 6px;
  1355. border-radius: 4px;
  1356. margin-left: auto;
  1357. background: #f3f4f6;
  1358. color: #6b7280;
  1359. }}
  1360. .main-content {{
  1361. min-width: 0;
  1362. display: flex;
  1363. flex-direction: column;
  1364. gap: 30px;
  1365. }}
  1366. /* 顶部框:包含帖子详情和灵感点详情 */
  1367. .top-section-box {{
  1368. background: white;
  1369. border: 1px solid #e5e7eb;
  1370. border-radius: 12px;
  1371. padding: 25px;
  1372. box-shadow: 0 2px 8px rgba(0,0,0,0.05);
  1373. }}
  1374. /* 顶部框内的两栏布局 */
  1375. .two-column-layout {{
  1376. display: grid;
  1377. grid-template-columns: 380px 1fr;
  1378. gap: 25px;
  1379. align-items: start;
  1380. }}
  1381. .left-column {{
  1382. position: relative;
  1383. }}
  1384. .post-detail-wrapper {{
  1385. /* 帖子详情区域 */
  1386. }}
  1387. .right-column {{
  1388. min-width: 0;
  1389. }}
  1390. .inspirations-detail-wrapper {{
  1391. display: flex;
  1392. flex-direction: column;
  1393. gap: 20px;
  1394. max-height: 600px;
  1395. overflow-y: auto;
  1396. padding-right: 10px;
  1397. }}
  1398. .inspiration-detail-item {{
  1399. /* 单个灵感点详情 */
  1400. }}
  1401. /* 匹配结果区域 */
  1402. .matches-section {{
  1403. display: flex;
  1404. flex-direction: column;
  1405. gap: 20px;
  1406. }}
  1407. /* 步骤区域 */
  1408. .step-section {{
  1409. background: white;
  1410. border: 2px solid #e5e7eb;
  1411. border-radius: 12px;
  1412. overflow: hidden;
  1413. box-shadow: 0 2px 8px rgba(0,0,0,0.05);
  1414. transition: all 0.3s ease;
  1415. }}
  1416. .step-section:hover {{
  1417. box-shadow: 0 4px 12px rgba(0,0,0,0.08);
  1418. border-color: #d1d5db;
  1419. }}
  1420. .step-header {{
  1421. padding: 18px 24px;
  1422. background: linear-gradient(135deg, #f9fafb 0%, #ffffff 100%);
  1423. border-bottom: 2px solid #e5e7eb;
  1424. display: flex;
  1425. justify-content: space-between;
  1426. align-items: center;
  1427. cursor: pointer;
  1428. user-select: none;
  1429. transition: all 0.3s ease;
  1430. }}
  1431. .step-header:hover {{
  1432. background: linear-gradient(135deg, #f3f4f6 0%, #f9fafb 100%);
  1433. }}
  1434. .step-name {{
  1435. font-size: 18px;
  1436. color: #111827;
  1437. font-weight: 600;
  1438. margin: 0;
  1439. }}
  1440. .step-inspiration-name {{
  1441. font-size: 14px;
  1442. color: #6b7280;
  1443. font-style: italic;
  1444. }}
  1445. .step-features-list {{
  1446. padding: 20px;
  1447. display: flex;
  1448. flex-direction: column;
  1449. gap: 15px;
  1450. }}
  1451. .feature-match-wrapper {{
  1452. /* 特征匹配容器 */
  1453. }}
  1454. .content-section {{
  1455. scroll-margin-top: 80px;
  1456. }}
  1457. /* 帖子详情卡片(紧凑样式) */
  1458. .post-card-compact {{
  1459. display: grid;
  1460. grid-template-columns: 200px 1fr;
  1461. gap: 20px;
  1462. background: white;
  1463. border: 1px solid #e5e7eb;
  1464. border-radius: 12px;
  1465. overflow: hidden;
  1466. padding: 20px;
  1467. cursor: pointer;
  1468. transition: all 0.3s;
  1469. margin-bottom: 30px;
  1470. }}
  1471. .post-card-compact:hover {{
  1472. box-shadow: 0 4px 12px rgba(0,0,0,0.1);
  1473. transform: translateY(-2px);
  1474. border-color: #667eea;
  1475. }}
  1476. .post-card-image {{
  1477. position: relative;
  1478. display: flex;
  1479. align-items: center;
  1480. justify-content: center;
  1481. }}
  1482. .post-card-thumbnail {{
  1483. width: 100%;
  1484. height: 150px;
  1485. object-fit: contain;
  1486. border-radius: 8px;
  1487. background: #f9fafb;
  1488. }}
  1489. .post-card-thumbnail-placeholder {{
  1490. width: 100%;
  1491. height: 150px;
  1492. display: flex;
  1493. align-items: center;
  1494. justify-content: center;
  1495. background: #f3f4f6;
  1496. color: #9ca3af;
  1497. font-size: 48px;
  1498. border-radius: 8px;
  1499. }}
  1500. .post-card-image-count {{
  1501. position: absolute;
  1502. bottom: 8px;
  1503. right: 8px;
  1504. background: rgba(0, 0, 0, 0.7);
  1505. color: white;
  1506. padding: 4px 8px;
  1507. border-radius: 12px;
  1508. font-size: 11px;
  1509. font-weight: 600;
  1510. }}
  1511. .post-card-content {{
  1512. display: flex;
  1513. flex-direction: column;
  1514. gap: 12px;
  1515. }}
  1516. .post-card-title {{
  1517. font-size: 18px;
  1518. font-weight: 600;
  1519. color: #111827;
  1520. line-height: 1.4;
  1521. overflow: hidden;
  1522. text-overflow: ellipsis;
  1523. display: -webkit-box;
  1524. -webkit-line-clamp: 2;
  1525. -webkit-box-orient: vertical;
  1526. }}
  1527. .post-card-preview {{
  1528. font-size: 14px;
  1529. color: #6b7280;
  1530. line-height: 1.6;
  1531. overflow: hidden;
  1532. text-overflow: ellipsis;
  1533. display: -webkit-box;
  1534. -webkit-line-clamp: 2;
  1535. -webkit-box-orient: vertical;
  1536. }}
  1537. .post-card-meta {{
  1538. display: flex;
  1539. gap: 15px;
  1540. font-size: 13px;
  1541. color: #9ca3af;
  1542. }}
  1543. .post-card-stats {{
  1544. display: flex;
  1545. gap: 15px;
  1546. font-size: 13px;
  1547. color: #6b7280;
  1548. font-weight: 500;
  1549. }}
  1550. /* 灵感点详情卡片样式保持不变 */
  1551. .inspiration-detail-card {{
  1552. background: linear-gradient(135deg, #f9fafb 0%, #ffffff 100%);
  1553. padding: 25px;
  1554. border-bottom: 2px solid #e5e7eb;
  1555. }}
  1556. .inspiration-header {{
  1557. margin-bottom: 15px;
  1558. display: flex;
  1559. align-items: center;
  1560. gap: 10px;
  1561. flex-wrap: wrap;
  1562. }}
  1563. .inspiration-conclusion {{
  1564. padding: 6px 14px;
  1565. border-radius: 16px;
  1566. font-size: 13px;
  1567. font-weight: 700;
  1568. margin-left: auto;
  1569. }}
  1570. .inspiration-conclusion.insp-conclusion-found {{
  1571. background: #d1fae5;
  1572. color: #065f46;
  1573. border: 2px solid #10b981;
  1574. }}
  1575. .inspiration-conclusion.insp-conclusion-partial {{
  1576. background: #fed7aa;
  1577. color: #92400e;
  1578. border: 2px solid #f59e0b;
  1579. }}
  1580. .inspiration-conclusion.insp-conclusion-not-found {{
  1581. background: #fee2e2;
  1582. color: #991b1b;
  1583. border: 2px solid #ef4444;
  1584. }}
  1585. .inspiration-type-badge {{
  1586. display: inline-block;
  1587. padding: 4px 12px;
  1588. background: #fef3c7;
  1589. color: #92400e;
  1590. border-radius: 12px;
  1591. font-size: 12px;
  1592. font-weight: 600;
  1593. }}
  1594. .inspiration-name {{
  1595. font-size: 20px;
  1596. color: #111827;
  1597. font-weight: 600;
  1598. margin: 0;
  1599. }}
  1600. .inspiration-description {{
  1601. margin-bottom: 15px;
  1602. }}
  1603. .desc-label {{
  1604. font-weight: 600;
  1605. color: #6b7280;
  1606. font-size: 13px;
  1607. margin-bottom: 8px;
  1608. }}
  1609. .desc-text {{
  1610. color: #4b5563;
  1611. font-size: 14px;
  1612. line-height: 1.7;
  1613. }}
  1614. .inspiration-features {{
  1615. margin-top: 15px;
  1616. }}
  1617. .features-label {{
  1618. font-weight: 600;
  1619. color: #6b7280;
  1620. font-size: 13px;
  1621. margin-bottom: 8px;
  1622. }}
  1623. .features-tags {{
  1624. display: flex;
  1625. flex-wrap: wrap;
  1626. gap: 8px;
  1627. }}
  1628. .feature-tag {{
  1629. padding: 5px 12px;
  1630. background: #667eea;
  1631. color: white;
  1632. border-radius: 16px;
  1633. font-size: 13px;
  1634. font-weight: 500;
  1635. display: inline-flex;
  1636. align-items: center;
  1637. gap: 4px;
  1638. }}
  1639. .feature-tag.feature-same {{
  1640. background: #10b981;
  1641. }}
  1642. .feature-tag.feature-similar {{
  1643. background: #f59e0b;
  1644. }}
  1645. .feature-tag.feature-unrelated {{
  1646. background: #9ca3af;
  1647. }}
  1648. .feature-status-label {{
  1649. font-weight: 700;
  1650. font-size: 11px;
  1651. padding: 2px 6px;
  1652. background: rgba(255, 255, 255, 0.3);
  1653. border-radius: 4px;
  1654. }}
  1655. /* 匹配结果部分 */
  1656. .match-results-section {{
  1657. padding: 48px 25px;
  1658. border-bottom: none;
  1659. }}
  1660. .match-results-section:first-child {{
  1661. padding-top: 25px;
  1662. }}
  1663. .match-section-header {{
  1664. display: flex;
  1665. justify-content: space-between;
  1666. align-items: center;
  1667. padding: 10px 0 24px 0;
  1668. background: transparent;
  1669. border: none;
  1670. border-bottom: 1px solid #e5e7eb;
  1671. border-radius: 0;
  1672. gap: 12px;
  1673. cursor: pointer;
  1674. user-select: none;
  1675. transition: all 0.2s ease;
  1676. }}
  1677. .match-section-header:hover {{
  1678. border-bottom-color: #9ca3af;
  1679. }}
  1680. .header-left {{
  1681. display: flex;
  1682. align-items: center;
  1683. gap: 10px;
  1684. flex: 1;
  1685. }}
  1686. .collapsible-header {{
  1687. cursor: pointer;
  1688. user-select: none;
  1689. transition: background 0.2s;
  1690. padding: 10px;
  1691. margin: -10px;
  1692. border-radius: 6px;
  1693. }}
  1694. .collapsible-header:hover {{
  1695. background: #f9fafb;
  1696. }}
  1697. .overall-top-match {{
  1698. display: inline-flex;
  1699. align-items: center;
  1700. gap: 8px;
  1701. padding: 6px 12px;
  1702. background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%);
  1703. border-radius: 8px;
  1704. font-size: 13px;
  1705. border: 1px solid #bae6fd;
  1706. flex-shrink: 0;
  1707. }}
  1708. .overall-top-label {{
  1709. font-weight: 700;
  1710. color: #0369a1;
  1711. }}
  1712. .overall-top-combined {{
  1713. padding: 3px 8px;
  1714. background: linear-gradient(135deg, #e0e7ff 0%, #fef3c7 100%);
  1715. color: #4338ca;
  1716. border-radius: 6px;
  1717. font-size: 11px;
  1718. font-weight: 600;
  1719. border: 1px solid #c7d2fe;
  1720. }}
  1721. .overall-top-name {{
  1722. font-weight: 700;
  1723. color: #1e40af;
  1724. }}
  1725. .overall-top-score {{
  1726. padding: 4px 12px;
  1727. border-radius: 12px;
  1728. font-size: 13px;
  1729. font-weight: 700;
  1730. margin-left: auto;
  1731. }}
  1732. .match-section-header .header-left {{
  1733. display: flex;
  1734. align-items: center;
  1735. gap: 10px;
  1736. flex: 1;
  1737. }}
  1738. .match-section-header h4 {{
  1739. font-size: 18px;
  1740. color: #111827;
  1741. margin: 0;
  1742. }}
  1743. .match-stats {{
  1744. display: flex;
  1745. gap: 10px;
  1746. flex-wrap: wrap;
  1747. }}
  1748. .stat-badge {{
  1749. padding: 4px 10px;
  1750. border-radius: 12px;
  1751. color: white;
  1752. font-size: 12px;
  1753. font-weight: 600;
  1754. }}
  1755. .matches-list {{
  1756. display: flex;
  1757. flex-direction: column;
  1758. gap: 8px;
  1759. transition: all 0.3s ease;
  1760. overflow: hidden;
  1761. }}
  1762. .match-group-section {{
  1763. margin-bottom: 20px;
  1764. background: white;
  1765. border: 1px solid #e5e7eb;
  1766. border-radius: 8px;
  1767. overflow: hidden;
  1768. }}
  1769. .match-group-header {{
  1770. padding: 12px 18px;
  1771. background: linear-gradient(135deg, #f3f4f6 0%, #e5e7eb 100%);
  1772. border-bottom: 2px solid #d1d5db;
  1773. cursor: pointer;
  1774. user-select: none;
  1775. display: flex;
  1776. align-items: center;
  1777. gap: 10px;
  1778. transition: background 0.2s;
  1779. }}
  1780. .match-group-header:hover {{
  1781. background: linear-gradient(135deg, #e5e7eb 0%, #d1d5db 100%);
  1782. }}
  1783. .match-group-title {{
  1784. font-size: 15px;
  1785. font-weight: 600;
  1786. color: #374151;
  1787. margin: 0;
  1788. }}
  1789. .match-group-content {{
  1790. padding: 15px;
  1791. display: flex;
  1792. flex-direction: row;
  1793. flex-wrap: wrap;
  1794. gap: 10px;
  1795. }}
  1796. /* 层级分组样式 - 极简无边框 */
  1797. .level-group-section {{
  1798. background: transparent;
  1799. border: none;
  1800. border-radius: 0;
  1801. overflow: visible;
  1802. }}
  1803. .level-group-header {{
  1804. padding: 20px 0 16px 0;
  1805. background: transparent;
  1806. border-bottom: 1px solid #e5e7eb;
  1807. cursor: pointer;
  1808. user-select: none;
  1809. display: flex;
  1810. justify-content: space-between;
  1811. align-items: center;
  1812. transition: all 0.2s ease;
  1813. }}
  1814. .level-group-header:hover {{
  1815. border-bottom-color: #9ca3af;
  1816. }}
  1817. .level-group-header .expand-icon {{
  1818. color: #6b7280;
  1819. font-size: 13px;
  1820. }}
  1821. .level-header-left {{
  1822. display: flex;
  1823. align-items: center;
  1824. gap: 8px;
  1825. }}
  1826. .level-group-title {{
  1827. font-size: 15px;
  1828. font-weight: 600;
  1829. color: #111827;
  1830. margin: 0;
  1831. }}
  1832. .level-stats {{
  1833. display: inline-flex;
  1834. gap: 8px;
  1835. align-items: center;
  1836. }}
  1837. .level-top-match {{
  1838. display: inline-flex;
  1839. align-items: center;
  1840. gap: 6px;
  1841. font-size: 12px;
  1842. color: #6b7280;
  1843. }}
  1844. .top-match-label {{
  1845. font-weight: 500;
  1846. color: #9ca3af;
  1847. }}
  1848. .top-match-type {{
  1849. padding: 2px 6px;
  1850. background: #f3f4f6;
  1851. border-radius: 4px;
  1852. font-size: 11px;
  1853. font-weight: 500;
  1854. color: #6b7280;
  1855. }}
  1856. .top-match-name {{
  1857. font-weight: 500;
  1858. color: #374151;
  1859. }}
  1860. .top-match-score {{
  1861. padding: 3px 10px;
  1862. border-radius: 12px;
  1863. font-size: 12px;
  1864. font-weight: 700;
  1865. margin-left: auto;
  1866. }}
  1867. .level-group-content {{
  1868. padding: 0;
  1869. background: transparent;
  1870. transition: all 0.3s ease;
  1871. overflow: hidden;
  1872. }}
  1873. .level-group-content.collapsed {{
  1874. display: none;
  1875. }}
  1876. /* 子分组样式 - 极简 */
  1877. .match-subgroup-section {{
  1878. display: block;
  1879. width: 100%;
  1880. background: transparent;
  1881. border-radius: 0;
  1882. overflow: visible;
  1883. }}
  1884. .match-subgroup-header {{
  1885. padding: 12px 0 8px 0;
  1886. background: transparent;
  1887. cursor: pointer;
  1888. user-select: none;
  1889. display: flex;
  1890. justify-content: space-between;
  1891. align-items: center;
  1892. transition: all 0.2s ease;
  1893. border-bottom: 1px solid #f3f4f6;
  1894. }}
  1895. .match-subgroup-header:hover {{
  1896. border-bottom-color: #e5e7eb;
  1897. }}
  1898. .subgroup-header-left {{
  1899. display: flex;
  1900. align-items: center;
  1901. gap: 8px;
  1902. }}
  1903. .match-subgroup-title {{
  1904. font-size: 13px;
  1905. font-weight: 600;
  1906. color: #374151;
  1907. margin: 0;
  1908. }}
  1909. .subgroup-top-match {{
  1910. display: inline-flex;
  1911. align-items: center;
  1912. gap: 6px;
  1913. font-size: 11px;
  1914. color: #6b7280;
  1915. }}
  1916. .subgroup-top-label {{
  1917. font-weight: 500;
  1918. color: #9ca3af;
  1919. }}
  1920. .subgroup-top-combined {{
  1921. padding: 2px 6px;
  1922. background: #f3f4f6;
  1923. color: #6b7280;
  1924. border-radius: 4px;
  1925. font-size: 10px;
  1926. font-weight: 500;
  1927. }}
  1928. .subgroup-top-name {{
  1929. font-weight: 500;
  1930. color: #374151;
  1931. }}
  1932. .subgroup-top-score {{
  1933. padding: 2px 8px;
  1934. border-radius: 10px;
  1935. font-size: 11px;
  1936. font-weight: 600;
  1937. }}
  1938. .match-subgroup-content {{
  1939. padding: 0;
  1940. display: flex;
  1941. flex-direction: row;
  1942. flex-wrap: wrap;
  1943. gap: 8px;
  1944. transition: all 0.3s ease;
  1945. overflow: hidden;
  1946. background: transparent;
  1947. }}
  1948. .match-item-compact {{
  1949. display: inline-flex;
  1950. align-items: center;
  1951. gap: 6px;
  1952. padding: 8px 14px;
  1953. background: transparent;
  1954. border: 1.5px solid #e5e7eb;
  1955. border-radius: 8px;
  1956. font-size: 13px;
  1957. transition: all 0.3s ease;
  1958. cursor: pointer;
  1959. }}
  1960. .match-item-compact:hover {{
  1961. border-color: #667eea;
  1962. box-shadow: 0 4px 8px rgba(102, 126, 234, 0.15);
  1963. transform: translateY(-2px);
  1964. background: #fafbff;
  1965. }}
  1966. .match-item-compact:active {{
  1967. transform: translateY(0);
  1968. }}
  1969. /* 匹配项中的层级-类型标签 */
  1970. .match-item-compact .feature-combined-badge {{
  1971. color: #9ca3af;
  1972. }}
  1973. /* 匹配详情模态框 */
  1974. .match-modal {{
  1975. display: none;
  1976. position: fixed;
  1977. z-index: 3000;
  1978. left: 0;
  1979. top: 0;
  1980. width: 100%;
  1981. height: 100%;
  1982. background: rgba(0, 0, 0, 0.5);
  1983. animation: fadeIn 0.2s;
  1984. }}
  1985. .match-modal-content {{
  1986. position: relative;
  1987. background: white;
  1988. margin: 5% auto;
  1989. padding: 0;
  1990. width: 80%;
  1991. max-width: 900px;
  1992. max-height: 80vh;
  1993. border-radius: 12px;
  1994. box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
  1995. overflow: hidden;
  1996. animation: slideDown 0.3s;
  1997. }}
  1998. .match-modal-header {{
  1999. background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  2000. color: white;
  2001. padding: 20px 25px;
  2002. display: flex;
  2003. align-items: center;
  2004. justify-content: space-between;
  2005. }}
  2006. .match-modal-title {{
  2007. font-size: 18px;
  2008. font-weight: 600;
  2009. margin: 0;
  2010. }}
  2011. .match-modal-close {{
  2012. color: white;
  2013. font-size: 32px;
  2014. font-weight: 300;
  2015. cursor: pointer;
  2016. background: none;
  2017. border: none;
  2018. padding: 0;
  2019. line-height: 1;
  2020. transition: transform 0.2s;
  2021. }}
  2022. .match-modal-close:hover {{
  2023. transform: scale(1.2);
  2024. }}
  2025. .match-modal-body {{
  2026. padding: 25px;
  2027. max-height: calc(80vh - 80px);
  2028. overflow-y: auto;
  2029. }}
  2030. .match-detail-section {{
  2031. margin-bottom: 25px;
  2032. }}
  2033. .match-detail-section h3 {{
  2034. font-size: 16px;
  2035. font-weight: 600;
  2036. color: #374151;
  2037. margin-bottom: 12px;
  2038. padding-bottom: 8px;
  2039. border-bottom: 2px solid #e5e7eb;
  2040. }}
  2041. .match-explanation-text {{
  2042. line-height: 1.8;
  2043. color: #4b5563;
  2044. padding: 15px;
  2045. background: #f9fafb;
  2046. border-radius: 8px;
  2047. border-left: 4px solid #667eea;
  2048. }}
  2049. .match-item-collapsible {{
  2050. border: 1px solid #e5e7eb;
  2051. border-radius: 8px;
  2052. overflow: hidden;
  2053. background: white;
  2054. transition: box-shadow 0.2s;
  2055. }}
  2056. .match-item-collapsible:hover {{
  2057. box-shadow: 0 2px 8px rgba(0,0,0,0.08);
  2058. }}
  2059. .match-header {{
  2060. padding: 12px 16px;
  2061. cursor: pointer;
  2062. user-select: none;
  2063. transition: background 0.2s;
  2064. }}
  2065. .match-header:hover {{
  2066. background: #f9fafb;
  2067. }}
  2068. .match-header-left {{
  2069. display: flex;
  2070. align-items: center;
  2071. gap: 10px;
  2072. }}
  2073. .expand-icon {{
  2074. color: #9ca3af;
  2075. font-size: 14px;
  2076. transition: transform 0.3s ease;
  2077. display: inline-flex;
  2078. align-items: center;
  2079. justify-content: center;
  2080. width: 20px;
  2081. height: 20px;
  2082. flex-shrink: 0;
  2083. }}
  2084. .expand-icon.expanded {{
  2085. transform: rotate(0deg);
  2086. }}
  2087. /* 折叠状态时旋转 */
  2088. .collapsed .expand-icon {{
  2089. transform: rotate(-90deg);
  2090. }}
  2091. .persona-name {{
  2092. font-weight: 600;
  2093. font-size: 14px;
  2094. color: #111827;
  2095. }}
  2096. .match-item-compact .persona-name {{
  2097. font-weight: 500;
  2098. font-size: 13px;
  2099. color: #374151;
  2100. }}
  2101. .relation-badge {{
  2102. padding: 3px 10px;
  2103. border-radius: 12px;
  2104. color: white;
  2105. font-size: 11px;
  2106. font-weight: 600;
  2107. }}
  2108. .score-badge {{
  2109. padding: 3px 10px;
  2110. border-radius: 12px;
  2111. background: #e5e7eb;
  2112. color: #374151;
  2113. font-size: 11px;
  2114. font-weight: 600;
  2115. }}
  2116. .feature-combined-badge {{
  2117. padding: 3px 8px;
  2118. border-radius: 10px;
  2119. background: transparent;
  2120. color: #9ca3af;
  2121. font-size: 10px;
  2122. font-weight: 500;
  2123. border: none;
  2124. }}
  2125. .feature-combined-badge.combined-badge-same {{
  2126. background: transparent;
  2127. color: #9ca3af;
  2128. border: none;
  2129. box-shadow: none;
  2130. font-weight: 500;
  2131. }}
  2132. .feature-category-badge {{
  2133. padding: 3px 8px;
  2134. border-radius: 10px;
  2135. background: #dbeafe;
  2136. color: #1e40af;
  2137. font-size: 10px;
  2138. font-weight: 500;
  2139. border: 1px solid #93c5fd;
  2140. }}
  2141. /* 同层级匹配 - 统一边框颜色 */
  2142. .match-item-compact.match-same-level {{
  2143. border: 1.5px solid #e5e7eb;
  2144. background: transparent;
  2145. box-shadow: none;
  2146. }}
  2147. .match-item-compact.match-same-level:hover {{
  2148. border-color: #667eea;
  2149. box-shadow: 0 4px 8px rgba(102, 126, 234, 0.15);
  2150. transform: translateY(-2px);
  2151. background: #fafbff;
  2152. }}
  2153. .level-badge {{
  2154. padding: 4px 10px;
  2155. border-radius: 12px;
  2156. font-size: 11px;
  2157. font-weight: 600;
  2158. margin-right: 6px;
  2159. }}
  2160. .level-badge-match {{
  2161. background: #e0e7ff;
  2162. color: #4338ca;
  2163. border: 1px solid #a5b4fc;
  2164. }}
  2165. .level-badge-category {{
  2166. background: #fef3c7;
  2167. color: #92400e;
  2168. border: 1px solid #fcd34d;
  2169. }}
  2170. .feature-match-status {{
  2171. padding: 5px 12px;
  2172. border-radius: 12px;
  2173. font-size: 12px;
  2174. font-weight: 700;
  2175. margin-left: 12px;
  2176. }}
  2177. .feature-match-status.status-same {{
  2178. background: #d1fae5;
  2179. color: #065f46;
  2180. border: 2px solid #10b981;
  2181. }}
  2182. .feature-match-status.status-similar {{
  2183. background: #fed7aa;
  2184. color: #92400e;
  2185. border: 2px solid #f59e0b;
  2186. }}
  2187. .feature-match-status.status-unrelated {{
  2188. background: #e5e7eb;
  2189. color: #4b5563;
  2190. border: 2px solid #9ca3af;
  2191. }}
  2192. .match-content {{
  2193. padding: 16px;
  2194. background: #f9fafb;
  2195. border-top: 1px solid #e5e7eb;
  2196. }}
  2197. .match-explanation {{
  2198. font-size: 13px;
  2199. color: #4b5563;
  2200. line-height: 1.7;
  2201. margin-bottom: 16px;
  2202. }}
  2203. /* 历史帖子来源区域 */
  2204. .historical-posts-section {{
  2205. margin-top: 20px;
  2206. padding-top: 20px;
  2207. border-top: 2px solid #e5e7eb;
  2208. }}
  2209. .historical-posts-title {{
  2210. font-size: 14px;
  2211. font-weight: 600;
  2212. color: #374151;
  2213. margin-bottom: 12px;
  2214. }}
  2215. .historical-posts-grid {{
  2216. display: grid;
  2217. grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
  2218. gap: 16px;
  2219. }}
  2220. .historical-post-card {{
  2221. display: grid;
  2222. grid-template-columns: 120px 1fr;
  2223. gap: 12px;
  2224. background: white;
  2225. border: 1px solid #e5e7eb;
  2226. border-radius: 8px;
  2227. padding: 12px;
  2228. transition: all 0.2s;
  2229. cursor: pointer;
  2230. }}
  2231. .historical-post-card:hover {{
  2232. border-color: #667eea;
  2233. box-shadow: 0 2px 8px rgba(102, 126, 234, 0.1);
  2234. transform: translateY(-2px);
  2235. }}
  2236. .historical-post-image {{
  2237. position: relative;
  2238. }}
  2239. .historical-post-thumbnail {{
  2240. width: 100%;
  2241. height: 100px;
  2242. object-fit: contain;
  2243. border-radius: 6px;
  2244. background: #f9fafb;
  2245. }}
  2246. .historical-post-content {{
  2247. display: flex;
  2248. flex-direction: column;
  2249. gap: 8px;
  2250. }}
  2251. .historical-post-title {{
  2252. font-size: 13px;
  2253. font-weight: 600;
  2254. color: #1f2937;
  2255. line-height: 1.4;
  2256. overflow: hidden;
  2257. display: -webkit-box;
  2258. -webkit-line-clamp: 2;
  2259. -webkit-box-orient: vertical;
  2260. }}
  2261. .historical-inspiration-info {{
  2262. display: flex;
  2263. align-items: center;
  2264. gap: 6px;
  2265. }}
  2266. .inspiration-type-badge-small {{
  2267. padding: 2px 6px;
  2268. border-radius: 4px;
  2269. background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
  2270. color: white;
  2271. font-size: 10px;
  2272. font-weight: 600;
  2273. }}
  2274. .inspiration-name-small {{
  2275. font-size: 11px;
  2276. color: #667eea;
  2277. font-weight: 500;
  2278. }}
  2279. .historical-inspiration-desc {{
  2280. font-size: 11px;
  2281. color: #6b7280;
  2282. line-height: 1.5;
  2283. overflow: hidden;
  2284. display: -webkit-box;
  2285. -webkit-line-clamp: 2;
  2286. -webkit-box-orient: vertical;
  2287. }}
  2288. .historical-post-meta {{
  2289. margin: 4px 0;
  2290. }}
  2291. .historical-post-time {{
  2292. font-size: 11px;
  2293. color: #9ca3af;
  2294. }}
  2295. .historical-post-stats {{
  2296. display: flex;
  2297. align-items: center;
  2298. gap: 10px;
  2299. font-size: 11px;
  2300. color: #9ca3af;
  2301. }}
  2302. .view-link {{
  2303. margin-left: auto;
  2304. color: #667eea;
  2305. text-decoration: none;
  2306. font-weight: 500;
  2307. }}
  2308. .view-link:hover {{
  2309. color: #764ba2;
  2310. }}
  2311. .category-simple {{
  2312. color: #9ca3af;
  2313. font-size: 12px;
  2314. font-weight: 400;
  2315. margin-right: 6px;
  2316. }}
  2317. /* 帖子详情模态框 */
  2318. .post-detail-modal {{
  2319. display: none;
  2320. position: fixed;
  2321. z-index: 4000;
  2322. left: 0;
  2323. top: 0;
  2324. width: 100%;
  2325. height: 100%;
  2326. background: rgba(0, 0, 0, 0.7);
  2327. overflow: auto;
  2328. animation: fadeIn 0.3s;
  2329. }}
  2330. .post-detail-modal.active {{
  2331. display: flex;
  2332. align-items: center;
  2333. justify-content: center;
  2334. padding: 40px 20px;
  2335. }}
  2336. .post-detail-content {{
  2337. position: relative;
  2338. background: white;
  2339. border-radius: 16px;
  2340. max-width: 900px;
  2341. width: 100%;
  2342. max-height: 90vh;
  2343. overflow-y: auto;
  2344. box-shadow: 0 20px 60px rgba(0,0,0,0.3);
  2345. }}
  2346. .post-detail-close {{
  2347. position: sticky;
  2348. top: 20px;
  2349. right: 20px;
  2350. float: right;
  2351. font-size: 36px;
  2352. font-weight: 300;
  2353. color: #9ca3af;
  2354. background: white;
  2355. border: none;
  2356. cursor: pointer;
  2357. width: 40px;
  2358. height: 40px;
  2359. border-radius: 50%;
  2360. display: flex;
  2361. align-items: center;
  2362. justify-content: center;
  2363. transition: all 0.2s;
  2364. z-index: 10;
  2365. box-shadow: 0 2px 8px rgba(0,0,0,0.1);
  2366. }}
  2367. .post-detail-close:hover {{
  2368. color: #ef4444;
  2369. background: #fee2e2;
  2370. transform: rotate(90deg);
  2371. }}
  2372. .post-detail-header {{
  2373. padding: 40px 40px 20px 40px;
  2374. border-bottom: 2px solid #e5e7eb;
  2375. }}
  2376. .post-detail-title {{
  2377. font-size: 28px;
  2378. font-weight: bold;
  2379. color: #111827;
  2380. line-height: 1.4;
  2381. margin-bottom: 15px;
  2382. }}
  2383. .post-detail-meta {{
  2384. display: flex;
  2385. justify-content: space-between;
  2386. align-items: center;
  2387. color: #6b7280;
  2388. font-size: 14px;
  2389. gap: 20px;
  2390. }}
  2391. .post-detail-author {{
  2392. font-weight: 500;
  2393. }}
  2394. .post-detail-time {{
  2395. color: #9ca3af;
  2396. }}
  2397. .post-detail-stats {{
  2398. display: flex;
  2399. gap: 15px;
  2400. font-weight: 500;
  2401. }}
  2402. .post-detail-body {{
  2403. padding: 30px 40px;
  2404. }}
  2405. .post-detail-desc {{
  2406. font-size: 15px;
  2407. color: #4b5563;
  2408. line-height: 1.8;
  2409. margin-bottom: 25px;
  2410. white-space: pre-wrap;
  2411. }}
  2412. .post-detail-images {{
  2413. display: grid;
  2414. grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
  2415. gap: 15px;
  2416. margin-top: 20px;
  2417. }}
  2418. .post-detail-image {{
  2419. width: 100%;
  2420. border-radius: 8px;
  2421. object-fit: cover;
  2422. cursor: pointer;
  2423. transition: transform 0.2s;
  2424. }}
  2425. .post-detail-image:hover {{
  2426. transform: scale(1.02);
  2427. }}
  2428. .post-detail-footer {{
  2429. padding: 20px 40px 30px 40px;
  2430. border-top: 1px solid #e5e7eb;
  2431. text-align: center;
  2432. }}
  2433. .post-detail-link {{
  2434. display: inline-block;
  2435. padding: 12px 30px;
  2436. background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  2437. color: white;
  2438. text-decoration: none;
  2439. border-radius: 8px;
  2440. font-weight: 500;
  2441. transition: all 0.3s;
  2442. }}
  2443. .post-detail-link:hover {{
  2444. transform: translateY(-2px);
  2445. box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
  2446. }}
  2447. /* 响应式 */
  2448. @media (max-width: 1400px) {{
  2449. .content-with-toc {{
  2450. grid-template-columns: 240px 1fr;
  2451. }}
  2452. .two-column-layout {{
  2453. grid-template-columns: 320px 1fr;
  2454. }}
  2455. }}
  2456. @media (max-width: 1200px) {{
  2457. .content-with-toc {{
  2458. grid-template-columns: 200px 1fr;
  2459. }}
  2460. .two-column-layout {{
  2461. grid-template-columns: 280px 1fr;
  2462. }}
  2463. }}
  2464. @media (max-width: 1024px) {{
  2465. .page-container {{
  2466. flex-direction: column;
  2467. height: auto;
  2468. }}
  2469. .left-sidebar {{
  2470. width: 100%;
  2471. max-height: 300px;
  2472. border-right: none;
  2473. border-bottom: 2px solid #e5e7eb;
  2474. }}
  2475. .right-content {{
  2476. height: auto;
  2477. }}
  2478. .two-column-layout {{
  2479. grid-template-columns: 1fr;
  2480. }}
  2481. .inspirations-detail-wrapper {{
  2482. max-height: none;
  2483. }}
  2484. .post-card-compact {{
  2485. grid-template-columns: 150px 1fr;
  2486. }}
  2487. }}
  2488. @media (max-width: 768px) {{
  2489. .header {{
  2490. padding: 15px 20px;
  2491. }}
  2492. .header h1 {{
  2493. font-size: 24px;
  2494. }}
  2495. .left-sidebar {{
  2496. max-height: 250px;
  2497. }}
  2498. .post-info-section {{
  2499. padding: 20px;
  2500. }}
  2501. }}
  2502. </style>
  2503. </head>
  2504. <body>
  2505. <div class="page-container">
  2506. <!-- 左侧目录 -->
  2507. <div class="left-sidebar">
  2508. <div class="header">
  2509. <h1>How 解构结果</h1>
  2510. <p>灵感点特征匹配分析</p>
  2511. </div>
  2512. <div class="toc-content">
  2513. {toc_items_html}
  2514. </div>
  2515. </div>
  2516. <!-- 右侧内容 -->
  2517. <div class="right-content">
  2518. <!-- 吸顶面包屑导航 -->
  2519. <div class="breadcrumb-nav" id="breadcrumb-nav">
  2520. <span class="breadcrumb-item" id="breadcrumb-point">-</span>
  2521. <span class="breadcrumb-separator" id="sep-1" style="display:none;">></span>
  2522. <span class="breadcrumb-item" id="breadcrumb-step" style="display:none;">-</span>
  2523. <span class="breadcrumb-separator" id="sep-2" style="display:none;">></span>
  2524. <span class="breadcrumb-item" id="breadcrumb-feature" style="display:none;">-</span>
  2525. <span class="breadcrumb-separator" id="sep-3" style="display:none;">></span>
  2526. <span class="breadcrumb-item" id="breadcrumb-level" style="display:none;">-</span>
  2527. <span class="breadcrumb-separator" id="sep-4" style="display:none;">></span>
  2528. <span class="breadcrumb-item" id="breadcrumb-subgroup" style="display:none;">-</span>
  2529. </div>
  2530. {all_contents_html}
  2531. </div>
  2532. </div>
  2533. <!-- 匹配详情模态框 -->
  2534. <div id="matchModal" class="match-modal" onclick="closeMatchModal(event)">
  2535. <div class="match-modal-content" onclick="event.stopPropagation()">
  2536. <div class="match-modal-header">
  2537. <h2 class="match-modal-title" id="matchModalTitle"></h2>
  2538. <button class="match-modal-close" onclick="closeMatchModal()">&times;</button>
  2539. </div>
  2540. <div class="match-modal-body" id="matchModalBody"></div>
  2541. </div>
  2542. </div>
  2543. <!-- 帖子详情模态框 -->
  2544. <div id="postDetailModal" class="post-detail-modal" onclick="closePostDetail(event)"></div>
  2545. <script>
  2546. // 切换内容视图
  2547. function showContentView(viewId, clickedElement) {{
  2548. // 隐藏所有视图
  2549. var allViews = document.querySelectorAll('.content-view');
  2550. allViews.forEach(function(view) {{
  2551. view.style.display = 'none';
  2552. view.classList.remove('active');
  2553. }});
  2554. // 显示指定视图
  2555. var targetView = document.getElementById(viewId);
  2556. if (targetView) {{
  2557. targetView.style.display = 'block';
  2558. targetView.classList.add('active');
  2559. }}
  2560. // 更新目录项的active状态:清除所有,然后标记当前项和祖先
  2561. var tocItems = document.querySelectorAll('.toc-item');
  2562. tocItems.forEach(function(item) {{
  2563. item.classList.remove('active', 'active-ancestor');
  2564. }});
  2565. // 标记当前激活项
  2566. var activeItem = clickedElement || (event && event.currentTarget);
  2567. if (activeItem && activeItem.classList.contains('toc-item')) {{
  2568. activeItem.classList.add('active');
  2569. // 标记所有祖先节点
  2570. var parent = activeItem.parentElement;
  2571. while (parent) {{
  2572. // 查找上一个兄弟节点中的toc-item
  2573. var prevSibling = parent.previousElementSibling;
  2574. if (prevSibling && prevSibling.classList.contains('toc-item')) {{
  2575. prevSibling.classList.add('active-ancestor');
  2576. }}
  2577. parent = parent.parentElement;
  2578. // 到达toc-content就停止
  2579. if (parent && parent.classList.contains('toc-content')) {{
  2580. break;
  2581. }}
  2582. }}
  2583. }}
  2584. }}
  2585. // 折叠/展开帖子
  2586. function toggleTocPost(event, postIdx) {{
  2587. event.stopPropagation();
  2588. var item = event.currentTarget;
  2589. var children = document.getElementById('toc-post-' + postIdx + '-children');
  2590. item.classList.toggle('collapsed');
  2591. children.classList.toggle('hidden');
  2592. }}
  2593. // 折叠/展开点类型分组
  2594. function toggleTocGroup(event, groupId) {{
  2595. event.stopPropagation();
  2596. var item = event.currentTarget;
  2597. var children = document.getElementById('toc-' + groupId + '-children');
  2598. item.classList.toggle('collapsed');
  2599. children.classList.toggle('hidden');
  2600. }}
  2601. // 折叠/展开点项(同时切换到该点的内容视图)
  2602. function toggleTocPoint(event, pointItemId, viewId) {{
  2603. event.stopPropagation();
  2604. // 如果点击的是展开图标,只切换折叠状态
  2605. if (event.target.classList.contains('toc-expand-icon')) {{
  2606. event.preventDefault();
  2607. var item = event.currentTarget;
  2608. var children = document.getElementById('toc-' + pointItemId + '-children');
  2609. item.classList.toggle('collapsed');
  2610. children.classList.toggle('hidden');
  2611. }} else {{
  2612. // 如果点击的是其他部分,切换到该点的视图
  2613. showContentView(viewId, event.currentTarget);
  2614. // 滚动到右侧内容区域的顶部
  2615. var rightContent = document.querySelector('.right-content');
  2616. if (rightContent) {{
  2617. rightContent.scrollTo({{ top: 0, behavior: 'smooth' }});
  2618. }}
  2619. }}
  2620. }}
  2621. // 折叠/展开特征项(特征有子层级)
  2622. function toggleTocFeature(event, featureItemId, viewId, featureSectionId) {{
  2623. event.stopPropagation();
  2624. // 如果点击的是展开图标,只切换折叠状态
  2625. if (event.target.classList.contains('toc-expand-icon')) {{
  2626. event.preventDefault();
  2627. var item = event.currentTarget;
  2628. var children = document.getElementById('toc-' + featureItemId + '-children');
  2629. item.classList.toggle('collapsed');
  2630. children.classList.toggle('hidden');
  2631. }} else {{
  2632. // 如果点击的是其他部分,切换到该点的视图并滚动到特征
  2633. showContentView(viewId, event.currentTarget);
  2634. setTimeout(function() {{
  2635. var element = document.getElementById(featureSectionId);
  2636. if (element) {{
  2637. element.scrollIntoView({{ behavior: 'smooth', block: 'start' }});
  2638. }}
  2639. }}, 100);
  2640. }}
  2641. }}
  2642. // 滚动到层级分组(在特征下的子层级)
  2643. function scrollToLevelSection(event, viewId, levelSectionId) {{
  2644. event.stopPropagation();
  2645. // 先切换到对应点的视图
  2646. showContentView(viewId, event.currentTarget);
  2647. // 等待视图切换完成后滚动到层级分组位置
  2648. setTimeout(function() {{
  2649. var element = document.getElementById(levelSectionId);
  2650. if (element) {{
  2651. element.scrollIntoView({{ behavior: 'smooth', block: 'start' }});
  2652. }}
  2653. }}, 100);
  2654. }}
  2655. // 点击特征:先切换到对应点的视图,再滚动到特征位置
  2656. function clickFeature(event, pointViewId, featureSectionId) {{
  2657. event.stopPropagation();
  2658. // 先切换到对应点的视图
  2659. showContentView(pointViewId, event.currentTarget);
  2660. // 等待视图切换完成后滚动到特征位置
  2661. setTimeout(function() {{
  2662. var element = document.getElementById(featureSectionId);
  2663. if (element) {{
  2664. element.scrollIntoView({{ behavior: 'smooth', block: 'start' }});
  2665. }}
  2666. }}, 100);
  2667. }}
  2668. function scrollToSection(sectionId) {{
  2669. var element = document.getElementById(sectionId);
  2670. if (element) {{
  2671. element.scrollIntoView({{ behavior: 'smooth', block: 'start' }});
  2672. }}
  2673. }}
  2674. function toggleMatch(matchId) {{
  2675. var content = document.getElementById(matchId + '-content');
  2676. var icon = document.getElementById(matchId + '-icon');
  2677. if (content.style.display === 'none') {{
  2678. content.style.display = 'block';
  2679. icon.classList.add('expanded');
  2680. }} else {{
  2681. content.style.display = 'none';
  2682. icon.classList.remove('expanded');
  2683. }}
  2684. }}
  2685. function toggleFeatureSection(event, sectionId) {{
  2686. event.stopPropagation();
  2687. var content = document.getElementById(sectionId + '-content');
  2688. var icon = document.getElementById(sectionId + '-icon');
  2689. if (!content || !icon) {{
  2690. console.error('Element not found:', sectionId);
  2691. return;
  2692. }}
  2693. var isHidden = content.style.display === 'none' || !content.style.display || window.getComputedStyle(content).display === 'none';
  2694. if (isHidden) {{
  2695. content.style.display = 'flex';
  2696. icon.textContent = '▼';
  2697. }} else {{
  2698. content.style.display = 'none';
  2699. icon.textContent = '▶';
  2700. }}
  2701. }}
  2702. function toggleStepSection(sectionId) {{
  2703. var content = document.getElementById(sectionId + '-content');
  2704. var icon = document.getElementById(sectionId + '-icon');
  2705. if (content.style.display === 'none') {{
  2706. content.style.display = 'flex';
  2707. icon.textContent = '▼';
  2708. }} else {{
  2709. content.style.display = 'none';
  2710. icon.textContent = '▶';
  2711. }}
  2712. }}
  2713. function toggleMatchGroup(event, groupId) {{
  2714. event.stopPropagation();
  2715. var content = document.getElementById(groupId + '-content');
  2716. var icon = document.getElementById(groupId + '-icon');
  2717. if (!content || !icon) {{
  2718. console.error('Element not found:', groupId);
  2719. return;
  2720. }}
  2721. var isHidden = content.style.display === 'none' || !content.style.display || window.getComputedStyle(content).display === 'none';
  2722. if (isHidden) {{
  2723. // 根据class决定使用什么display值
  2724. if (content.classList.contains('match-subgroup-content')) {{
  2725. content.style.display = 'flex';
  2726. }} else {{
  2727. content.style.display = 'block';
  2728. }}
  2729. icon.textContent = '▼';
  2730. }} else {{
  2731. content.style.display = 'none';
  2732. icon.textContent = '▶';
  2733. }}
  2734. }}
  2735. function showMatchDetail(element) {{
  2736. var modal = document.getElementById('matchModal');
  2737. var title = document.getElementById('matchModalTitle');
  2738. var body = document.getElementById('matchModalBody');
  2739. // 从data属性读取数据
  2740. var personaName = element.dataset.personaName;
  2741. var featureType = element.dataset.featureType;
  2742. var personaLevel = element.dataset.personaLevel;
  2743. var similarity = parseFloat(element.dataset.similarity);
  2744. var label = element.dataset.label;
  2745. var explanation = element.dataset.explanation;
  2746. var historicalPosts = element.dataset.historicalPosts;
  2747. // 设置标题 - 合并显示层级和类型
  2748. var titleText = '';
  2749. if (personaLevel && featureType) {{
  2750. titleText = '[' + personaLevel + '-' + featureType + '] ';
  2751. }}
  2752. titleText += personaName + ' (相似度: ' + similarity.toFixed(2) + ' - ' + label + ')';
  2753. title.textContent = titleText;
  2754. // 生成内容
  2755. var bodyHTML = '<div class="match-detail-section">';
  2756. bodyHTML += '<h3>匹配说明</h3>';
  2757. bodyHTML += '<div class="match-explanation-text">' + explanation + '</div>';
  2758. bodyHTML += '</div>';
  2759. // 如果有历史帖子
  2760. if (historicalPosts && historicalPosts.trim().length > 0) {{
  2761. bodyHTML += '<div class="match-detail-section">';
  2762. bodyHTML += '<h3>历史帖子来源</h3>';
  2763. bodyHTML += '<div class="historical-posts-grid">';
  2764. bodyHTML += historicalPosts;
  2765. bodyHTML += '</div>';
  2766. bodyHTML += '</div>';
  2767. }}
  2768. body.innerHTML = bodyHTML;
  2769. modal.style.display = 'block';
  2770. }}
  2771. function closeMatchModal(event) {{
  2772. var modal = document.getElementById('matchModal');
  2773. if (!event || event.target === modal) {{
  2774. modal.style.display = 'none';
  2775. }}
  2776. }}
  2777. // ESC键关闭模态框
  2778. document.addEventListener('keydown', function(event) {{
  2779. if (event.key === 'Escape') {{
  2780. closeMatchModal();
  2781. }}
  2782. }});
  2783. function showPostDetail(element) {{
  2784. const postDataStr = element.dataset.postData;
  2785. if (!postDataStr) return;
  2786. try {{
  2787. const postData = JSON.parse(postDataStr);
  2788. // 生成图片HTML(添加懒加载)
  2789. let imagesHtml = '';
  2790. if (postData.images && postData.images.length > 0) {{
  2791. imagesHtml = postData.images.map(img =>
  2792. `<img src="${{img}}" class="post-detail-image" alt="图片" loading="lazy">`
  2793. ).join('');
  2794. }} else {{
  2795. imagesHtml = '<div style="text-align: center; color: #9ca3af; padding: 40px;">暂无图片</div>';
  2796. }}
  2797. const modalHtml = `
  2798. <div class="post-detail-content" onclick="event.stopPropagation()">
  2799. <button class="post-detail-close" onclick="closePostDetail()">×</button>
  2800. <div class="post-detail-header">
  2801. <div class="post-detail-title">${{postData.title || '无标题'}}</div>
  2802. <div class="post-detail-meta">
  2803. <div>
  2804. <span class="post-detail-author">👤 ${{postData.author}}</span>
  2805. <span class="post-detail-time"> · 📅 ${{postData.publish_time}}</span>
  2806. </div>
  2807. <div class="post-detail-stats">
  2808. <span>👍 ${{postData.like_count}}</span>
  2809. <span>💬 ${{postData.comment_count}}</span>
  2810. <span>⭐ ${{postData.collect_count}}</span>
  2811. </div>
  2812. </div>
  2813. </div>
  2814. <div class="post-detail-body">
  2815. ${{postData.body_text ? `<div class="post-detail-desc">${{postData.body_text}}</div>` : '<div style="color: #9ca3af;">暂无正文</div>'}}
  2816. <div class="post-detail-images">
  2817. ${{imagesHtml}}
  2818. </div>
  2819. </div>
  2820. <div class="post-detail-footer">
  2821. <a href="${{postData.link}}" target="_blank" class="post-detail-link">
  2822. 在小红书查看完整内容 →
  2823. </a>
  2824. </div>
  2825. </div>
  2826. `;
  2827. let modal = document.getElementById('postDetailModal');
  2828. if (!modal) {{
  2829. modal = document.createElement('div');
  2830. modal.id = 'postDetailModal';
  2831. modal.className = 'post-detail-modal';
  2832. modal.onclick = closePostDetail;
  2833. document.body.appendChild(modal);
  2834. }}
  2835. modal.innerHTML = modalHtml;
  2836. modal.classList.add('active');
  2837. document.body.style.overflow = 'hidden';
  2838. }} catch (e) {{
  2839. console.error('解析帖子数据失败:', e);
  2840. }}
  2841. }}
  2842. function closePostDetail(event) {{
  2843. if (event && event.target !== event.currentTarget) return;
  2844. const modal = document.getElementById('postDetailModal');
  2845. if (modal) {{
  2846. modal.classList.remove('active');
  2847. document.body.style.overflow = '';
  2848. }}
  2849. }}
  2850. function moveImage(postId, direction) {{
  2851. var carousel = document.querySelector(`[data-post-id="${{postId}}"]`);
  2852. var track = document.getElementById(postId + '-track');
  2853. var totalImages = parseInt(carousel.getAttribute('data-total-images'));
  2854. if (!carousel.currentIndex) {{
  2855. carousel.currentIndex = 0;
  2856. }}
  2857. carousel.currentIndex = (carousel.currentIndex + direction + totalImages) % totalImages;
  2858. track.style.transform = `translateX(-${{carousel.currentIndex * 100}}%)`;
  2859. updateIndicators(postId, carousel.currentIndex);
  2860. }}
  2861. function jumpToImage(postId, index) {{
  2862. var carousel = document.querySelector(`[data-post-id="${{postId}}"]`);
  2863. var track = document.getElementById(postId + '-track');
  2864. carousel.currentIndex = index;
  2865. track.style.transform = `translateX(-${{index * 100}}%)`;
  2866. updateIndicators(postId, index);
  2867. }}
  2868. function updateIndicators(postId, activeIndex) {{
  2869. var indicators = document.querySelectorAll(`#${{postId}}-indicators .indicator`);
  2870. indicators.forEach((indicator, i) => {{
  2871. if (i === activeIndex) {{
  2872. indicator.classList.add('active');
  2873. }} else {{
  2874. indicator.classList.remove('active');
  2875. }}
  2876. }});
  2877. }}
  2878. // 目录激活状态追踪
  2879. function updateTocActiveState() {{
  2880. const rightContent = document.querySelector('.right-content');
  2881. if (!rightContent) return;
  2882. // 找到当前激活的view
  2883. const activeView = document.querySelector('.content-view[style*="display: block"], .content-view:not([style*="display: none"])');
  2884. if (!activeView) return;
  2885. // 在激活的view中查找所有sections
  2886. const sections = activeView.querySelectorAll('[id]');
  2887. const tocItems = document.querySelectorAll('.toc-item');
  2888. let currentActive = null;
  2889. let maxVisibility = 0;
  2890. // 找到当前在视口中最visible的section
  2891. sections.forEach(section => {{
  2892. if (!section.id) return;
  2893. const rect = section.getBoundingClientRect();
  2894. const sectionTop = rect.top;
  2895. const sectionBottom = rect.bottom;
  2896. const windowHeight = window.innerHeight;
  2897. // 计算section在视口中的可见度
  2898. if (sectionTop < windowHeight && sectionBottom > 0) {{
  2899. const visibleTop = Math.max(sectionTop, 0);
  2900. const visibleBottom = Math.min(sectionBottom, windowHeight);
  2901. const visibility = visibleBottom - visibleTop;
  2902. // 优先选择顶部在视口上半部分的section
  2903. if (sectionTop < windowHeight / 2 && visibility > maxVisibility) {{
  2904. maxVisibility = visibility;
  2905. currentActive = section.id;
  2906. }}
  2907. }}
  2908. }});
  2909. // 更新目录项的激活状态
  2910. tocItems.forEach(item => {{
  2911. // 移除所有激活状态和祖先状态
  2912. item.classList.remove('active');
  2913. item.classList.remove('active-ancestor');
  2914. const onclick = item.getAttribute('onclick');
  2915. if (!onclick) return;
  2916. // 检查是否匹配当前活动section
  2917. if (currentActive && onclick.includes(currentActive)) {{
  2918. item.classList.add('active');
  2919. // 标记所有祖先节点
  2920. let parent = item.parentElement;
  2921. while (parent) {{
  2922. if (parent.classList && parent.classList.contains('toc-item')) {{
  2923. parent.classList.add('active-ancestor');
  2924. }}
  2925. parent = parent.parentElement;
  2926. }}
  2927. }}
  2928. }});
  2929. // 更新面包屑导航
  2930. updateBreadcrumb(currentActive);
  2931. }}
  2932. // 更新面包屑导航(只显示右侧内容层级,不显示帖子信息)
  2933. function updateBreadcrumb(currentSectionId) {{
  2934. const breadcrumbPoint = document.getElementById('breadcrumb-point');
  2935. const breadcrumbStep = document.getElementById('breadcrumb-step');
  2936. const breadcrumbFeature = document.getElementById('breadcrumb-feature');
  2937. const breadcrumbLevel = document.getElementById('breadcrumb-level');
  2938. const breadcrumbSubgroup = document.getElementById('breadcrumb-subgroup');
  2939. const sep1 = document.getElementById('sep-1');
  2940. const sep2 = document.getElementById('sep-2');
  2941. const sep3 = document.getElementById('sep-3');
  2942. const sep4 = document.getElementById('sep-4');
  2943. // 隐藏所有可选元素
  2944. [breadcrumbStep, breadcrumbFeature, breadcrumbLevel, breadcrumbSubgroup, sep1, sep2, sep3, sep4].forEach(el => {{
  2945. if (el) el.style.display = 'none';
  2946. }});
  2947. if (!currentSectionId) {{
  2948. breadcrumbPoint.textContent = '-';
  2949. return;
  2950. }}
  2951. // 从当前section找到所在的view
  2952. let currentView = null;
  2953. const allViews = document.querySelectorAll('.content-view');
  2954. allViews.forEach(view => {{
  2955. if (view.style.display !== 'none' && view.querySelector('#' + currentSectionId)) {{
  2956. currentView = view;
  2957. }}
  2958. }});
  2959. if (!currentView) return;
  2960. // 从view ID解析信息: view-post-0-point-灵感点列表-0
  2961. const viewId = currentView.id;
  2962. const viewMatch = viewId.match(/view-post-(\d+)-point-(.+?)-(\d+)/);
  2963. if (viewMatch) {{
  2964. const postIdx = viewMatch[1];
  2965. const pointType = viewMatch[2].replace('列表', '');
  2966. const pointIdx = parseInt(viewMatch[3]);
  2967. // 1. 点信息(总是显示)
  2968. const pointElement = currentView.querySelector('.inspiration-detail-card .inspiration-name');
  2969. const pointName = pointElement ? pointElement.textContent : `${{pointType}} ${{pointIdx + 1}}`;
  2970. breadcrumbPoint.textContent = `${{pointType}} ${{pointIdx + 1}}: ${{pointName.substring(0, 20)}}${{pointName.length > 20 ? '...' : ''}}`;
  2971. breadcrumbPoint.onclick = function() {{
  2972. showContentView(viewId, this);
  2973. const rightContent = document.querySelector('.right-content');
  2974. if (rightContent) rightContent.scrollTo({{ top: 0, behavior: 'smooth' }});
  2975. }};
  2976. // 2. 步骤信息(如果在步骤section中)
  2977. if (currentSectionId.includes('step')) {{
  2978. const stepMatch = currentSectionId.match(/step-\d+-(\d+)/);
  2979. if (stepMatch) {{
  2980. sep1.style.display = 'inline';
  2981. breadcrumbStep.style.display = 'inline';
  2982. breadcrumbStep.textContent = `步骤 ${{parseInt(stepMatch[1]) + 1}}`;
  2983. breadcrumbStep.onclick = function() {{
  2984. const el = document.getElementById(currentSectionId);
  2985. if (el) el.scrollIntoView({{ behavior: 'smooth', block: 'start' }});
  2986. }};
  2987. }}
  2988. }}
  2989. // 3. 特征信息(如果在特征相关section中)
  2990. if (currentSectionId.includes('feat')) {{
  2991. const featMatch = currentSectionId.match(/feat-(\d+)-(\d+)/);
  2992. if (featMatch) {{
  2993. const featIdx = parseInt(featMatch[2]);
  2994. // 查找特征名称
  2995. const featureSection = document.getElementById('post-' + postIdx + '-feat-' + pointIdx + '-' + featIdx);
  2996. let featureName = `特征 ${{featIdx + 1}}`;
  2997. if (featureSection) {{
  2998. const header = featureSection.querySelector('.match-section-header h4');
  2999. if (header) {{
  3000. const match = header.textContent.match(/匹配结果:\s*(.+?)\s*\(/);
  3001. if (match) featureName = match[1].substring(0, 15) + (match[1].length > 15 ? '...' : '');
  3002. }}
  3003. }}
  3004. sep2.style.display = 'inline';
  3005. breadcrumbFeature.style.display = 'inline';
  3006. breadcrumbFeature.textContent = featureName;
  3007. breadcrumbFeature.onclick = function() {{
  3008. if (featureSection) featureSection.scrollIntoView({{ behavior: 'smooth', block: 'start' }});
  3009. }};
  3010. // 4. 层级分组信息(如果在level section中)
  3011. if (currentSectionId.includes('level')) {{
  3012. const levelMatch = currentSectionId.match(/level-(.+?)(?:-|$)/);
  3013. if (levelMatch) {{
  3014. const levelName = levelMatch[1];
  3015. const levelElement = document.getElementById(currentSectionId);
  3016. let levelTitle = `匹配人设${{levelName}}`;
  3017. if (levelElement) {{
  3018. const header = levelElement.querySelector('.level-group-title');
  3019. if (header) levelTitle = header.textContent.trim();
  3020. }}
  3021. sep3.style.display = 'inline';
  3022. breadcrumbLevel.style.display = 'inline';
  3023. breadcrumbLevel.textContent = levelTitle.substring(0, 20);
  3024. breadcrumbLevel.onclick = function() {{
  3025. if (levelElement) levelElement.scrollIntoView({{ behavior: 'smooth', block: 'start' }});
  3026. }};
  3027. }}
  3028. }}
  3029. }}
  3030. }}
  3031. }}
  3032. }}
  3033. // 监听滚动事件
  3034. document.addEventListener('DOMContentLoaded', function() {{
  3035. // 默认显示第一个视图
  3036. var allViews = document.querySelectorAll('.content-view');
  3037. if (allViews.length > 0) {{
  3038. // 找到第一个点视图(跳过帖子详情视图)
  3039. var firstPointView = null;
  3040. var firstPointViewId = null;
  3041. for (var i = 0; i < allViews.length; i++) {{
  3042. if (allViews[i].id.includes('point') && allViews[i].id.includes('灵感点列表')) {{
  3043. firstPointView = allViews[i];
  3044. firstPointViewId = allViews[i].id;
  3045. break;
  3046. }}
  3047. }}
  3048. // 如果有点视图,显示第一个点;否则显示第一个帖子详情
  3049. var defaultView = firstPointView || allViews[0];
  3050. defaultView.style.display = 'block';
  3051. defaultView.classList.add('active');
  3052. // 展开第一个帖子
  3053. var firstPost = document.querySelector('.toc-post-header');
  3054. if (firstPost) {{
  3055. firstPost.classList.remove('collapsed');
  3056. var postIdx = firstPost.getAttribute('data-post-id');
  3057. var postChildren = document.getElementById('toc-post-' + postIdx + '-children');
  3058. if (postChildren) {{
  3059. postChildren.classList.remove('hidden');
  3060. }}
  3061. }}
  3062. // 展开第一个灵感点分组
  3063. var firstInspGroup = document.querySelector('[onclick*="post-0-灵感点列表"]');
  3064. if (firstInspGroup && firstInspGroup.classList.contains('toc-group-header')) {{
  3065. firstInspGroup.classList.remove('collapsed');
  3066. var groupChildren = document.getElementById('toc-post-0-灵感点列表-children');
  3067. if (groupChildren) {{
  3068. groupChildren.classList.remove('hidden');
  3069. }}
  3070. }}
  3071. // 展开第一个灵感点
  3072. var firstPoint = document.querySelector('.toc-level-2[onclick*="view-post-0-point-灵感点列表-0"]');
  3073. if (firstPoint) {{
  3074. firstPoint.classList.remove('collapsed');
  3075. var pointId = firstPoint.getAttribute('onclick').match(/toggleTocPoint\(event,\s*'([^']+)'/);
  3076. if (pointId && pointId[1]) {{
  3077. var pointChildren = document.getElementById('toc-' + pointId[1] + '-children');
  3078. if (pointChildren) {{
  3079. pointChildren.classList.remove('hidden');
  3080. }}
  3081. }}
  3082. }}
  3083. // 激活对应的目录项
  3084. if (firstPointViewId) {{
  3085. // 找到对应的目录项并添加active类
  3086. var tocItems = document.querySelectorAll('.toc-item');
  3087. tocItems.forEach(function(item) {{
  3088. var onclick = item.getAttribute('onclick');
  3089. if (onclick && onclick.includes(firstPointViewId)) {{
  3090. item.classList.add('active');
  3091. // 标记祖先节点
  3092. var parent = item.parentElement;
  3093. while (parent) {{
  3094. if (parent.classList && parent.classList.contains('toc-item')) {{
  3095. parent.classList.add('active-ancestor');
  3096. }}
  3097. parent = parent.parentElement;
  3098. }}
  3099. }}
  3100. }});
  3101. }}
  3102. }}
  3103. const rightContent = document.querySelector('.right-content');
  3104. if (rightContent) {{
  3105. rightContent.addEventListener('scroll', function() {{
  3106. // 使用节流避免频繁触发
  3107. if (this.scrollTimeout) clearTimeout(this.scrollTimeout);
  3108. this.scrollTimeout = setTimeout(updateTocActiveState, 50);
  3109. }});
  3110. // 初始化时更新一次
  3111. updateTocActiveState();
  3112. }}
  3113. }});
  3114. </script>
  3115. </body>
  3116. </html>
  3117. '''
  3118. return html
  3119. def minify_html(html: str) -> str:
  3120. """压缩HTML,去除多余空格和换行"""
  3121. import re
  3122. # 保护script和style标签内容
  3123. scripts = []
  3124. styles = []
  3125. def save_script(match):
  3126. scripts.append(match.group(0))
  3127. return f"___SCRIPT_{len(scripts)-1}___"
  3128. def save_style(match):
  3129. styles.append(match.group(0))
  3130. return f"___STYLE_{len(styles)-1}___"
  3131. # 保存script和style
  3132. html = re.sub(r'<script[^>]*>.*?</script>', save_script, html, flags=re.DOTALL)
  3133. html = re.sub(r'<style[^>]*>.*?</style>', save_style, html, flags=re.DOTALL)
  3134. # 去除HTML注释
  3135. html = re.sub(r'<!--.*?-->', '', html, flags=re.DOTALL)
  3136. # 去除多余空格和换行
  3137. html = re.sub(r'\s+', ' ', html)
  3138. html = re.sub(r'>\s+<', '><', html)
  3139. # 恢复script和style
  3140. for i, script in enumerate(scripts):
  3141. html = html.replace(f"___SCRIPT_{i}___", script)
  3142. for i, style in enumerate(styles):
  3143. html = html.replace(f"___STYLE_{i}___", style)
  3144. return html.strip()
  3145. def main():
  3146. """主函数"""
  3147. # 解析命令行参数
  3148. import sys
  3149. account_name = sys.argv[1] if len(sys.argv) > 1 else None
  3150. # 使用路径配置
  3151. config = PathConfig(account_name=account_name)
  3152. # 确保输出目录存在
  3153. config.ensure_dirs()
  3154. # 获取路径
  3155. input_dir = config.how_results_dir
  3156. output_file = config.visualization_file
  3157. print(f"账号: {config.account_name}")
  3158. print(f"输入目录: {input_dir}")
  3159. print(f"输出文件: {output_file}")
  3160. print()
  3161. print(f"读取 how 解构结果: {input_dir}")
  3162. # 加载特征分类映射
  3163. print(f"加载特征分类映射...")
  3164. category_mapping = load_feature_category_mapping(config)
  3165. print(f"已加载 {sum(len(v) for v in category_mapping.values())} 个特征分类")
  3166. # 加载特征来源映射
  3167. print(f"加载特征来源映射...")
  3168. source_mapping = load_feature_source_mapping(config)
  3169. print(f"已加载 {len(source_mapping)} 个特征的来源信息")
  3170. json_files = list(input_dir.glob("*_how.json"))
  3171. print(f"找到 {len(json_files)} 个文件\n")
  3172. posts_data = []
  3173. for i, file_path in enumerate(json_files, 1):
  3174. print(f"读取文件 [{i}/{len(json_files)}]: {file_path.name}")
  3175. with open(file_path, "r", encoding="utf-8") as f:
  3176. post_data = json.load(f)
  3177. posts_data.append(post_data)
  3178. # 按发布时间降序排序(最新的在前)
  3179. print(f"\n按发布时间排序...")
  3180. posts_data.sort(key=lambda x: x.get("帖子详情", {}).get("publish_timestamp", 0), reverse=True)
  3181. print(f"\n生成合并的 HTML...")
  3182. html_content = generate_combined_html(posts_data, category_mapping, source_mapping)
  3183. # 保存原始版本
  3184. print(f"保存原始HTML到: {output_file}")
  3185. with open(output_file, "w", encoding="utf-8") as f:
  3186. f.write(html_content)
  3187. original_size = len(html_content) / 1024 / 1024
  3188. print(f"原始HTML大小: {original_size:.1f} MB")
  3189. # 压缩HTML
  3190. print(f"\n压缩HTML...")
  3191. minified_html = minify_html(html_content)
  3192. minified_file = output_file.parent / "当前帖子_how解构结果_可视化.min.html"
  3193. print(f"保存压缩HTML到: {minified_file}")
  3194. with open(minified_file, "w", encoding="utf-8") as f:
  3195. f.write(minified_html)
  3196. minified_size = len(minified_html) / 1024 / 1024
  3197. print(f"压缩HTML大小: {minified_size:.1f} MB (减少 {(1 - minified_size/original_size)*100:.1f}%)")
  3198. # Gzip压缩
  3199. import gzip
  3200. print(f"\n生成Gzip压缩版本...")
  3201. gzip_file = output_file.parent / "当前帖子_how解构结果_可视化.html.gz"
  3202. with gzip.open(gzip_file, "wb") as f:
  3203. f.write(minified_html.encode('utf-8'))
  3204. gzip_size = gzip_file.stat().st_size / 1024 / 1024
  3205. print(f"Gzip压缩大小: {gzip_size:.1f} MB (比原始减少 {(1 - gzip_size/original_size)*100:.1f}%)")
  3206. print(f"\n完成! 生成了3个版本:")
  3207. print(f"1. 原始版本: {output_file} ({original_size:.1f} MB)")
  3208. print(f"2. 压缩版本: {minified_file} ({minified_size:.1f} MB)")
  3209. print(f"3. Gzip版本: {gzip_file} ({gzip_size:.1f} MB)")
  3210. print(f"\n建议分享: {gzip_file.name} (浏览器可直接打开)")
  3211. if __name__ == "__main__":
  3212. main()