| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296329732983299330033013302330333043305330633073308330933103311331233133314331533163317331833193320332133223323332433253326332733283329333033313332333333343335333633373338333933403341334233433344334533463347334833493350335133523353335433553356335733583359336033613362336333643365336633673368336933703371337233733374337533763377337833793380338133823383338433853386338733883389339033913392339333943395339633973398339934003401340234033404340534063407340834093410341134123413341434153416341734183419342034213422342334243425342634273428342934303431343234333434343534363437343834393440344134423443344434453446344734483449345034513452345334543455345634573458345934603461346234633464346534663467346834693470347134723473347434753476347734783479348034813482348334843485348634873488348934903491349234933494349534963497349834993500350135023503350435053506350735083509351035113512351335143515351635173518351935203521352235233524352535263527352835293530353135323533353435353536353735383539354035413542354335443545354635473548354935503551355235533554355535563557355835593560356135623563356435653566356735683569357035713572357335743575357635773578357935803581358235833584358535863587358835893590359135923593359435953596359735983599360036013602360336043605360636073608360936103611361236133614361536163617361836193620362136223623362436253626362736283629363036313632363336343635363636373638363936403641364236433644364536463647364836493650365136523653365436553656365736583659366036613662366336643665366636673668366936703671367236733674367536763677367836793680368136823683368436853686368736883689369036913692369336943695369636973698369937003701370237033704370537063707370837093710371137123713371437153716371737183719372037213722372337243725372637273728372937303731373237333734373537363737373837393740374137423743374437453746374737483749375037513752375337543755375637573758375937603761376237633764376537663767376837693770377137723773377437753776377737783779378037813782378337843785378637873788 |
- #!/usr/bin/env python3
- # -*- coding: utf-8 -*-
- """
- How解构结果可视化脚本 V2
- 改进版:
- - 使用标签页展示多个帖子
- - 参考 visualize_inspiration_points.py 的帖子详情展示
- - 分层可折叠的匹配结果
- """
- import json
- from pathlib import Path
- from typing import Dict, List
- import sys
- import html as html_module
- # 添加项目根目录到路径
- project_root = Path(__file__).parent.parent.parent
- sys.path.insert(0, str(project_root))
- from script.data_processing.path_config import PathConfig
- # 注意:已改用基于相似度的显示方式,不再使用关系类型
- # def get_relation_color(relation: str) -> str:
- # """根据关系类型返回对应的颜色"""
- # color_map = {
- # "same": "#10b981", # 绿色 - 同义
- # "contains": "#3b82f6", # 蓝色 - 包含
- # "contained_by": "#8b5cf6", # 紫色 - 被包含
- # "coordinate": "#f59e0b", # 橙色 - 同级
- # "overlap": "#ec4899", # 粉色 - 部分重叠
- # "related": "#6366f1", # 靛蓝 - 相关
- # "unrelated": "#9ca3af" # 灰色 - 无关
- # }
- # return color_map.get(relation, "#9ca3af")
- #
- #
- # def get_relation_label(relation: str) -> str:
- # """返回关系类型的中文标签"""
- # label_map = {
- # "same": "同义",
- # "contains": "包含",
- # "contained_by": "被包含",
- # "coordinate": "同级",
- # "overlap": "部分重叠",
- # "related": "相关",
- # "unrelated": "无关"
- # }
- # return label_map.get(relation, relation)
- def generate_historical_post_card_html(post_detail: Dict, inspiration_point: Dict) -> str:
- """生成历史帖子的紧凑卡片HTML"""
- title = post_detail.get("title", "无标题")
- body_text = post_detail.get("body_text", "")
- images = post_detail.get("images", [])
- like_count = post_detail.get("like_count", 0)
- collect_count = post_detail.get("collect_count", 0)
- comment_count = post_detail.get("comment_count", 0)
- author = post_detail.get("channel_account_name", "")
- link = post_detail.get("link", "#")
- publish_time = post_detail.get("publish_time", "")
- # 获取灵感点信息
- point_name = inspiration_point.get("点的名称", "")
- point_desc = inspiration_point.get("点的描述", "")
- # 准备详情数据(用于模态框)
- import json
- post_detail_data = {
- "title": title,
- "body_text": body_text,
- "images": images,
- "like_count": like_count,
- "comment_count": comment_count,
- "collect_count": collect_count,
- "author": author,
- "publish_time": publish_time,
- "link": link
- }
- post_data_json = json.dumps(post_detail_data, ensure_ascii=False)
- post_data_json_escaped = html_module.escape(post_data_json)
- # 截取正文预览(前80个字符)
- body_preview = body_text[:80] + "..." if len(body_text) > 80 else body_text
- # 生成缩略图
- thumbnail_html = ""
- if images:
- thumbnail_html = f'<img src="{images[0]}" alt="Post thumbnail" class="historical-post-thumbnail" loading="lazy">'
- html = f'''
- <div class="historical-post-card" data-post-data='{post_data_json_escaped}' onclick="showPostDetail(this)">
- <div class="historical-post-image">
- {thumbnail_html}
- {f'<div class="post-card-image-count">{len(images)}</div>' if len(images) > 1 else ''}
- </div>
- <div class="historical-post-content">
- <div class="historical-post-title">{html_module.escape(title)}</div>
- <div class="historical-inspiration-info">
- <span class="inspiration-type-badge-small">灵感点</span>
- <span class="inspiration-name-small">{html_module.escape(point_name)}</span>
- </div>
- <div class="historical-inspiration-desc">{html_module.escape(point_desc[:100])}{"..." if len(point_desc) > 100 else ""}</div>
- <div class="historical-post-meta">
- <span class="historical-post-time">📅 {publish_time}</span>
- </div>
- <div class="historical-post-stats">
- <span>❤ {like_count}</span>
- <span>⭐ {collect_count}</span>
- <a href="{link}" target="_blank" class="view-link" onclick="event.stopPropagation()">查看原帖 →</a>
- </div>
- </div>
- </div>
- '''
- return html
- def generate_post_detail_html(post_data: Dict, post_idx: int) -> str:
- """生成帖子详情HTML(紧凑的卡片样式,点击可展开)"""
- post_detail = post_data.get("帖子详情", {})
- title = post_detail.get("title", "无标题")
- body_text = post_detail.get("body_text", "")
- images = post_detail.get("images", [])
- like_count = post_detail.get("like_count", 0)
- comment_count = post_detail.get("comment_count", 0)
- collect_count = post_detail.get("collect_count", 0)
- author = post_detail.get("channel_account_name", "")
- publish_time = post_detail.get("publish_time", "")
- link = post_detail.get("link", "")
- post_id = post_data.get("帖子id", f"post-{post_idx}")
- # 准备详情数据(用于模态框)
- import json
- post_detail_data = {
- "title": title,
- "body_text": body_text,
- "images": images,
- "like_count": like_count,
- "comment_count": comment_count,
- "collect_count": collect_count,
- "author": author,
- "publish_time": publish_time,
- "link": link
- }
- post_data_json = json.dumps(post_detail_data, ensure_ascii=False)
- post_data_json_escaped = html_module.escape(post_data_json)
- # 生成缩略图HTML
- thumbnail_html = ""
- if images and len(images) > 0:
- # 使用第一张图片作为缩略图,添加懒加载
- thumbnail_html = f'<img src="{images[0]}" class="post-card-thumbnail" alt="缩略图" loading="lazy">'
- else:
- thumbnail_html = '<div class="post-card-thumbnail-placeholder">📄</div>'
- # 截断正文用于预览
- body_preview = body_text[:80] + "..." if len(body_text) > 80 else body_text
- html = f'''
- <div class="post-card-compact" data-post-data='{post_data_json_escaped}' onclick="showPostDetail(this)">
- <div class="post-card-image">
- {thumbnail_html}
- {f'<div class="post-card-image-count">📷 {len(images)}</div>' if len(images) > 1 else ''}
- </div>
- <div class="post-card-content">
- <div class="post-card-title">{html_module.escape(title)}</div>
- <div class="post-card-preview">{html_module.escape(body_preview) if body_preview else "暂无正文"}</div>
- <div class="post-card-meta">
- <span class="post-card-author">👤 {html_module.escape(author)}</span>
- <span class="post-card-time">📅 {publish_time}</span>
- </div>
- <div class="post-card-stats">
- <span>👍 {like_count}</span>
- <span>💬 {comment_count if comment_count else 0}</span>
- <span>⭐ {collect_count if collect_count else 0}</span>
- </div>
- </div>
- </div>
- '''
- return html
- def generate_inspiration_detail_html(inspiration_point: Dict, feature_status_map: Dict[str, str] = None, point_type: str = "灵感点") -> str:
- """生成点详情HTML
- Args:
- inspiration_point: 点数据
- feature_status_map: 特征名称到状态的映射 {特征名称: "相同"|"相似"|"无关"}
- point_type: 点类型(灵感点/关键点/目的点)
- """
- name = inspiration_point.get("名称", "")
- desc = inspiration_point.get("描述", "")
- features = inspiration_point.get("特征列表", [])
- if feature_status_map is None:
- feature_status_map = {}
- # 计算该灵感点的整体结论
- feature_statuses = []
- features_html_list = []
- for f in features:
- feature_name = f if isinstance(f, str) else f.get("特征名称", "")
- weight = f.get("权重", 1.0) if isinstance(f, dict) else 1.0
- # 获取该特征的状态
- status = feature_status_map.get(feature_name, "无关")
- feature_statuses.append(status)
- if status == "相同":
- status_class = "feature-same"
- status_label = "相同"
- elif status == "相似":
- status_class = "feature-similar"
- status_label = "相似"
- else:
- status_class = "feature-unrelated"
- status_label = "无关"
- features_html_list.append(
- f'<span class="feature-tag {status_class}">'
- f'<span class="feature-status-label">{status_label}</span> '
- f'{html_module.escape(feature_name)} '
- f'<span class="feature-weight">({weight})</span>'
- f'</span>'
- )
- features_html = "".join(features_html_list)
- # 计算灵感点结论
- has_same = "相同" in feature_statuses
- has_similar = "相似" in feature_statuses
- has_unrelated = "无关" in feature_statuses
- if not has_unrelated:
- # 没有无关的 -> 找到
- insp_conclusion = "找到"
- insp_conclusion_class = "insp-conclusion-found"
- elif has_same or has_similar:
- # 有相同或相似,但也有无关 -> 部分找到
- insp_conclusion = "部分找到"
- insp_conclusion_class = "insp-conclusion-partial"
- else:
- # 都是无关 -> 都找不到
- insp_conclusion = "都找不到"
- insp_conclusion_class = "insp-conclusion-not-found"
- # 根据点类型设置图标
- point_icons = {
- "灵感点": "💡",
- "关键点": "🔑",
- "目的点": "🎯"
- }
- point_icon = point_icons.get(point_type, "💡")
- html = f'''
- <div class="inspiration-detail-card">
- <div class="inspiration-header">
- <span class="inspiration-type-badge">{point_icon} {point_type}</span>
- <h3 class="inspiration-name">{html_module.escape(name)}</h3>
- <span class="inspiration-conclusion {insp_conclusion_class}">{insp_conclusion}</span>
- </div>
- <div class="inspiration-description">
- <div class="desc-label">描述:</div>
- <div class="desc-text">{html_module.escape(desc)}</div>
- </div>
- <div class="inspiration-features">
- <div class="features-label">特征列表:</div>
- <div class="features-tags">{features_html}</div>
- </div>
- </div>
- '''
- return html
- def load_feature_category_mapping(config: PathConfig) -> Dict:
- """加载特征名称到分类的映射"""
- mapping_file = config.feature_category_mapping_file
- try:
- with open(mapping_file, "r", encoding="utf-8") as f:
- return json.load(f)
- except Exception as e:
- print(f"警告: 无法加载特征分类映射文件: {e}")
- return {}
- def load_feature_source_mapping(config: PathConfig) -> Dict:
- """加载特征名称到帖子来源的映射"""
- mapping_file = config.feature_source_mapping_file
- try:
- with open(mapping_file, "r", encoding="utf-8") as f:
- data = json.load(f)
- # 转换为便于查询的格式: {特征名称: [来源列表]}
- result = {}
- for feature_type in ["灵感点", "关键点", "目的点"]:
- if feature_type in data:
- for item in data[feature_type]:
- feature_name = item.get("特征名称")
- if feature_name:
- result[feature_name] = item.get("特征来源", [])
- return result
- except Exception as e:
- print(f"警告: 无法加载特征来源映射文件: {e}")
- return {}
- 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:
- """生成单个匹配项的HTML
- Args:
- match: 单个匹配数据
- match_idx: 匹配项索引
- post_idx: 帖子索引
- insp_idx: 灵感点索引
- feature_idx: 特征索引
- category_mapping: 特征分类映射
- source_mapping: 特征来源映射
- current_point_type: 当前点的类型(灵感点/关键点/目的点)
- """
- persona_name = match.get("人设特征名称", "")
- feature_type = match.get("特征类型", "")
- feature_categories = match.get("特征分类", [])
- persona_level = match.get("人设特征层级", "")
- match_result = match.get("匹配结果", {})
- similarity = match_result.get("相似度", 0.0)
- explanation = match_result.get("说明", "")
- # 根据相似度确定颜色和标签
- if similarity >= 0.9:
- color = "#10b981" # 绿色 - 相同
- label = "相同"
- elif similarity >= 0.8:
- color = "#f59e0b" # 橙色 - 相似
- label = "相似"
- else:
- color = "#9ca3af" # 灰色 - 无关
- label = "无关"
- match_id = f"post-{post_idx}-insp-{insp_idx}-feat-{feature_idx}-match-{match_idx}"
- # 判断是否同层级匹配
- is_same_level = (persona_level == current_point_type)
- same_level_class = "match-same-level" if is_same_level else ""
- # 生成合并的层级-类型标签
- combined_badge_html = ""
- if persona_level and feature_type:
- combined_text = f"{persona_level}-{feature_type}"
- # 同层级用特殊样式
- if is_same_level:
- combined_badge_html = f'<span class="feature-combined-badge combined-badge-same">[{html_module.escape(combined_text)}]</span>'
- else:
- combined_badge_html = f'<span class="feature-combined-badge">[{html_module.escape(combined_text)}]</span>'
- categories_badge_html = ""
- if feature_categories:
- categories_text = " / ".join(feature_categories)
- categories_badge_html = f'<span class="feature-category-badge">{html_module.escape(categories_text)}</span>'
- # 获取该人设特征的分类信息
- categories_html = ""
- if category_mapping and persona_name:
- found_categories = None
- # 依次在灵感点、关键点、目的点中查找
- for persona_type in ["灵感点", "关键点", "目的点"]:
- if persona_type in category_mapping:
- type_mapping = category_mapping[persona_type]
- if persona_name in type_mapping:
- found_categories = type_mapping[persona_name].get("所属分类", [])
- break
- if found_categories:
- # 简洁样式:[大类/中类/小类]
- categories_reversed = list(reversed(found_categories))
- categories_text = "/".join(categories_reversed)
- categories_html = f'<span class="category-simple">[{html_module.escape(categories_text)}]</span>'
- # 获取该人设特征的历史帖子来源
- historical_posts_html = ""
- if source_mapping and persona_name and persona_name in source_mapping:
- source_list = source_mapping[persona_name]
- if source_list:
- historical_cards = []
- for source_item in source_list:
- post_detail = source_item.get("帖子详情", {})
- if post_detail:
- card_html = generate_historical_post_card_html(post_detail, source_item)
- historical_cards.append(card_html)
- if historical_cards:
- historical_posts_html = f'''
- <div class="historical-posts-section">
- <h4 class="historical-posts-title">历史帖子来源</h4>
- <div class="historical-posts-grid">
- {"".join(historical_cards)}
- </div>
- </div>
- '''
- # 生成历史帖子HTML
- historical_posts_html = ""
- if source_mapping and persona_name and persona_name in source_mapping:
- source_list = source_mapping[persona_name]
- if source_list:
- for source_item in source_list[:5]: # 最多5个
- post_detail = source_item.get("帖子详情", {})
- if post_detail:
- card_html = generate_historical_post_card_html(post_detail, source_item)
- historical_posts_html += card_html
- # 将数据编码到data属性中
- import html as html_encode
- data_explanation = html_encode.escape(explanation)
- data_historical = html_encode.escape(historical_posts_html)
- # 生成紧凑的匹配项HTML(可点击,弹出模态框)
- html = f'''
- <div class="match-item-compact {same_level_class}"
- data-persona-name="{html_module.escape(persona_name)}"
- data-feature-type="{html_module.escape(feature_type)}"
- data-persona-level="{html_module.escape(persona_level)}"
- data-similarity="{similarity}"
- data-label="{label}"
- data-explanation="{data_explanation}"
- data-historical-posts="{data_historical}"
- onclick="showMatchDetail(this)">
- {combined_badge_html}
- <span class="persona-name">{html_module.escape(persona_name)}</span>
- <span class="score-badge">相似度: {similarity:.2f}</span>
- <span class="relation-badge" style="background: {color};">{label}</span>
- </div>
- '''
- return html
- 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:
- """生成可折叠的匹配结果HTML
- Args:
- how_steps: how步骤列表
- feature_idx: 特征索引
- insp_idx: 灵感点索引
- post_idx: 帖子索引
- category_mapping: 特征分类映射
- source_mapping: 特征来源映射
- current_point_type: 当前点的类型(灵感点/关键点/目的点)
- feature_number: 特征序号(从1开始)
- """
- if not how_steps or len(how_steps) == 0:
- return ""
- step = how_steps[0]
- features = step.get("特征列表", [])
- if feature_idx >= len(features):
- return ""
- feature_data = features[feature_idx]
- feature_name = feature_data.get("特征名称", "")
- feature_weight = feature_data.get("权重", 1.0)
- match_results = feature_data.get("匹配结果", [])
- if category_mapping is None:
- category_mapping = {}
- # 按相似度排序
- sorted_matches = sorted(match_results, key=lambda x: x.get("匹配结果", {}).get("相似度", 0), reverse=True)
- # 找出最高相似度,确定状态
- max_similarity = 0.0
- if match_results:
- max_similarity = max(match.get("匹配结果", {}).get("相似度", 0) for match in match_results)
- # 根据最高相似度确定状态
- if max_similarity >= 0.9:
- status = "相同"
- status_class = "status-same"
- elif max_similarity >= 0.8:
- status = "相似"
- status_class = "status-similar"
- else:
- status = "无关"
- status_class = "status-unrelated"
- found_status_html = f'<span class="feature-match-status {status_class}">{status}</span>'
- # 统计相似度分布
- similarity_ranges = {
- "相同 (≥0.9)": 0,
- "相似 (0.8-0.9)": 0,
- "无关 (<0.8)": 0
- }
- for match in match_results:
- similarity = match.get("匹配结果", {}).get("相似度", 0)
- if similarity >= 0.9:
- similarity_ranges["相同 (≥0.9)"] += 1
- elif similarity >= 0.8:
- similarity_ranges["相似 (0.8-0.9)"] += 1
- else:
- similarity_ranges["无关 (<0.8)"] += 1
- # 生成统计信息
- stats_items = []
- range_colors = {
- "相同 (≥0.9)": "#10b981",
- "相似 (0.8-0.9)": "#f59e0b",
- "无关 (<0.8)": "#9ca3af"
- }
- for range_name, count in similarity_ranges.items():
- if count > 0:
- color = range_colors[range_name]
- stats_items.append(f'<span class="stat-badge" style="background: {color};">{range_name}: {count}</span>')
- stats_html = "".join(stats_items)
- # 按人设特征层级分组匹配项
- level_groups = {
- "灵感点": {"标签": [], "分类": []},
- "关键点": {"标签": [], "分类": []},
- "目的点": {"标签": [], "分类": []}
- }
- for i, match in enumerate(sorted_matches):
- persona_level = match.get("人设特征层级", "")
- feature_type = match.get("特征类型", "")
- if persona_level in level_groups and feature_type in ["标签", "分类"]:
- level_groups[persona_level][feature_type].append((i, match))
- # 生成分组的匹配项HTML
- matches_html = ""
- # 按层级顺序显示:灵感点 -> 关键点 -> 目的点
- level_index = 1
- for level_name in ["灵感点", "关键点", "目的点"]:
- level_data = level_groups[level_name]
- total_count = len(level_data["标签"]) + len(level_data["分类"])
- if total_count == 0:
- continue
- # 生成该层级的折叠区域
- level_section_id = f"post-{post_idx}-{current_point_type}-{insp_idx}-feat-{feature_idx}-level-{level_name}"
- # 找出该层级的最高分匹配
- all_level_matches = level_data["标签"] + level_data["分类"]
- top_match = None
- max_similarity = 0
- for _, match in all_level_matches:
- similarity = match.get("匹配结果", {}).get("相似度", 0)
- if similarity > max_similarity:
- max_similarity = similarity
- top_match = match
- # 生成最高分特征信息
- top_match_html = ""
- if top_match:
- top_persona_name = top_match.get("人设特征名称", "")
- top_feature_type = top_match.get("特征类型", "")
- if max_similarity >= 0.9:
- similarity_label = "相同"
- similarity_color = "#10b981"
- elif max_similarity >= 0.8:
- similarity_label = "相似"
- similarity_color = "#f59e0b"
- else:
- similarity_label = "无关"
- similarity_color = "#9ca3af"
- top_match_html = f'''
- <div class="level-top-match">
- <span class="top-match-label">最高:</span>
- <span class="top-match-type">[{html_module.escape(top_feature_type)}]</span>
- <span class="top-match-name">{html_module.escape(top_persona_name)}</span>
- <span class="top-match-score" style="background: {similarity_color}; color: white;">{max_similarity:.2f} {similarity_label}</span>
- </div>
- '''
- # 计算该层级的相似度分布
- level_stats = {"相同": 0, "相似": 0, "无关": 0}
- for _, match in all_level_matches:
- similarity = match.get("匹配结果", {}).get("相似度", 0)
- if similarity >= 0.9:
- level_stats["相同"] += 1
- elif similarity >= 0.8:
- level_stats["相似"] += 1
- else:
- level_stats["无关"] += 1
- # 生成统计标签
- level_stats_html = ""
- if level_stats["相同"] > 0:
- level_stats_html += f'<span class="stat-badge" style="background: #10b981;">相同: {level_stats["相同"]}</span>'
- if level_stats["相似"] > 0:
- level_stats_html += f'<span class="stat-badge" style="background: #f59e0b;">相似: {level_stats["相似"]}</span>'
- if level_stats["无关"] > 0:
- level_stats_html += f'<span class="stat-badge" style="background: #9ca3af;">无关: {level_stats["无关"]}</span>'
- matches_html += f'''
- <div class="level-group-section">
- <div class="level-group-header" onclick="toggleMatchGroup(event, '{level_section_id}')">
- <div class="level-header-left">
- <span class="expand-icon" id="{level_section_id}-icon">▶</span>
- <h4 class="level-group-title">{feature_number}.{level_index} 匹配人设{level_name} ({total_count})</h4>
- <div class="level-stats">{level_stats_html}</div>
- </div>
- {top_match_html}
- </div>
- <div class="level-group-content" id="{level_section_id}-content" style="display: none;">
- '''
- # 该层级下的标签分组
- subgroup_index = 1
- if level_data["标签"]:
- group_id = f"post-{post_idx}-{current_point_type}-{insp_idx}-feat-{feature_idx}-level-{level_name}-label"
- group_matches_html = ""
- # 找出标签中的最高分
- tag_top_match = None
- tag_max_similarity = 0
- for i, match in level_data["标签"]:
- similarity = match.get("匹配结果", {}).get("相似度", 0)
- if similarity > tag_max_similarity:
- tag_max_similarity = similarity
- tag_top_match = match
- match_html = generate_single_match_html(
- match, i, post_idx, insp_idx, feature_idx,
- category_mapping, source_mapping, current_point_type
- )
- group_matches_html += match_html
- # 生成标签最高分信息
- tag_top_html = ""
- if tag_top_match:
- tag_persona_name = tag_top_match.get("人设特征名称", "")
- tag_persona_level = tag_top_match.get("人设特征层级", "")
- tag_feature_type = tag_top_match.get("特征类型", "")
- if tag_max_similarity >= 0.9:
- tag_color = "#10b981"
- tag_label = "相同"
- elif tag_max_similarity >= 0.8:
- tag_color = "#f59e0b"
- tag_label = "相似"
- else:
- tag_color = "#9ca3af"
- tag_label = "无关"
- # 生成层级-类型标签
- tag_combined = f"[{tag_persona_level}-{tag_feature_type}]" if tag_persona_level and tag_feature_type else ""
- tag_top_html = f'''
- <div class="subgroup-top-match">
- <span class="subgroup-top-label">最高:</span>
- <span class="subgroup-top-combined">{tag_combined}</span>
- <span class="subgroup-top-name">{html_module.escape(tag_persona_name)}</span>
- <span class="subgroup-top-score" style="background: {tag_color}; color: white;">{tag_max_similarity:.2f} {tag_label}</span>
- </div>
- '''
- matches_html += f'''
- <div class="match-subgroup-section">
- <div class="match-subgroup-header" onclick="toggleMatchGroup(event, '{group_id}')">
- <div class="subgroup-header-left">
- <span class="expand-icon" id="{group_id}-icon">▼</span>
- <h5 class="match-subgroup-title">{feature_number}.{level_index}.{subgroup_index} 标签 ({len(level_data["标签"])})</h5>
- </div>
- {tag_top_html}
- </div>
- <div class="match-subgroup-content" id="{group_id}-content">
- {group_matches_html}
- </div>
- </div>
- '''
- subgroup_index += 1
- # 该层级下的分类分组
- if level_data["分类"]:
- group_id = f"post-{post_idx}-{current_point_type}-{insp_idx}-feat-{feature_idx}-level-{level_name}-category"
- group_matches_html = ""
- # 找出分类中的最高分
- cat_top_match = None
- cat_max_similarity = 0
- for i, match in level_data["分类"]:
- similarity = match.get("匹配结果", {}).get("相似度", 0)
- if similarity > cat_max_similarity:
- cat_max_similarity = similarity
- cat_top_match = match
- match_html = generate_single_match_html(
- match, i, post_idx, insp_idx, feature_idx,
- category_mapping, source_mapping, current_point_type
- )
- group_matches_html += match_html
- # 生成分类最高分信息
- cat_top_html = ""
- if cat_top_match:
- cat_persona_name = cat_top_match.get("人设特征名称", "")
- cat_persona_level = cat_top_match.get("人设特征层级", "")
- cat_feature_type = cat_top_match.get("特征类型", "")
- if cat_max_similarity >= 0.9:
- cat_color = "#10b981"
- cat_label = "相同"
- elif cat_max_similarity >= 0.8:
- cat_color = "#f59e0b"
- cat_label = "相似"
- else:
- cat_color = "#9ca3af"
- cat_label = "无关"
- # 生成层级-类型标签
- cat_combined = f"[{cat_persona_level}-{cat_feature_type}]" if cat_persona_level and cat_feature_type else ""
- cat_top_html = f'''
- <div class="subgroup-top-match">
- <span class="subgroup-top-label">最高:</span>
- <span class="subgroup-top-combined">{cat_combined}</span>
- <span class="subgroup-top-name">{html_module.escape(cat_persona_name)}</span>
- <span class="subgroup-top-score" style="background: {cat_color}; color: white;">{cat_max_similarity:.2f} {cat_label}</span>
- </div>
- '''
- matches_html += f'''
- <div class="match-subgroup-section">
- <div class="match-subgroup-header" onclick="toggleMatchGroup(event, '{group_id}')">
- <div class="subgroup-header-left">
- <span class="expand-icon" id="{group_id}-icon">▼</span>
- <h5 class="match-subgroup-title">{feature_number}.{level_index}.{subgroup_index} 分类 ({len(level_data["分类"])})</h5>
- </div>
- {cat_top_html}
- </div>
- <div class="match-subgroup-content" id="{group_id}-content">
- {group_matches_html}
- </div>
- </div>
- '''
- subgroup_index += 1
- matches_html += '''
- </div>
- </div>
- '''
- level_index += 1
- section_id = f"post-{post_idx}-insp-{insp_idx}-feat-{feature_idx}-section"
- # 找出所有匹配中的最高分
- overall_top_match = None
- overall_max_similarity = 0
- for match in match_results:
- similarity = match.get("匹配结果", {}).get("相似度", 0)
- if similarity > overall_max_similarity:
- overall_max_similarity = similarity
- overall_top_match = match
- # 生成最高分信息
- overall_top_html = ""
- if overall_top_match:
- top_persona_name = overall_top_match.get("人设特征名称", "")
- top_feature_type = overall_top_match.get("特征类型", "")
- top_persona_level = overall_top_match.get("人设特征层级", "")
- if overall_max_similarity >= 0.9:
- top_color = "#10b981"
- top_label = "相同"
- elif overall_max_similarity >= 0.8:
- top_color = "#f59e0b"
- top_label = "相似"
- else:
- top_color = "#9ca3af"
- top_label = "无关"
- overall_top_html = f'''
- <div class="overall-top-match">
- <span class="overall-top-label">最高:</span>
- <span class="overall-top-combined">[{html_module.escape(top_persona_level)}-{html_module.escape(top_feature_type)}]</span>
- <span class="overall-top-name">{html_module.escape(top_persona_name)}</span>
- <span class="overall-top-score" style="background: {top_color}; color: white;">{overall_max_similarity:.2f} {top_label}</span>
- </div>
- '''
- html = f'''
- <div class="match-results-section">
- <div class="match-section-header collapsible-header" onclick="toggleFeatureSection(event, '{section_id}')">
- <div class="header-left">
- <span class="expand-icon" id="{section_id}-icon">▼</span>
- <h4>{feature_number}. 匹配结果: {html_module.escape(feature_name)} <span class="feature-weight-display">(权重: {feature_weight})</span></h4>
- {found_status_html}
- </div>
- <div class="match-stats">{stats_html}</div>
- {overall_top_html}
- </div>
- <div class="matches-list" id="{section_id}-content">
- {matches_html}
- </div>
- </div>
- '''
- return html
- def generate_toc_html(post_data: Dict, post_idx: int, feature_status_map: Dict[str, str] = None, overall_conclusion: str = "") -> str:
- """生成目录导航HTML
- Args:
- post_data: 帖子数据
- post_idx: 帖子索引
- feature_status_map: 特征名称到状态的映射 {特征名称: "相同"|"相似"|"无关"}
- overall_conclusion: 整体结论
- """
- how_result = post_data.get("how解构结果", {})
- if feature_status_map is None:
- feature_status_map = {}
- toc_items = []
- # 帖子详情
- 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>')
- # 处理不同类型的点
- point_types = [
- ("灵感点列表", "灵感点", "toc-badge-inspiration", "💡"),
- ("目的点列表", "目的点", "toc-badge-purpose", "🎯"),
- ("关键点列表", "关键点", "toc-badge-key", "🔑")
- ]
- for list_key, point_name, badge_class, icon in point_types:
- point_list = how_result.get(list_key, [])
- if not point_list:
- continue
- # 点类型分组标题
- 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>')
- for point_idx, point in enumerate(point_list):
- name = point.get("名称", f"{point_name} {point_idx + 1}")
- name_short = name[:18] + "..." if len(name) > 18 else name
- # 计算该点的整体状态
- how_steps = point.get("how步骤列表", [])
- point_status = "无关"
- has_same = False
- has_similar = False
- if how_steps:
- features = how_steps[0].get("特征列表", [])
- for feature_data in features:
- feature_name = feature_data.get("特征名称", "")
- status = feature_status_map.get(feature_name, "无关")
- if status == "相同":
- has_same = True
- break
- elif status == "相似":
- has_similar = True
- if has_same:
- point_status = "找到"
- point_status_class = "toc-point-found"
- elif has_similar:
- point_status = "部分找到"
- point_status_class = "toc-point-partial"
- else:
- point_status = "都找不到"
- point_status_class = "toc-point-notfound"
- else:
- point_status_class = "toc-point-notfound"
- 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>')
- # 特征列表
- if how_steps:
- features = how_steps[0].get("特征列表", [])
- for feat_idx, feature_data in enumerate(features):
- feature_name = feature_data.get("特征名称", f"特征 {feat_idx + 1}")
- # 获取状态
- status = feature_status_map.get(feature_name, "无关")
- if status == "相同":
- status_class = "toc-feature-same"
- status_label = "相同"
- elif status == "相似":
- status_class = "toc-feature-similar"
- status_label = "相似"
- else:
- status_class = "toc-feature-unrelated"
- status_label = "无关"
- 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>')
- # 整体结论HTML
- conclusion_html = ""
- if overall_conclusion:
- if overall_conclusion == "找到":
- conclusion_class = "conclusion-found"
- conclusion_icon = "✓"
- elif overall_conclusion == "部分找到":
- conclusion_class = "conclusion-partial"
- conclusion_icon = "~"
- else: # 都找不到
- conclusion_class = "conclusion-not-found"
- conclusion_icon = "✗"
- conclusion_html = f'''
- <div class="toc-conclusion {conclusion_class}">
- <span class="conclusion-icon">{conclusion_icon}</span>
- <span class="conclusion-text">{overall_conclusion}</span>
- </div>
- '''
- return f'''
- <div class="toc-container">
- <div class="toc-header">目录导航</div>
- {conclusion_html}
- <div class="toc-content">
- {"".join(toc_items)}
- </div>
- </div>
- '''
- def generate_post_content_html(post_data: Dict, post_idx: int, category_mapping: Dict = None, source_mapping: Dict = None) -> str:
- """生成单个帖子的完整内容HTML"""
- # 2. 灵感点详情和匹配结果
- how_result = post_data.get("how解构结果", {})
- inspiration_list = how_result.get("灵感点列表", [])
- # 先计算所有特征的状态(基于最高相似度)
- feature_status_map = {} # {特征名称: "相同"|"相似"|"无关"}
- for inspiration_point in inspiration_list:
- how_steps = inspiration_point.get("how步骤列表", [])
- if how_steps:
- features = how_steps[0].get("特征列表", [])
- for feature_data in features:
- feature_name = feature_data.get("特征名称", "")
- match_results = feature_data.get("匹配结果", [])
- # 找出最高相似度
- max_similarity = 0.0
- if match_results:
- max_similarity = max(match.get("匹配结果", {}).get("相似度", 0) for match in match_results)
- # 根据最高相似度确定状态
- if max_similarity >= 0.9:
- feature_status_map[feature_name] = "相同"
- elif max_similarity >= 0.8:
- feature_status_map[feature_name] = "相似"
- else:
- feature_status_map[feature_name] = "无关"
- # 1. 帖子详情
- post_detail_html = generate_post_detail_html(post_data, post_idx)
- # 生成所有灵感点的详情HTML(传入状态映射)
- inspirations_detail_html = ""
- for insp_idx, inspiration_point in enumerate(inspiration_list):
- inspiration_detail = generate_inspiration_detail_html(inspiration_point, feature_status_map)
- inspirations_detail_html += f'''
- <div id="post-{post_idx}-insp-{insp_idx}" class="inspiration-detail-item content-section">
- {inspiration_detail}
- </div>
- '''
- # 生成所有匹配结果HTML,按照how步骤分组
- all_matches_html = ""
- for insp_idx, inspiration_point in enumerate(inspiration_list):
- inspiration_name = inspiration_point.get("名称", f"灵感点 {insp_idx + 1}")
- how_steps = inspiration_point.get("how步骤列表", [])
- if how_steps:
- # 为每个灵感点创建一个区域
- for step_idx, step in enumerate(how_steps):
- step_name = step.get("步骤名称", f"步骤 {step_idx + 1}")
- features = step.get("特征列表", [])
- # 生成该步骤下所有特征的匹配结果
- features_html = ""
- for feat_idx, feature_data in enumerate(features):
- match_html = generate_match_results_html([step], feat_idx, insp_idx, post_idx, category_mapping, source_mapping, "灵感点", feat_idx + 1)
- features_html += f'<div id="post-{post_idx}-feat-{insp_idx}-{feat_idx}" class="feature-match-wrapper">{match_html}</div>'
- # 生成步骤区域(可折叠)
- step_section_id = f"post-{post_idx}-step-{insp_idx}-{step_idx}"
- all_matches_html += f'''
- <div class="step-section">
- <div class="step-header collapsible-header" onclick="toggleStepSection('{step_section_id}')">
- <div class="header-left">
- <span class="expand-icon" id="{step_section_id}-icon">▼</span>
- <h3 class="step-name">{html_module.escape(step_name)}</h3>
- </div>
- <span class="step-inspiration-name">来自: {html_module.escape(inspiration_name)}</span>
- </div>
- <div class="step-features-list" id="{step_section_id}-content">
- {features_html}
- </div>
- </div>
- '''
- html = f'''
- <div class="post-content-wrapper">
- <!-- 第一个框:左右分栏(帖子详情 + 灵感点详情) -->
- <div class="top-section-box">
- <div class="two-column-layout">
- <div class="left-column">
- <div id="post-{post_idx}-detail" class="post-detail-wrapper">
- {post_detail_html}
- </div>
- </div>
- <div class="right-column">
- <div class="inspirations-detail-wrapper">
- {inspirations_detail_html}
- </div>
- </div>
- </div>
- </div>
- <!-- 下面:所有匹配结果 -->
- <div class="matches-section">
- {all_matches_html}
- </div>
- </div>
- '''
- return html
- def generate_combined_html(posts_data: List[Dict], category_mapping: Dict = None, source_mapping: Dict = None) -> str:
- """生成包含所有帖子的单一HTML(左边目录,右边内容)"""
- # 生成统一的目录(包含所有帖子)
- all_toc_items = []
- for post_idx, post in enumerate(posts_data):
- post_detail = post.get("帖子详情", {})
- title = post_detail.get("title", "无标题")
- post_id = post_detail.get("post_id", f"post_{post_idx}")
- # 获取发布时间并格式化
- publish_timestamp = post_detail.get("publish_timestamp", 0)
- if publish_timestamp:
- from datetime import datetime
- # publish_timestamp 是毫秒级时间戳,需要除以1000
- date_str = datetime.fromtimestamp(publish_timestamp / 1000).strftime("%Y-%m-%d")
- else:
- date_str = "未知日期"
- # 帖子标题作为一级目录(可折叠),在标题前显示日期
- all_toc_items.append(f'''
- <div class="toc-item toc-level-0 toc-post-header collapsed" data-post-id="{post_idx}" onclick="toggleTocPost(event, {post_idx})">
- <span class="toc-expand-icon">▼</span>
- <div class="toc-item-content">
- <span style="color: #666; font-size: 0.9em;">{date_str}</span> {html_module.escape(title[:30])}...
- </div>
- </div>
- <div class="toc-children hidden" id="toc-post-{post_idx}-children">
- ''')
- # 获取特征状态映射
- feature_status_map = {}
- how_result = post.get("how解构结果", {})
- # 先收集所有特征状态
- for point_list_key in ["灵感点列表", "目的点列表", "关键点列表"]:
- point_list = how_result.get(point_list_key, [])
- for point in point_list:
- how_steps = point.get("how步骤列表", [])
- if how_steps:
- features = how_steps[0].get("特征列表", [])
- for feature_data in features:
- feature_name = feature_data.get("特征名称", "")
- match_results = feature_data.get("匹配结果", [])
- max_similarity = 0.0
- if match_results:
- max_similarity = max(match.get("匹配结果", {}).get("相似度", 0) for match in match_results)
- if max_similarity >= 0.9:
- feature_status_map[feature_name] = "相同"
- elif max_similarity >= 0.8:
- feature_status_map[feature_name] = "相似"
- else:
- feature_status_map[feature_name] = "无关"
- # 生成点类型目录
- point_types = [
- ("灵感点列表", "灵感点", "toc-badge-inspiration", "💡"),
- ("目的点列表", "目的点", "toc-badge-purpose", "🎯"),
- ("关键点列表", "关键点", "toc-badge-key", "🔑")
- ]
- for list_key, point_name, badge_class, icon in point_types:
- point_list = how_result.get(list_key, [])
- if not point_list:
- continue
- # 点类型分组标题(可折叠),去掉"列表"两字
- group_id = f"post-{post_idx}-{list_key}"
- all_toc_items.append(f'''
- <div class="toc-item toc-level-1 toc-group-header collapsed" onclick="toggleTocGroup(event, '{group_id}')">
- <span class="toc-expand-icon">▼</span>
- <div class="toc-item-content">
- {icon} {point_name}
- </div>
- </div>
- <div class="toc-children hidden" id="toc-{group_id}-children">
- ''')
- for point_idx, point in enumerate(point_list):
- name = point.get("名称", f"{point_name} {point_idx + 1}")
- name_short = name[:18] + "..." if len(name) > 18 else name
- # 计算该点的状态
- how_steps = point.get("how步骤列表", [])
- point_status = "无关"
- has_same = False
- has_similar = False
- if how_steps:
- features = how_steps[0].get("特征列表", [])
- has_unrelated = False
- for feature_data in features:
- feature_name = feature_data.get("特征名称", "")
- status = feature_status_map.get(feature_name, "无关")
- if status == "相同":
- has_same = True
- elif status == "相似":
- has_similar = True
- elif status == "无关":
- has_unrelated = True
- # 统一使用与详情页相同的逻辑
- if not has_unrelated:
- # 没有无关的 -> 找到
- point_status = "找到"
- point_status_class = "toc-point-found"
- elif has_same or has_similar:
- # 有相同或相似,但也有无关 -> 部分找到
- point_status = "部分找到"
- point_status_class = "toc-point-partial"
- else:
- # 都是无关 -> 都找不到
- point_status = "都找不到"
- point_status_class = "toc-point-notfound"
- else:
- point_status = "都找不到"
- point_status_class = "toc-point-notfound"
- # 点项(可折叠,点击切换到该点的视图)
- # 确保ID唯一:包含list_key以区分不同类型的点
- point_item_id = f"post-{post_idx}-{list_key}-point-{point_idx}"
- point_view_id = f"view-post-{post_idx}-point-{list_key}-{point_idx}"
- all_toc_items.append(f'''
- <div class="toc-item toc-level-2 {point_status_class} collapsed" onclick="toggleTocPoint(event, '{point_item_id}', '{point_view_id}')">
- <span class="toc-expand-icon">▼</span>
- <div class="toc-item-content">
- [{point_name}] {html_module.escape(name_short)}
- <span class="toc-point-status">[{point_status}]</span>
- </div>
- </div>
- <div class="toc-children hidden" id="toc-{point_item_id}-children">
- ''')
- # 特征列表
- if how_steps:
- features = how_steps[0].get("特征列表", [])
- for feat_idx, feature_data in enumerate(features):
- feature_name = feature_data.get("特征名称", f"特征 {feat_idx + 1}")
- status = feature_status_map.get(feature_name, "无关")
- if status == "相同":
- status_class = "toc-feature-same"
- status_label = "相同"
- elif status == "相似":
- status_class = "toc-feature-similar"
- status_label = "相似"
- else:
- status_class = "toc-feature-unrelated"
- status_label = "无关"
- # 特征项(点击直接跳转,不展开子项)
- all_toc_items.append(f'''
- <div class="toc-item toc-level-3 {status_class}" onclick="clickFeature(event, '{point_view_id}', 'post-{post_idx}-feat-{point_idx}-{feat_idx}')">
- <div class="toc-item-content">
- [特征] {html_module.escape(feature_name)}
- <span class="toc-feature-status">[{status_label}]</span>
- </div>
- </div>
- ''')
- # 关闭点项的children
- all_toc_items.append('</div>')
- # 关闭点类型分组的children
- all_toc_items.append('</div>')
- # 关闭帖子的children
- all_toc_items.append('</div>')
- # 生成所有独立的内容视图
- all_content_views = []
- for post_idx, post in enumerate(posts_data):
- post_detail = post.get("帖子详情", {})
- how_result = post.get("how解构结果", {})
- # 1. 生成"仅帖子详情"的视图
- post_detail_html = generate_post_detail_html(post, post_idx)
- post_only_view = f'''
- <div class="content-view" id="view-post-{post_idx}-detail" style="display: none;">
- <div class="post-content-wrapper">
- <div class="top-section-box">
- <div id="post-{post_idx}-detail" class="post-detail-wrapper">
- {post_detail_html}
- </div>
- </div>
- </div>
- </div>
- '''
- all_content_views.append(post_only_view)
- # 2. 为每个点生成独立视图(包含帖子详情+点详情+匹配结果)
- for point_list_key in ["灵感点列表", "目的点列表", "关键点列表"]:
- point_list = how_result.get(point_list_key, [])
- # 提取点类型(去掉"列表")
- point_type = point_list_key.replace("列表", "")
- for point_idx, point in enumerate(point_list):
- # 计算该点的特征状态映射
- point_feature_status_map = {}
- how_steps = point.get("how步骤列表", [])
- if how_steps:
- features = how_steps[0].get("特征列表", [])
- for feature_data in features:
- feature_name = feature_data.get("特征名称", "")
- match_results = feature_data.get("匹配结果", [])
- max_similarity = 0.0
- if match_results:
- max_similarity = max(match.get("匹配结果", {}).get("相似度", 0) for match in match_results)
- if max_similarity >= 0.9:
- point_feature_status_map[feature_name] = "相同"
- elif max_similarity >= 0.8:
- point_feature_status_map[feature_name] = "相似"
- else:
- point_feature_status_map[feature_name] = "无关"
- # 生成点的详情HTML,传入特征状态映射和点类型
- point_detail_html = generate_inspiration_detail_html(point, point_feature_status_map, point_type)
- # 生成该点的所有特征匹配结果
- point_name = point.get("名称", f"点 {point_idx + 1}")
- matches_html = ""
- if how_steps:
- for step_idx, step in enumerate(how_steps):
- step_name = step.get("步骤名称", f"步骤 {step_idx + 1}")
- features = step.get("特征列表", [])
- features_html = ""
- for feat_idx, feature_data in enumerate(features):
- match_html = generate_match_results_html([step], feat_idx, point_idx, post_idx, category_mapping, source_mapping, point_list_key.replace("列表", ""), feat_idx + 1)
- features_html += f'<div id="post-{post_idx}-feat-{point_idx}-{feat_idx}" class="feature-match-wrapper">{match_html}</div>'
- step_section_id = f"post-{post_idx}-step-{point_idx}-{step_idx}"
- matches_html += f'''
- <div class="step-section">
- <div class="step-header collapsible-header" onclick="toggleStepSection('{step_section_id}')">
- <div class="header-left">
- <span class="expand-icon" id="{step_section_id}-icon">▼</span>
- <h3 class="step-name">{html_module.escape(step_name)}</h3>
- </div>
- <span class="step-inspiration-name">来自: {html_module.escape(point_name)}</span>
- </div>
- <div class="step-features-list" id="{step_section_id}-content">
- {features_html}
- </div>
- </div>
- '''
- # 组合成完整视图
- point_view_id = f"view-post-{post_idx}-point-{point_list_key}-{point_idx}"
- point_view = f'''
- <div class="content-view" id="{point_view_id}" style="display: none;">
- <div class="post-content-wrapper">
- <div class="top-section-box">
- <div class="two-column-layout">
- <div class="left-column">
- <div class="post-detail-wrapper">
- {post_detail_html}
- </div>
- </div>
- <div class="right-column">
- <div class="inspirations-detail-wrapper">
- <div id="post-{post_idx}-point-{point_list_key}-{point_idx}" class="inspiration-detail-item content-section">
- {point_detail_html}
- </div>
- </div>
- </div>
- </div>
- </div>
- <div class="matches-section">
- {matches_html}
- </div>
- </div>
- </div>
- '''
- all_content_views.append(point_view)
- # 组合所有内容视图
- all_contents_html = "\n".join(all_content_views)
- # 组合目录HTML
- toc_items_html = "\n".join(all_toc_items)
- html = f'''
- <!DOCTYPE html>
- <html lang="zh-CN">
- <head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>How解构结果可视化</title>
- <style>
- * {{
- margin: 0;
- padding: 0;
- box-sizing: border-box;
- }}
- body {{
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
- background: #f5f5f5;
- color: #333;
- line-height: 1.6;
- }}
- /* 两栏布局容器 */
- .page-container {{
- display: flex;
- height: 100vh;
- overflow: hidden;
- }}
- /* 左侧目录 */
- .left-sidebar {{
- width: 420px;
- background: white;
- border-right: 2px solid #e5e7eb;
- display: flex;
- flex-direction: column;
- overflow: hidden;
- }}
- /* 右侧内容区 */
- .right-content {{
- flex: 1;
- overflow-y: auto;
- background: #f5f5f5;
- padding: 0;
- position: relative;
- }}
- /* 吸顶面包屑导航 */
- .breadcrumb-nav {{
- position: sticky;
- top: 0;
- left: 0;
- right: 0;
- background: white;
- border-bottom: 2px solid #e5e7eb;
- padding: 12px 30px;
- display: flex;
- align-items: center;
- gap: 8px;
- font-size: 14px;
- z-index: 100;
- box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
- }}
- .breadcrumb-item {{
- color: #667eea;
- cursor: pointer;
- transition: all 0.2s;
- font-weight: 500;
- }}
- .breadcrumb-item:hover {{
- color: #5568d3;
- text-decoration: underline;
- }}
- .breadcrumb-separator {{
- color: #9ca3af;
- user-select: none;
- }}
- .content-view {{
- padding: 30px;
- width: 100%;
- height: 100%;
- }}
- .content-view.active {{
- display: block !important;
- }}
- .post-content-wrapper {{
- max-width: 1600px;
- margin: 0 auto;
- display: flex;
- flex-direction: column;
- gap: 30px;
- }}
- .container {{
- max-width: 1600px;
- margin: 0 auto;
- background: white;
- min-height: 100vh;
- }}
- .header {{
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
- color: white;
- padding: 20px 30px;
- text-align: center;
- }}
- .header h1 {{
- font-size: 28px;
- font-weight: bold;
- margin-bottom: 5px;
- }}
- .header p {{
- font-size: 14px;
- opacity: 0.9;
- }}
- .toc-header {{
- padding: 15px 20px;
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
- color: white;
- font-weight: 600;
- font-size: 16px;
- border-bottom: 2px solid #5a67d8;
- }}
- .toc-content {{
- padding: 10px;
- overflow-y: auto;
- flex: 1;
- }}
- .toc-level-0 {{
- color: #111827;
- font-weight: 700;
- font-size: 15px;
- background: #f9fafb;
- margin-top: 8px;
- margin-bottom: 4px;
- }}
- .toc-post-header {{
- font-weight: 700;
- }}
- .toc-badge-info {{
- background: #dbeafe;
- color: #1e40af;
- }}
- .toc-item {{
- padding: 10px 15px;
- margin: 4px 0;
- cursor: pointer;
- border-radius: 6px;
- font-size: 14px;
- transition: all 0.2s;
- user-select: none;
- display: flex;
- align-items: center;
- gap: 8px;
- }}
- .toc-item:hover {{
- background: #f9fafb;
- }}
- .toc-item.active {{
- color: #667eea;
- font-weight: 600;
- }}
- .toc-item.active-ancestor {{
- color: #667eea;
- }}
- .toc-expand-icon {{
- font-size: 10px;
- transition: transform 0.2s;
- color: #9ca3af;
- flex-shrink: 0;
- }}
- .toc-item.collapsed .toc-expand-icon {{
- transform: rotate(-90deg);
- }}
- .toc-item-content {{
- flex: 1;
- min-width: 0;
- }}
- /* 子项隐藏 */
- .toc-children {{
- display: block;
- }}
- .toc-children.hidden {{
- display: none;
- }}
- .toc-level-1 {{
- color: #111827;
- font-weight: 500;
- padding-left: 20px;
- }}
- .toc-level-2 {{
- color: #6b7280;
- font-size: 13px;
- padding-left: 40px;
- }}
- .toc-level-3 {{
- padding-left: 60px;
- font-size: 12px;
- color: #9ca3af;
- }}
- .toc-level-4 {{
- padding-left: 80px;
- font-size: 11px;
- color: #9ca3af;
- }}
- .toc-feature-status {{
- font-weight: 500;
- font-size: 10px;
- padding: 2px 6px;
- border-radius: 4px;
- margin-left: auto;
- background: #f3f4f6;
- color: #6b7280;
- }}
- /* 目录整体结论 */
- .toc-conclusion {{
- padding: 15px 20px;
- margin: 10px;
- border-radius: 8px;
- display: flex;
- align-items: center;
- gap: 10px;
- font-weight: 600;
- font-size: 14px;
- }}
- .toc-conclusion.conclusion-found {{
- background: #d1fae5;
- color: #065f46;
- border: 2px solid #10b981;
- }}
- .toc-conclusion.conclusion-partial {{
- background: #fed7aa;
- color: #92400e;
- border: 2px solid #f59e0b;
- }}
- .toc-conclusion.conclusion-not-found {{
- background: #fee2e2;
- color: #991b1b;
- border: 2px solid #ef4444;
- }}
- .conclusion-icon {{
- font-size: 18px;
- font-weight: 700;
- }}
- .conclusion-text {{
- flex: 1;
- }}
- .toc-badge {{
- display: inline-block;
- font-size: 12px;
- margin-right: 6px;
- opacity: 0.8;
- }}
- .toc-group-header {{
- font-weight: 600;
- cursor: pointer !important;
- }}
- .toc-point-status {{
- font-size: 10px;
- font-weight: 500;
- padding: 2px 6px;
- border-radius: 4px;
- margin-left: auto;
- background: #f3f4f6;
- color: #6b7280;
- }}
- .main-content {{
- min-width: 0;
- display: flex;
- flex-direction: column;
- gap: 30px;
- }}
- /* 顶部框:包含帖子详情和灵感点详情 */
- .top-section-box {{
- background: white;
- border: 1px solid #e5e7eb;
- border-radius: 12px;
- padding: 25px;
- box-shadow: 0 2px 8px rgba(0,0,0,0.05);
- }}
- /* 顶部框内的两栏布局 */
- .two-column-layout {{
- display: grid;
- grid-template-columns: 380px 1fr;
- gap: 25px;
- align-items: start;
- }}
- .left-column {{
- position: relative;
- }}
- .post-detail-wrapper {{
- /* 帖子详情区域 */
- }}
- .right-column {{
- min-width: 0;
- }}
- .inspirations-detail-wrapper {{
- display: flex;
- flex-direction: column;
- gap: 20px;
- max-height: 600px;
- overflow-y: auto;
- padding-right: 10px;
- }}
- .inspiration-detail-item {{
- /* 单个灵感点详情 */
- }}
- /* 匹配结果区域 */
- .matches-section {{
- display: flex;
- flex-direction: column;
- gap: 20px;
- }}
- /* 步骤区域 */
- .step-section {{
- background: white;
- border: 2px solid #e5e7eb;
- border-radius: 12px;
- overflow: hidden;
- box-shadow: 0 2px 8px rgba(0,0,0,0.05);
- transition: all 0.3s ease;
- }}
- .step-section:hover {{
- box-shadow: 0 4px 12px rgba(0,0,0,0.08);
- border-color: #d1d5db;
- }}
- .step-header {{
- padding: 18px 24px;
- background: linear-gradient(135deg, #f9fafb 0%, #ffffff 100%);
- border-bottom: 2px solid #e5e7eb;
- display: flex;
- justify-content: space-between;
- align-items: center;
- cursor: pointer;
- user-select: none;
- transition: all 0.3s ease;
- }}
- .step-header:hover {{
- background: linear-gradient(135deg, #f3f4f6 0%, #f9fafb 100%);
- }}
- .step-name {{
- font-size: 18px;
- color: #111827;
- font-weight: 600;
- margin: 0;
- }}
- .step-inspiration-name {{
- font-size: 14px;
- color: #6b7280;
- font-style: italic;
- }}
- .step-features-list {{
- padding: 20px;
- display: flex;
- flex-direction: column;
- gap: 15px;
- }}
- .feature-match-wrapper {{
- /* 特征匹配容器 */
- }}
- .content-section {{
- scroll-margin-top: 80px;
- }}
- /* 帖子详情卡片(紧凑样式) */
- .post-card-compact {{
- display: grid;
- grid-template-columns: 200px 1fr;
- gap: 20px;
- background: white;
- border: 1px solid #e5e7eb;
- border-radius: 12px;
- overflow: hidden;
- padding: 20px;
- cursor: pointer;
- transition: all 0.3s;
- margin-bottom: 30px;
- }}
- .post-card-compact:hover {{
- box-shadow: 0 4px 12px rgba(0,0,0,0.1);
- transform: translateY(-2px);
- border-color: #667eea;
- }}
- .post-card-image {{
- position: relative;
- display: flex;
- align-items: center;
- justify-content: center;
- }}
- .post-card-thumbnail {{
- width: 100%;
- height: 150px;
- object-fit: contain;
- border-radius: 8px;
- background: #f9fafb;
- }}
- .post-card-thumbnail-placeholder {{
- width: 100%;
- height: 150px;
- display: flex;
- align-items: center;
- justify-content: center;
- background: #f3f4f6;
- color: #9ca3af;
- font-size: 48px;
- border-radius: 8px;
- }}
- .post-card-image-count {{
- position: absolute;
- bottom: 8px;
- right: 8px;
- background: rgba(0, 0, 0, 0.7);
- color: white;
- padding: 4px 8px;
- border-radius: 12px;
- font-size: 11px;
- font-weight: 600;
- }}
- .post-card-content {{
- display: flex;
- flex-direction: column;
- gap: 12px;
- }}
- .post-card-title {{
- font-size: 18px;
- font-weight: 600;
- color: #111827;
- line-height: 1.4;
- overflow: hidden;
- text-overflow: ellipsis;
- display: -webkit-box;
- -webkit-line-clamp: 2;
- -webkit-box-orient: vertical;
- }}
- .post-card-preview {{
- font-size: 14px;
- color: #6b7280;
- line-height: 1.6;
- overflow: hidden;
- text-overflow: ellipsis;
- display: -webkit-box;
- -webkit-line-clamp: 2;
- -webkit-box-orient: vertical;
- }}
- .post-card-meta {{
- display: flex;
- gap: 15px;
- font-size: 13px;
- color: #9ca3af;
- }}
- .post-card-stats {{
- display: flex;
- gap: 15px;
- font-size: 13px;
- color: #6b7280;
- font-weight: 500;
- }}
- /* 灵感点详情卡片样式保持不变 */
- .inspiration-detail-card {{
- background: linear-gradient(135deg, #f9fafb 0%, #ffffff 100%);
- padding: 25px;
- border-bottom: 2px solid #e5e7eb;
- }}
- .inspiration-header {{
- margin-bottom: 15px;
- display: flex;
- align-items: center;
- gap: 10px;
- flex-wrap: wrap;
- }}
- .inspiration-conclusion {{
- padding: 6px 14px;
- border-radius: 16px;
- font-size: 13px;
- font-weight: 700;
- margin-left: auto;
- }}
- .inspiration-conclusion.insp-conclusion-found {{
- background: #d1fae5;
- color: #065f46;
- border: 2px solid #10b981;
- }}
- .inspiration-conclusion.insp-conclusion-partial {{
- background: #fed7aa;
- color: #92400e;
- border: 2px solid #f59e0b;
- }}
- .inspiration-conclusion.insp-conclusion-not-found {{
- background: #fee2e2;
- color: #991b1b;
- border: 2px solid #ef4444;
- }}
- .inspiration-type-badge {{
- display: inline-block;
- padding: 4px 12px;
- background: #fef3c7;
- color: #92400e;
- border-radius: 12px;
- font-size: 12px;
- font-weight: 600;
- }}
- .inspiration-name {{
- font-size: 20px;
- color: #111827;
- font-weight: 600;
- margin: 0;
- }}
- .inspiration-description {{
- margin-bottom: 15px;
- }}
- .desc-label {{
- font-weight: 600;
- color: #6b7280;
- font-size: 13px;
- margin-bottom: 8px;
- }}
- .desc-text {{
- color: #4b5563;
- font-size: 14px;
- line-height: 1.7;
- }}
- .inspiration-features {{
- margin-top: 15px;
- }}
- .features-label {{
- font-weight: 600;
- color: #6b7280;
- font-size: 13px;
- margin-bottom: 8px;
- }}
- .features-tags {{
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
- }}
- .feature-tag {{
- padding: 5px 12px;
- background: #667eea;
- color: white;
- border-radius: 16px;
- font-size: 13px;
- font-weight: 500;
- display: inline-flex;
- align-items: center;
- gap: 4px;
- }}
- .feature-tag.feature-same {{
- background: #10b981;
- }}
- .feature-tag.feature-similar {{
- background: #f59e0b;
- }}
- .feature-tag.feature-unrelated {{
- background: #9ca3af;
- }}
- .feature-status-label {{
- font-weight: 700;
- font-size: 11px;
- padding: 2px 6px;
- background: rgba(255, 255, 255, 0.3);
- border-radius: 4px;
- }}
- /* 匹配结果部分 */
- .match-results-section {{
- padding: 48px 25px;
- border-bottom: none;
- }}
- .match-results-section:first-child {{
- padding-top: 25px;
- }}
- .match-section-header {{
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding: 10px 0 24px 0;
- background: transparent;
- border: none;
- border-bottom: 1px solid #e5e7eb;
- border-radius: 0;
- gap: 12px;
- cursor: pointer;
- user-select: none;
- transition: all 0.2s ease;
- }}
- .match-section-header:hover {{
- border-bottom-color: #9ca3af;
- }}
- .header-left {{
- display: flex;
- align-items: center;
- gap: 10px;
- flex: 1;
- }}
- .collapsible-header {{
- cursor: pointer;
- user-select: none;
- transition: background 0.2s;
- padding: 10px;
- margin: -10px;
- border-radius: 6px;
- }}
- .collapsible-header:hover {{
- background: #f9fafb;
- }}
- .overall-top-match {{
- display: inline-flex;
- align-items: center;
- gap: 8px;
- padding: 6px 12px;
- background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%);
- border-radius: 8px;
- font-size: 13px;
- border: 1px solid #bae6fd;
- flex-shrink: 0;
- }}
- .overall-top-label {{
- font-weight: 700;
- color: #0369a1;
- }}
- .overall-top-combined {{
- padding: 3px 8px;
- background: linear-gradient(135deg, #e0e7ff 0%, #fef3c7 100%);
- color: #4338ca;
- border-radius: 6px;
- font-size: 11px;
- font-weight: 600;
- border: 1px solid #c7d2fe;
- }}
- .overall-top-name {{
- font-weight: 700;
- color: #1e40af;
- }}
- .overall-top-score {{
- padding: 4px 12px;
- border-radius: 12px;
- font-size: 13px;
- font-weight: 700;
- margin-left: auto;
- }}
- .match-section-header .header-left {{
- display: flex;
- align-items: center;
- gap: 10px;
- flex: 1;
- }}
- .match-section-header h4 {{
- font-size: 18px;
- color: #111827;
- margin: 0;
- }}
- .match-stats {{
- display: flex;
- gap: 10px;
- flex-wrap: wrap;
- }}
- .stat-badge {{
- padding: 4px 10px;
- border-radius: 12px;
- color: white;
- font-size: 12px;
- font-weight: 600;
- }}
- .matches-list {{
- display: flex;
- flex-direction: column;
- gap: 8px;
- transition: all 0.3s ease;
- overflow: hidden;
- }}
- .match-group-section {{
- margin-bottom: 20px;
- background: white;
- border: 1px solid #e5e7eb;
- border-radius: 8px;
- overflow: hidden;
- }}
- .match-group-header {{
- padding: 12px 18px;
- background: linear-gradient(135deg, #f3f4f6 0%, #e5e7eb 100%);
- border-bottom: 2px solid #d1d5db;
- cursor: pointer;
- user-select: none;
- display: flex;
- align-items: center;
- gap: 10px;
- transition: background 0.2s;
- }}
- .match-group-header:hover {{
- background: linear-gradient(135deg, #e5e7eb 0%, #d1d5db 100%);
- }}
- .match-group-title {{
- font-size: 15px;
- font-weight: 600;
- color: #374151;
- margin: 0;
- }}
- .match-group-content {{
- padding: 15px;
- display: flex;
- flex-direction: row;
- flex-wrap: wrap;
- gap: 10px;
- }}
- /* 层级分组样式 - 极简无边框 */
- .level-group-section {{
- background: transparent;
- border: none;
- border-radius: 0;
- overflow: visible;
- }}
- .level-group-header {{
- padding: 20px 0 16px 0;
- background: transparent;
- border-bottom: 1px solid #e5e7eb;
- cursor: pointer;
- user-select: none;
- display: flex;
- justify-content: space-between;
- align-items: center;
- transition: all 0.2s ease;
- }}
- .level-group-header:hover {{
- border-bottom-color: #9ca3af;
- }}
- .level-group-header .expand-icon {{
- color: #6b7280;
- font-size: 13px;
- }}
- .level-header-left {{
- display: flex;
- align-items: center;
- gap: 8px;
- }}
- .level-group-title {{
- font-size: 15px;
- font-weight: 600;
- color: #111827;
- margin: 0;
- }}
- .level-stats {{
- display: inline-flex;
- gap: 8px;
- align-items: center;
- }}
- .level-top-match {{
- display: inline-flex;
- align-items: center;
- gap: 6px;
- font-size: 12px;
- color: #6b7280;
- }}
- .top-match-label {{
- font-weight: 500;
- color: #9ca3af;
- }}
- .top-match-type {{
- padding: 2px 6px;
- background: #f3f4f6;
- border-radius: 4px;
- font-size: 11px;
- font-weight: 500;
- color: #6b7280;
- }}
- .top-match-name {{
- font-weight: 500;
- color: #374151;
- }}
- .top-match-score {{
- padding: 3px 10px;
- border-radius: 12px;
- font-size: 12px;
- font-weight: 700;
- margin-left: auto;
- }}
- .level-group-content {{
- padding: 0;
- background: transparent;
- transition: all 0.3s ease;
- overflow: hidden;
- }}
- .level-group-content.collapsed {{
- display: none;
- }}
- /* 子分组样式 - 极简 */
- .match-subgroup-section {{
- display: block;
- width: 100%;
- background: transparent;
- border-radius: 0;
- overflow: visible;
- }}
- .match-subgroup-header {{
- padding: 12px 0 8px 0;
- background: transparent;
- cursor: pointer;
- user-select: none;
- display: flex;
- justify-content: space-between;
- align-items: center;
- transition: all 0.2s ease;
- border-bottom: 1px solid #f3f4f6;
- }}
- .match-subgroup-header:hover {{
- border-bottom-color: #e5e7eb;
- }}
- .subgroup-header-left {{
- display: flex;
- align-items: center;
- gap: 8px;
- }}
- .match-subgroup-title {{
- font-size: 13px;
- font-weight: 600;
- color: #374151;
- margin: 0;
- }}
- .subgroup-top-match {{
- display: inline-flex;
- align-items: center;
- gap: 6px;
- font-size: 11px;
- color: #6b7280;
- }}
- .subgroup-top-label {{
- font-weight: 500;
- color: #9ca3af;
- }}
- .subgroup-top-combined {{
- padding: 2px 6px;
- background: #f3f4f6;
- color: #6b7280;
- border-radius: 4px;
- font-size: 10px;
- font-weight: 500;
- }}
- .subgroup-top-name {{
- font-weight: 500;
- color: #374151;
- }}
- .subgroup-top-score {{
- padding: 2px 8px;
- border-radius: 10px;
- font-size: 11px;
- font-weight: 600;
- }}
- .match-subgroup-content {{
- padding: 0;
- display: flex;
- flex-direction: row;
- flex-wrap: wrap;
- gap: 8px;
- transition: all 0.3s ease;
- overflow: hidden;
- background: transparent;
- }}
- .match-item-compact {{
- display: inline-flex;
- align-items: center;
- gap: 6px;
- padding: 8px 14px;
- background: transparent;
- border: 1.5px solid #e5e7eb;
- border-radius: 8px;
- font-size: 13px;
- transition: all 0.3s ease;
- cursor: pointer;
- }}
- .match-item-compact:hover {{
- border-color: #667eea;
- box-shadow: 0 4px 8px rgba(102, 126, 234, 0.15);
- transform: translateY(-2px);
- background: #fafbff;
- }}
- .match-item-compact:active {{
- transform: translateY(0);
- }}
- /* 匹配项中的层级-类型标签 */
- .match-item-compact .feature-combined-badge {{
- color: #9ca3af;
- }}
- /* 匹配详情模态框 */
- .match-modal {{
- display: none;
- position: fixed;
- z-index: 3000;
- left: 0;
- top: 0;
- width: 100%;
- height: 100%;
- background: rgba(0, 0, 0, 0.5);
- animation: fadeIn 0.2s;
- }}
- .match-modal-content {{
- position: relative;
- background: white;
- margin: 5% auto;
- padding: 0;
- width: 80%;
- max-width: 900px;
- max-height: 80vh;
- border-radius: 12px;
- box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
- overflow: hidden;
- animation: slideDown 0.3s;
- }}
- .match-modal-header {{
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
- color: white;
- padding: 20px 25px;
- display: flex;
- align-items: center;
- justify-content: space-between;
- }}
- .match-modal-title {{
- font-size: 18px;
- font-weight: 600;
- margin: 0;
- }}
- .match-modal-close {{
- color: white;
- font-size: 32px;
- font-weight: 300;
- cursor: pointer;
- background: none;
- border: none;
- padding: 0;
- line-height: 1;
- transition: transform 0.2s;
- }}
- .match-modal-close:hover {{
- transform: scale(1.2);
- }}
- .match-modal-body {{
- padding: 25px;
- max-height: calc(80vh - 80px);
- overflow-y: auto;
- }}
- .match-detail-section {{
- margin-bottom: 25px;
- }}
- .match-detail-section h3 {{
- font-size: 16px;
- font-weight: 600;
- color: #374151;
- margin-bottom: 12px;
- padding-bottom: 8px;
- border-bottom: 2px solid #e5e7eb;
- }}
- .match-explanation-text {{
- line-height: 1.8;
- color: #4b5563;
- padding: 15px;
- background: #f9fafb;
- border-radius: 8px;
- border-left: 4px solid #667eea;
- }}
- .match-item-collapsible {{
- border: 1px solid #e5e7eb;
- border-radius: 8px;
- overflow: hidden;
- background: white;
- transition: box-shadow 0.2s;
- }}
- .match-item-collapsible:hover {{
- box-shadow: 0 2px 8px rgba(0,0,0,0.08);
- }}
- .match-header {{
- padding: 12px 16px;
- cursor: pointer;
- user-select: none;
- transition: background 0.2s;
- }}
- .match-header:hover {{
- background: #f9fafb;
- }}
- .match-header-left {{
- display: flex;
- align-items: center;
- gap: 10px;
- }}
- .expand-icon {{
- color: #9ca3af;
- font-size: 14px;
- transition: transform 0.3s ease;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 20px;
- height: 20px;
- flex-shrink: 0;
- }}
- .expand-icon.expanded {{
- transform: rotate(0deg);
- }}
- /* 折叠状态时旋转 */
- .collapsed .expand-icon {{
- transform: rotate(-90deg);
- }}
- .persona-name {{
- font-weight: 600;
- font-size: 14px;
- color: #111827;
- }}
- .match-item-compact .persona-name {{
- font-weight: 500;
- font-size: 13px;
- color: #374151;
- }}
- .relation-badge {{
- padding: 3px 10px;
- border-radius: 12px;
- color: white;
- font-size: 11px;
- font-weight: 600;
- }}
- .score-badge {{
- padding: 3px 10px;
- border-radius: 12px;
- background: #e5e7eb;
- color: #374151;
- font-size: 11px;
- font-weight: 600;
- }}
- .feature-combined-badge {{
- padding: 3px 8px;
- border-radius: 10px;
- background: transparent;
- color: #9ca3af;
- font-size: 10px;
- font-weight: 500;
- border: none;
- }}
- .feature-combined-badge.combined-badge-same {{
- background: transparent;
- color: #9ca3af;
- border: none;
- box-shadow: none;
- font-weight: 500;
- }}
- .feature-category-badge {{
- padding: 3px 8px;
- border-radius: 10px;
- background: #dbeafe;
- color: #1e40af;
- font-size: 10px;
- font-weight: 500;
- border: 1px solid #93c5fd;
- }}
- /* 同层级匹配 - 统一边框颜色 */
- .match-item-compact.match-same-level {{
- border: 1.5px solid #e5e7eb;
- background: transparent;
- box-shadow: none;
- }}
- .match-item-compact.match-same-level:hover {{
- border-color: #667eea;
- box-shadow: 0 4px 8px rgba(102, 126, 234, 0.15);
- transform: translateY(-2px);
- background: #fafbff;
- }}
- .level-badge {{
- padding: 4px 10px;
- border-radius: 12px;
- font-size: 11px;
- font-weight: 600;
- margin-right: 6px;
- }}
- .level-badge-match {{
- background: #e0e7ff;
- color: #4338ca;
- border: 1px solid #a5b4fc;
- }}
- .level-badge-category {{
- background: #fef3c7;
- color: #92400e;
- border: 1px solid #fcd34d;
- }}
- .feature-match-status {{
- padding: 5px 12px;
- border-radius: 12px;
- font-size: 12px;
- font-weight: 700;
- margin-left: 12px;
- }}
- .feature-match-status.status-same {{
- background: #d1fae5;
- color: #065f46;
- border: 2px solid #10b981;
- }}
- .feature-match-status.status-similar {{
- background: #fed7aa;
- color: #92400e;
- border: 2px solid #f59e0b;
- }}
- .feature-match-status.status-unrelated {{
- background: #e5e7eb;
- color: #4b5563;
- border: 2px solid #9ca3af;
- }}
- .match-content {{
- padding: 16px;
- background: #f9fafb;
- border-top: 1px solid #e5e7eb;
- }}
- .match-explanation {{
- font-size: 13px;
- color: #4b5563;
- line-height: 1.7;
- margin-bottom: 16px;
- }}
- /* 历史帖子来源区域 */
- .historical-posts-section {{
- margin-top: 20px;
- padding-top: 20px;
- border-top: 2px solid #e5e7eb;
- }}
- .historical-posts-title {{
- font-size: 14px;
- font-weight: 600;
- color: #374151;
- margin-bottom: 12px;
- }}
- .historical-posts-grid {{
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
- gap: 16px;
- }}
- .historical-post-card {{
- display: grid;
- grid-template-columns: 120px 1fr;
- gap: 12px;
- background: white;
- border: 1px solid #e5e7eb;
- border-radius: 8px;
- padding: 12px;
- transition: all 0.2s;
- cursor: pointer;
- }}
- .historical-post-card:hover {{
- border-color: #667eea;
- box-shadow: 0 2px 8px rgba(102, 126, 234, 0.1);
- transform: translateY(-2px);
- }}
- .historical-post-image {{
- position: relative;
- }}
- .historical-post-thumbnail {{
- width: 100%;
- height: 100px;
- object-fit: contain;
- border-radius: 6px;
- background: #f9fafb;
- }}
- .historical-post-content {{
- display: flex;
- flex-direction: column;
- gap: 8px;
- }}
- .historical-post-title {{
- font-size: 13px;
- font-weight: 600;
- color: #1f2937;
- line-height: 1.4;
- overflow: hidden;
- display: -webkit-box;
- -webkit-line-clamp: 2;
- -webkit-box-orient: vertical;
- }}
- .historical-inspiration-info {{
- display: flex;
- align-items: center;
- gap: 6px;
- }}
- .inspiration-type-badge-small {{
- padding: 2px 6px;
- border-radius: 4px;
- background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
- color: white;
- font-size: 10px;
- font-weight: 600;
- }}
- .inspiration-name-small {{
- font-size: 11px;
- color: #667eea;
- font-weight: 500;
- }}
- .historical-inspiration-desc {{
- font-size: 11px;
- color: #6b7280;
- line-height: 1.5;
- overflow: hidden;
- display: -webkit-box;
- -webkit-line-clamp: 2;
- -webkit-box-orient: vertical;
- }}
- .historical-post-meta {{
- margin: 4px 0;
- }}
- .historical-post-time {{
- font-size: 11px;
- color: #9ca3af;
- }}
- .historical-post-stats {{
- display: flex;
- align-items: center;
- gap: 10px;
- font-size: 11px;
- color: #9ca3af;
- }}
- .view-link {{
- margin-left: auto;
- color: #667eea;
- text-decoration: none;
- font-weight: 500;
- }}
- .view-link:hover {{
- color: #764ba2;
- }}
- .category-simple {{
- color: #9ca3af;
- font-size: 12px;
- font-weight: 400;
- margin-right: 6px;
- }}
- /* 帖子详情模态框 */
- .post-detail-modal {{
- display: none;
- position: fixed;
- z-index: 4000;
- left: 0;
- top: 0;
- width: 100%;
- height: 100%;
- background: rgba(0, 0, 0, 0.7);
- overflow: auto;
- animation: fadeIn 0.3s;
- }}
- .post-detail-modal.active {{
- display: flex;
- align-items: center;
- justify-content: center;
- padding: 40px 20px;
- }}
- .post-detail-content {{
- position: relative;
- background: white;
- border-radius: 16px;
- max-width: 900px;
- width: 100%;
- max-height: 90vh;
- overflow-y: auto;
- box-shadow: 0 20px 60px rgba(0,0,0,0.3);
- }}
- .post-detail-close {{
- position: sticky;
- top: 20px;
- right: 20px;
- float: right;
- font-size: 36px;
- font-weight: 300;
- color: #9ca3af;
- background: white;
- border: none;
- cursor: pointer;
- width: 40px;
- height: 40px;
- border-radius: 50%;
- display: flex;
- align-items: center;
- justify-content: center;
- transition: all 0.2s;
- z-index: 10;
- box-shadow: 0 2px 8px rgba(0,0,0,0.1);
- }}
- .post-detail-close:hover {{
- color: #ef4444;
- background: #fee2e2;
- transform: rotate(90deg);
- }}
- .post-detail-header {{
- padding: 40px 40px 20px 40px;
- border-bottom: 2px solid #e5e7eb;
- }}
- .post-detail-title {{
- font-size: 28px;
- font-weight: bold;
- color: #111827;
- line-height: 1.4;
- margin-bottom: 15px;
- }}
- .post-detail-meta {{
- display: flex;
- justify-content: space-between;
- align-items: center;
- color: #6b7280;
- font-size: 14px;
- gap: 20px;
- }}
- .post-detail-author {{
- font-weight: 500;
- }}
- .post-detail-time {{
- color: #9ca3af;
- }}
- .post-detail-stats {{
- display: flex;
- gap: 15px;
- font-weight: 500;
- }}
- .post-detail-body {{
- padding: 30px 40px;
- }}
- .post-detail-desc {{
- font-size: 15px;
- color: #4b5563;
- line-height: 1.8;
- margin-bottom: 25px;
- white-space: pre-wrap;
- }}
- .post-detail-images {{
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
- gap: 15px;
- margin-top: 20px;
- }}
- .post-detail-image {{
- width: 100%;
- border-radius: 8px;
- object-fit: cover;
- cursor: pointer;
- transition: transform 0.2s;
- }}
- .post-detail-image:hover {{
- transform: scale(1.02);
- }}
- .post-detail-footer {{
- padding: 20px 40px 30px 40px;
- border-top: 1px solid #e5e7eb;
- text-align: center;
- }}
- .post-detail-link {{
- display: inline-block;
- padding: 12px 30px;
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
- color: white;
- text-decoration: none;
- border-radius: 8px;
- font-weight: 500;
- transition: all 0.3s;
- }}
- .post-detail-link:hover {{
- transform: translateY(-2px);
- box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
- }}
- /* 响应式 */
- @media (max-width: 1400px) {{
- .content-with-toc {{
- grid-template-columns: 240px 1fr;
- }}
- .two-column-layout {{
- grid-template-columns: 320px 1fr;
- }}
- }}
- @media (max-width: 1200px) {{
- .content-with-toc {{
- grid-template-columns: 200px 1fr;
- }}
- .two-column-layout {{
- grid-template-columns: 280px 1fr;
- }}
- }}
- @media (max-width: 1024px) {{
- .page-container {{
- flex-direction: column;
- height: auto;
- }}
- .left-sidebar {{
- width: 100%;
- max-height: 300px;
- border-right: none;
- border-bottom: 2px solid #e5e7eb;
- }}
- .right-content {{
- height: auto;
- }}
- .two-column-layout {{
- grid-template-columns: 1fr;
- }}
- .inspirations-detail-wrapper {{
- max-height: none;
- }}
- .post-card-compact {{
- grid-template-columns: 150px 1fr;
- }}
- }}
- @media (max-width: 768px) {{
- .header {{
- padding: 15px 20px;
- }}
- .header h1 {{
- font-size: 24px;
- }}
- .left-sidebar {{
- max-height: 250px;
- }}
- .post-info-section {{
- padding: 20px;
- }}
- }}
- </style>
- </head>
- <body>
- <div class="page-container">
- <!-- 左侧目录 -->
- <div class="left-sidebar">
- <div class="header">
- <h1>How 解构结果</h1>
- <p>灵感点特征匹配分析</p>
- </div>
- <div class="toc-content">
- {toc_items_html}
- </div>
- </div>
- <!-- 右侧内容 -->
- <div class="right-content">
- <!-- 吸顶面包屑导航 -->
- <div class="breadcrumb-nav" id="breadcrumb-nav">
- <span class="breadcrumb-item" id="breadcrumb-point">-</span>
- <span class="breadcrumb-separator" id="sep-1" style="display:none;">></span>
- <span class="breadcrumb-item" id="breadcrumb-step" style="display:none;">-</span>
- <span class="breadcrumb-separator" id="sep-2" style="display:none;">></span>
- <span class="breadcrumb-item" id="breadcrumb-feature" style="display:none;">-</span>
- <span class="breadcrumb-separator" id="sep-3" style="display:none;">></span>
- <span class="breadcrumb-item" id="breadcrumb-level" style="display:none;">-</span>
- <span class="breadcrumb-separator" id="sep-4" style="display:none;">></span>
- <span class="breadcrumb-item" id="breadcrumb-subgroup" style="display:none;">-</span>
- </div>
- {all_contents_html}
- </div>
- </div>
- <!-- 匹配详情模态框 -->
- <div id="matchModal" class="match-modal" onclick="closeMatchModal(event)">
- <div class="match-modal-content" onclick="event.stopPropagation()">
- <div class="match-modal-header">
- <h2 class="match-modal-title" id="matchModalTitle"></h2>
- <button class="match-modal-close" onclick="closeMatchModal()">×</button>
- </div>
- <div class="match-modal-body" id="matchModalBody"></div>
- </div>
- </div>
- <!-- 帖子详情模态框 -->
- <div id="postDetailModal" class="post-detail-modal" onclick="closePostDetail(event)"></div>
- <script>
- // 切换内容视图
- function showContentView(viewId, clickedElement) {{
- // 隐藏所有视图
- var allViews = document.querySelectorAll('.content-view');
- allViews.forEach(function(view) {{
- view.style.display = 'none';
- view.classList.remove('active');
- }});
- // 显示指定视图
- var targetView = document.getElementById(viewId);
- if (targetView) {{
- targetView.style.display = 'block';
- targetView.classList.add('active');
- }}
- // 更新目录项的active状态:清除所有,然后标记当前项和祖先
- var tocItems = document.querySelectorAll('.toc-item');
- tocItems.forEach(function(item) {{
- item.classList.remove('active', 'active-ancestor');
- }});
- // 标记当前激活项
- var activeItem = clickedElement || (event && event.currentTarget);
- if (activeItem && activeItem.classList.contains('toc-item')) {{
- activeItem.classList.add('active');
- // 标记所有祖先节点
- var parent = activeItem.parentElement;
- while (parent) {{
- // 查找上一个兄弟节点中的toc-item
- var prevSibling = parent.previousElementSibling;
- if (prevSibling && prevSibling.classList.contains('toc-item')) {{
- prevSibling.classList.add('active-ancestor');
- }}
- parent = parent.parentElement;
- // 到达toc-content就停止
- if (parent && parent.classList.contains('toc-content')) {{
- break;
- }}
- }}
- }}
- }}
- // 折叠/展开帖子
- function toggleTocPost(event, postIdx) {{
- event.stopPropagation();
- var item = event.currentTarget;
- var children = document.getElementById('toc-post-' + postIdx + '-children');
- item.classList.toggle('collapsed');
- children.classList.toggle('hidden');
- }}
- // 折叠/展开点类型分组
- function toggleTocGroup(event, groupId) {{
- event.stopPropagation();
- var item = event.currentTarget;
- var children = document.getElementById('toc-' + groupId + '-children');
- item.classList.toggle('collapsed');
- children.classList.toggle('hidden');
- }}
- // 折叠/展开点项(同时切换到该点的内容视图)
- function toggleTocPoint(event, pointItemId, viewId) {{
- event.stopPropagation();
- // 如果点击的是展开图标,只切换折叠状态
- if (event.target.classList.contains('toc-expand-icon')) {{
- event.preventDefault();
- var item = event.currentTarget;
- var children = document.getElementById('toc-' + pointItemId + '-children');
- item.classList.toggle('collapsed');
- children.classList.toggle('hidden');
- }} else {{
- // 如果点击的是其他部分,切换到该点的视图
- showContentView(viewId, event.currentTarget);
- // 滚动到右侧内容区域的顶部
- var rightContent = document.querySelector('.right-content');
- if (rightContent) {{
- rightContent.scrollTo({{ top: 0, behavior: 'smooth' }});
- }}
- }}
- }}
- // 折叠/展开特征项(特征有子层级)
- function toggleTocFeature(event, featureItemId, viewId, featureSectionId) {{
- event.stopPropagation();
- // 如果点击的是展开图标,只切换折叠状态
- if (event.target.classList.contains('toc-expand-icon')) {{
- event.preventDefault();
- var item = event.currentTarget;
- var children = document.getElementById('toc-' + featureItemId + '-children');
- item.classList.toggle('collapsed');
- children.classList.toggle('hidden');
- }} else {{
- // 如果点击的是其他部分,切换到该点的视图并滚动到特征
- showContentView(viewId, event.currentTarget);
- setTimeout(function() {{
- var element = document.getElementById(featureSectionId);
- if (element) {{
- element.scrollIntoView({{ behavior: 'smooth', block: 'start' }});
- }}
- }}, 100);
- }}
- }}
- // 滚动到层级分组(在特征下的子层级)
- function scrollToLevelSection(event, viewId, levelSectionId) {{
- event.stopPropagation();
- // 先切换到对应点的视图
- showContentView(viewId, event.currentTarget);
- // 等待视图切换完成后滚动到层级分组位置
- setTimeout(function() {{
- var element = document.getElementById(levelSectionId);
- if (element) {{
- element.scrollIntoView({{ behavior: 'smooth', block: 'start' }});
- }}
- }}, 100);
- }}
- // 点击特征:先切换到对应点的视图,再滚动到特征位置
- function clickFeature(event, pointViewId, featureSectionId) {{
- event.stopPropagation();
- // 先切换到对应点的视图
- showContentView(pointViewId, event.currentTarget);
- // 等待视图切换完成后滚动到特征位置
- setTimeout(function() {{
- var element = document.getElementById(featureSectionId);
- if (element) {{
- element.scrollIntoView({{ behavior: 'smooth', block: 'start' }});
- }}
- }}, 100);
- }}
- function scrollToSection(sectionId) {{
- var element = document.getElementById(sectionId);
- if (element) {{
- element.scrollIntoView({{ behavior: 'smooth', block: 'start' }});
- }}
- }}
- function toggleMatch(matchId) {{
- var content = document.getElementById(matchId + '-content');
- var icon = document.getElementById(matchId + '-icon');
- if (content.style.display === 'none') {{
- content.style.display = 'block';
- icon.classList.add('expanded');
- }} else {{
- content.style.display = 'none';
- icon.classList.remove('expanded');
- }}
- }}
- function toggleFeatureSection(event, sectionId) {{
- event.stopPropagation();
- var content = document.getElementById(sectionId + '-content');
- var icon = document.getElementById(sectionId + '-icon');
- if (!content || !icon) {{
- console.error('Element not found:', sectionId);
- return;
- }}
- var isHidden = content.style.display === 'none' || !content.style.display || window.getComputedStyle(content).display === 'none';
- if (isHidden) {{
- content.style.display = 'flex';
- icon.textContent = '▼';
- }} else {{
- content.style.display = 'none';
- icon.textContent = '▶';
- }}
- }}
- function toggleStepSection(sectionId) {{
- var content = document.getElementById(sectionId + '-content');
- var icon = document.getElementById(sectionId + '-icon');
- if (content.style.display === 'none') {{
- content.style.display = 'flex';
- icon.textContent = '▼';
- }} else {{
- content.style.display = 'none';
- icon.textContent = '▶';
- }}
- }}
- function toggleMatchGroup(event, groupId) {{
- event.stopPropagation();
- var content = document.getElementById(groupId + '-content');
- var icon = document.getElementById(groupId + '-icon');
- if (!content || !icon) {{
- console.error('Element not found:', groupId);
- return;
- }}
- var isHidden = content.style.display === 'none' || !content.style.display || window.getComputedStyle(content).display === 'none';
- if (isHidden) {{
- // 根据class决定使用什么display值
- if (content.classList.contains('match-subgroup-content')) {{
- content.style.display = 'flex';
- }} else {{
- content.style.display = 'block';
- }}
- icon.textContent = '▼';
- }} else {{
- content.style.display = 'none';
- icon.textContent = '▶';
- }}
- }}
- function showMatchDetail(element) {{
- var modal = document.getElementById('matchModal');
- var title = document.getElementById('matchModalTitle');
- var body = document.getElementById('matchModalBody');
- // 从data属性读取数据
- var personaName = element.dataset.personaName;
- var featureType = element.dataset.featureType;
- var personaLevel = element.dataset.personaLevel;
- var similarity = parseFloat(element.dataset.similarity);
- var label = element.dataset.label;
- var explanation = element.dataset.explanation;
- var historicalPosts = element.dataset.historicalPosts;
- // 设置标题 - 合并显示层级和类型
- var titleText = '';
- if (personaLevel && featureType) {{
- titleText = '[' + personaLevel + '-' + featureType + '] ';
- }}
- titleText += personaName + ' (相似度: ' + similarity.toFixed(2) + ' - ' + label + ')';
- title.textContent = titleText;
- // 生成内容
- var bodyHTML = '<div class="match-detail-section">';
- bodyHTML += '<h3>匹配说明</h3>';
- bodyHTML += '<div class="match-explanation-text">' + explanation + '</div>';
- bodyHTML += '</div>';
- // 如果有历史帖子
- if (historicalPosts && historicalPosts.trim().length > 0) {{
- bodyHTML += '<div class="match-detail-section">';
- bodyHTML += '<h3>历史帖子来源</h3>';
- bodyHTML += '<div class="historical-posts-grid">';
- bodyHTML += historicalPosts;
- bodyHTML += '</div>';
- bodyHTML += '</div>';
- }}
- body.innerHTML = bodyHTML;
- modal.style.display = 'block';
- }}
- function closeMatchModal(event) {{
- var modal = document.getElementById('matchModal');
- if (!event || event.target === modal) {{
- modal.style.display = 'none';
- }}
- }}
- // ESC键关闭模态框
- document.addEventListener('keydown', function(event) {{
- if (event.key === 'Escape') {{
- closeMatchModal();
- }}
- }});
- function showPostDetail(element) {{
- const postDataStr = element.dataset.postData;
- if (!postDataStr) return;
- try {{
- const postData = JSON.parse(postDataStr);
- // 生成图片HTML(添加懒加载)
- let imagesHtml = '';
- if (postData.images && postData.images.length > 0) {{
- imagesHtml = postData.images.map(img =>
- `<img src="${{img}}" class="post-detail-image" alt="图片" loading="lazy">`
- ).join('');
- }} else {{
- imagesHtml = '<div style="text-align: center; color: #9ca3af; padding: 40px;">暂无图片</div>';
- }}
- const modalHtml = `
- <div class="post-detail-content" onclick="event.stopPropagation()">
- <button class="post-detail-close" onclick="closePostDetail()">×</button>
- <div class="post-detail-header">
- <div class="post-detail-title">${{postData.title || '无标题'}}</div>
- <div class="post-detail-meta">
- <div>
- <span class="post-detail-author">👤 ${{postData.author}}</span>
- <span class="post-detail-time"> · 📅 ${{postData.publish_time}}</span>
- </div>
- <div class="post-detail-stats">
- <span>👍 ${{postData.like_count}}</span>
- <span>💬 ${{postData.comment_count}}</span>
- <span>⭐ ${{postData.collect_count}}</span>
- </div>
- </div>
- </div>
- <div class="post-detail-body">
- ${{postData.body_text ? `<div class="post-detail-desc">${{postData.body_text}}</div>` : '<div style="color: #9ca3af;">暂无正文</div>'}}
- <div class="post-detail-images">
- ${{imagesHtml}}
- </div>
- </div>
- <div class="post-detail-footer">
- <a href="${{postData.link}}" target="_blank" class="post-detail-link">
- 在小红书查看完整内容 →
- </a>
- </div>
- </div>
- `;
- let modal = document.getElementById('postDetailModal');
- if (!modal) {{
- modal = document.createElement('div');
- modal.id = 'postDetailModal';
- modal.className = 'post-detail-modal';
- modal.onclick = closePostDetail;
- document.body.appendChild(modal);
- }}
- modal.innerHTML = modalHtml;
- modal.classList.add('active');
- document.body.style.overflow = 'hidden';
- }} catch (e) {{
- console.error('解析帖子数据失败:', e);
- }}
- }}
- function closePostDetail(event) {{
- if (event && event.target !== event.currentTarget) return;
- const modal = document.getElementById('postDetailModal');
- if (modal) {{
- modal.classList.remove('active');
- document.body.style.overflow = '';
- }}
- }}
- function moveImage(postId, direction) {{
- var carousel = document.querySelector(`[data-post-id="${{postId}}"]`);
- var track = document.getElementById(postId + '-track');
- var totalImages = parseInt(carousel.getAttribute('data-total-images'));
- if (!carousel.currentIndex) {{
- carousel.currentIndex = 0;
- }}
- carousel.currentIndex = (carousel.currentIndex + direction + totalImages) % totalImages;
- track.style.transform = `translateX(-${{carousel.currentIndex * 100}}%)`;
- updateIndicators(postId, carousel.currentIndex);
- }}
- function jumpToImage(postId, index) {{
- var carousel = document.querySelector(`[data-post-id="${{postId}}"]`);
- var track = document.getElementById(postId + '-track');
- carousel.currentIndex = index;
- track.style.transform = `translateX(-${{index * 100}}%)`;
- updateIndicators(postId, index);
- }}
- function updateIndicators(postId, activeIndex) {{
- var indicators = document.querySelectorAll(`#${{postId}}-indicators .indicator`);
- indicators.forEach((indicator, i) => {{
- if (i === activeIndex) {{
- indicator.classList.add('active');
- }} else {{
- indicator.classList.remove('active');
- }}
- }});
- }}
- // 目录激活状态追踪
- function updateTocActiveState() {{
- const rightContent = document.querySelector('.right-content');
- if (!rightContent) return;
- // 找到当前激活的view
- const activeView = document.querySelector('.content-view[style*="display: block"], .content-view:not([style*="display: none"])');
- if (!activeView) return;
- // 在激活的view中查找所有sections
- const sections = activeView.querySelectorAll('[id]');
- const tocItems = document.querySelectorAll('.toc-item');
- let currentActive = null;
- let maxVisibility = 0;
- // 找到当前在视口中最visible的section
- sections.forEach(section => {{
- if (!section.id) return;
- const rect = section.getBoundingClientRect();
- const sectionTop = rect.top;
- const sectionBottom = rect.bottom;
- const windowHeight = window.innerHeight;
- // 计算section在视口中的可见度
- if (sectionTop < windowHeight && sectionBottom > 0) {{
- const visibleTop = Math.max(sectionTop, 0);
- const visibleBottom = Math.min(sectionBottom, windowHeight);
- const visibility = visibleBottom - visibleTop;
- // 优先选择顶部在视口上半部分的section
- if (sectionTop < windowHeight / 2 && visibility > maxVisibility) {{
- maxVisibility = visibility;
- currentActive = section.id;
- }}
- }}
- }});
- // 更新目录项的激活状态
- tocItems.forEach(item => {{
- // 移除所有激活状态和祖先状态
- item.classList.remove('active');
- item.classList.remove('active-ancestor');
- const onclick = item.getAttribute('onclick');
- if (!onclick) return;
- // 检查是否匹配当前活动section
- if (currentActive && onclick.includes(currentActive)) {{
- item.classList.add('active');
- // 标记所有祖先节点
- let parent = item.parentElement;
- while (parent) {{
- if (parent.classList && parent.classList.contains('toc-item')) {{
- parent.classList.add('active-ancestor');
- }}
- parent = parent.parentElement;
- }}
- }}
- }});
- // 更新面包屑导航
- updateBreadcrumb(currentActive);
- }}
- // 更新面包屑导航(只显示右侧内容层级,不显示帖子信息)
- function updateBreadcrumb(currentSectionId) {{
- const breadcrumbPoint = document.getElementById('breadcrumb-point');
- const breadcrumbStep = document.getElementById('breadcrumb-step');
- const breadcrumbFeature = document.getElementById('breadcrumb-feature');
- const breadcrumbLevel = document.getElementById('breadcrumb-level');
- const breadcrumbSubgroup = document.getElementById('breadcrumb-subgroup');
- const sep1 = document.getElementById('sep-1');
- const sep2 = document.getElementById('sep-2');
- const sep3 = document.getElementById('sep-3');
- const sep4 = document.getElementById('sep-4');
- // 隐藏所有可选元素
- [breadcrumbStep, breadcrumbFeature, breadcrumbLevel, breadcrumbSubgroup, sep1, sep2, sep3, sep4].forEach(el => {{
- if (el) el.style.display = 'none';
- }});
- if (!currentSectionId) {{
- breadcrumbPoint.textContent = '-';
- return;
- }}
- // 从当前section找到所在的view
- let currentView = null;
- const allViews = document.querySelectorAll('.content-view');
- allViews.forEach(view => {{
- if (view.style.display !== 'none' && view.querySelector('#' + currentSectionId)) {{
- currentView = view;
- }}
- }});
- if (!currentView) return;
- // 从view ID解析信息: view-post-0-point-灵感点列表-0
- const viewId = currentView.id;
- const viewMatch = viewId.match(/view-post-(\d+)-point-(.+?)-(\d+)/);
- if (viewMatch) {{
- const postIdx = viewMatch[1];
- const pointType = viewMatch[2].replace('列表', '');
- const pointIdx = parseInt(viewMatch[3]);
- // 1. 点信息(总是显示)
- const pointElement = currentView.querySelector('.inspiration-detail-card .inspiration-name');
- const pointName = pointElement ? pointElement.textContent : `${{pointType}} ${{pointIdx + 1}}`;
- breadcrumbPoint.textContent = `${{pointType}} ${{pointIdx + 1}}: ${{pointName.substring(0, 20)}}${{pointName.length > 20 ? '...' : ''}}`;
- breadcrumbPoint.onclick = function() {{
- showContentView(viewId, this);
- const rightContent = document.querySelector('.right-content');
- if (rightContent) rightContent.scrollTo({{ top: 0, behavior: 'smooth' }});
- }};
- // 2. 步骤信息(如果在步骤section中)
- if (currentSectionId.includes('step')) {{
- const stepMatch = currentSectionId.match(/step-\d+-(\d+)/);
- if (stepMatch) {{
- sep1.style.display = 'inline';
- breadcrumbStep.style.display = 'inline';
- breadcrumbStep.textContent = `步骤 ${{parseInt(stepMatch[1]) + 1}}`;
- breadcrumbStep.onclick = function() {{
- const el = document.getElementById(currentSectionId);
- if (el) el.scrollIntoView({{ behavior: 'smooth', block: 'start' }});
- }};
- }}
- }}
- // 3. 特征信息(如果在特征相关section中)
- if (currentSectionId.includes('feat')) {{
- const featMatch = currentSectionId.match(/feat-(\d+)-(\d+)/);
- if (featMatch) {{
- const featIdx = parseInt(featMatch[2]);
- // 查找特征名称
- const featureSection = document.getElementById('post-' + postIdx + '-feat-' + pointIdx + '-' + featIdx);
- let featureName = `特征 ${{featIdx + 1}}`;
- if (featureSection) {{
- const header = featureSection.querySelector('.match-section-header h4');
- if (header) {{
- const match = header.textContent.match(/匹配结果:\s*(.+?)\s*\(/);
- if (match) featureName = match[1].substring(0, 15) + (match[1].length > 15 ? '...' : '');
- }}
- }}
- sep2.style.display = 'inline';
- breadcrumbFeature.style.display = 'inline';
- breadcrumbFeature.textContent = featureName;
- breadcrumbFeature.onclick = function() {{
- if (featureSection) featureSection.scrollIntoView({{ behavior: 'smooth', block: 'start' }});
- }};
- // 4. 层级分组信息(如果在level section中)
- if (currentSectionId.includes('level')) {{
- const levelMatch = currentSectionId.match(/level-(.+?)(?:-|$)/);
- if (levelMatch) {{
- const levelName = levelMatch[1];
- const levelElement = document.getElementById(currentSectionId);
- let levelTitle = `匹配人设${{levelName}}`;
- if (levelElement) {{
- const header = levelElement.querySelector('.level-group-title');
- if (header) levelTitle = header.textContent.trim();
- }}
- sep3.style.display = 'inline';
- breadcrumbLevel.style.display = 'inline';
- breadcrumbLevel.textContent = levelTitle.substring(0, 20);
- breadcrumbLevel.onclick = function() {{
- if (levelElement) levelElement.scrollIntoView({{ behavior: 'smooth', block: 'start' }});
- }};
- }}
- }}
- }}
- }}
- }}
- }}
- // 监听滚动事件
- document.addEventListener('DOMContentLoaded', function() {{
- // 默认显示第一个视图
- var allViews = document.querySelectorAll('.content-view');
- if (allViews.length > 0) {{
- // 找到第一个点视图(跳过帖子详情视图)
- var firstPointView = null;
- var firstPointViewId = null;
- for (var i = 0; i < allViews.length; i++) {{
- if (allViews[i].id.includes('point') && allViews[i].id.includes('灵感点列表')) {{
- firstPointView = allViews[i];
- firstPointViewId = allViews[i].id;
- break;
- }}
- }}
- // 如果有点视图,显示第一个点;否则显示第一个帖子详情
- var defaultView = firstPointView || allViews[0];
- defaultView.style.display = 'block';
- defaultView.classList.add('active');
- // 展开第一个帖子
- var firstPost = document.querySelector('.toc-post-header');
- if (firstPost) {{
- firstPost.classList.remove('collapsed');
- var postIdx = firstPost.getAttribute('data-post-id');
- var postChildren = document.getElementById('toc-post-' + postIdx + '-children');
- if (postChildren) {{
- postChildren.classList.remove('hidden');
- }}
- }}
- // 展开第一个灵感点分组
- var firstInspGroup = document.querySelector('[onclick*="post-0-灵感点列表"]');
- if (firstInspGroup && firstInspGroup.classList.contains('toc-group-header')) {{
- firstInspGroup.classList.remove('collapsed');
- var groupChildren = document.getElementById('toc-post-0-灵感点列表-children');
- if (groupChildren) {{
- groupChildren.classList.remove('hidden');
- }}
- }}
- // 展开第一个灵感点
- var firstPoint = document.querySelector('.toc-level-2[onclick*="view-post-0-point-灵感点列表-0"]');
- if (firstPoint) {{
- firstPoint.classList.remove('collapsed');
- var pointId = firstPoint.getAttribute('onclick').match(/toggleTocPoint\(event,\s*'([^']+)'/);
- if (pointId && pointId[1]) {{
- var pointChildren = document.getElementById('toc-' + pointId[1] + '-children');
- if (pointChildren) {{
- pointChildren.classList.remove('hidden');
- }}
- }}
- }}
- // 激活对应的目录项
- if (firstPointViewId) {{
- // 找到对应的目录项并添加active类
- var tocItems = document.querySelectorAll('.toc-item');
- tocItems.forEach(function(item) {{
- var onclick = item.getAttribute('onclick');
- if (onclick && onclick.includes(firstPointViewId)) {{
- item.classList.add('active');
- // 标记祖先节点
- var parent = item.parentElement;
- while (parent) {{
- if (parent.classList && parent.classList.contains('toc-item')) {{
- parent.classList.add('active-ancestor');
- }}
- parent = parent.parentElement;
- }}
- }}
- }});
- }}
- }}
- const rightContent = document.querySelector('.right-content');
- if (rightContent) {{
- rightContent.addEventListener('scroll', function() {{
- // 使用节流避免频繁触发
- if (this.scrollTimeout) clearTimeout(this.scrollTimeout);
- this.scrollTimeout = setTimeout(updateTocActiveState, 50);
- }});
- // 初始化时更新一次
- updateTocActiveState();
- }}
- }});
- </script>
- </body>
- </html>
- '''
- return html
- def minify_html(html: str) -> str:
- """压缩HTML,去除多余空格和换行"""
- import re
- # 保护script和style标签内容
- scripts = []
- styles = []
- def save_script(match):
- scripts.append(match.group(0))
- return f"___SCRIPT_{len(scripts)-1}___"
- def save_style(match):
- styles.append(match.group(0))
- return f"___STYLE_{len(styles)-1}___"
- # 保存script和style
- html = re.sub(r'<script[^>]*>.*?</script>', save_script, html, flags=re.DOTALL)
- html = re.sub(r'<style[^>]*>.*?</style>', save_style, html, flags=re.DOTALL)
- # 去除HTML注释
- html = re.sub(r'<!--.*?-->', '', html, flags=re.DOTALL)
- # 去除多余空格和换行
- html = re.sub(r'\s+', ' ', html)
- html = re.sub(r'>\s+<', '><', html)
- # 恢复script和style
- for i, script in enumerate(scripts):
- html = html.replace(f"___SCRIPT_{i}___", script)
- for i, style in enumerate(styles):
- html = html.replace(f"___STYLE_{i}___", style)
- return html.strip()
- def main():
- """主函数"""
- # 使用路径配置
- config = PathConfig()
- # 确保输出目录存在
- config.ensure_dirs()
- # 获取路径
- input_dir = config.how_results_dir
- output_file = config.visualization_file
- print(f"账号: {config.account_name}")
- print(f"输入目录: {input_dir}")
- print(f"输出文件: {output_file}")
- print()
- print(f"读取 how 解构结果: {input_dir}")
- # 加载特征分类映射
- print(f"加载特征分类映射...")
- category_mapping = load_feature_category_mapping(config)
- print(f"已加载 {sum(len(v) for v in category_mapping.values())} 个特征分类")
- # 加载特征来源映射
- print(f"加载特征来源映射...")
- source_mapping = load_feature_source_mapping(config)
- print(f"已加载 {len(source_mapping)} 个特征的来源信息")
- json_files = list(input_dir.glob("*_how.json"))
- print(f"找到 {len(json_files)} 个文件\n")
- posts_data = []
- for i, file_path in enumerate(json_files, 1):
- print(f"读取文件 [{i}/{len(json_files)}]: {file_path.name}")
- with open(file_path, "r", encoding="utf-8") as f:
- post_data = json.load(f)
- posts_data.append(post_data)
- # 按发布时间降序排序(最新的在前)
- print(f"\n按发布时间排序...")
- posts_data.sort(key=lambda x: x.get("帖子详情", {}).get("publish_timestamp", 0), reverse=True)
- print(f"\n生成合并的 HTML...")
- html_content = generate_combined_html(posts_data, category_mapping, source_mapping)
- # 保存原始版本
- print(f"保存原始HTML到: {output_file}")
- with open(output_file, "w", encoding="utf-8") as f:
- f.write(html_content)
- original_size = len(html_content) / 1024 / 1024
- print(f"原始HTML大小: {original_size:.1f} MB")
- # 压缩HTML
- print(f"\n压缩HTML...")
- minified_html = minify_html(html_content)
- minified_file = output_file.parent / "当前帖子_how解构结果_可视化.min.html"
- print(f"保存压缩HTML到: {minified_file}")
- with open(minified_file, "w", encoding="utf-8") as f:
- f.write(minified_html)
- minified_size = len(minified_html) / 1024 / 1024
- print(f"压缩HTML大小: {minified_size:.1f} MB (减少 {(1 - minified_size/original_size)*100:.1f}%)")
- # Gzip压缩
- import gzip
- print(f"\n生成Gzip压缩版本...")
- gzip_file = output_file.parent / "当前帖子_how解构结果_可视化.html.gz"
- with gzip.open(gzip_file, "wb") as f:
- f.write(minified_html.encode('utf-8'))
- gzip_size = gzip_file.stat().st_size / 1024 / 1024
- print(f"Gzip压缩大小: {gzip_size:.1f} MB (比原始减少 {(1 - gzip_size/original_size)*100:.1f}%)")
- print(f"\n完成! 生成了3个版本:")
- print(f"1. 原始版本: {output_file} ({original_size:.1f} MB)")
- print(f"2. 压缩版本: {minified_file} ({minified_size:.1f} MB)")
- print(f"3. Gzip版本: {gzip_file} ({gzip_size:.1f} MB)")
- print(f"\n建议分享: {gzip_file.name} (浏览器可直接打开)")
- if __name__ == "__main__":
- main()
|