visualize_how_results.py 139 KB

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