|
@@ -0,0 +1,1809 @@
|
|
|
|
|
+#!/usr/bin/env python3
|
|
|
|
|
+# -*- coding: utf-8 -*-
|
|
|
|
|
+"""
|
|
|
|
|
+How解构结果可视化脚本 V3
|
|
|
|
|
+
|
|
|
|
|
+改进版:
|
|
|
|
|
+- 使用标签页展示多个帖子
|
|
|
|
|
+- 参考 visualize_inspiration_points.py 的帖子详情展示
|
|
|
|
|
+- 分层可折叠的匹配结果
|
|
|
|
|
+- V3: 使用新的输入目录 当前帖子_how解构结果_v2
|
|
|
|
|
+"""
|
|
|
|
|
+
|
|
|
|
|
+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))
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+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="缩略图">'
|
|
|
|
|
+ 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) -> str:
|
|
|
|
|
+ """生成灵感点详情HTML"""
|
|
|
|
|
+ name = inspiration_point.get("名称", "")
|
|
|
|
|
+ desc = inspiration_point.get("描述", "")
|
|
|
|
|
+ features = inspiration_point.get("特征列表", [])
|
|
|
|
|
+
|
|
|
|
|
+ features_html = "".join([
|
|
|
|
|
+ f'<span class="feature-tag">{html_module.escape(f)}</span>'
|
|
|
|
|
+ for f in features
|
|
|
|
|
+ ])
|
|
|
|
|
+
|
|
|
|
|
+ html = f'''
|
|
|
|
|
+ <div class="inspiration-detail-card">
|
|
|
|
|
+ <div class="inspiration-header">
|
|
|
|
|
+ <span class="inspiration-type-badge">灵感点</span>
|
|
|
|
|
+ <h3 class="inspiration-name">{html_module.escape(name)}</h3>
|
|
|
|
|
+ </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() -> Dict:
|
|
|
|
|
+ """加载特征名称到分类的映射"""
|
|
|
|
|
+ script_dir = Path(__file__).parent
|
|
|
|
|
+ project_root = script_dir.parent.parent
|
|
|
|
|
+ mapping_file = project_root / "data" / "data_1118" / "特征名称_分类映射.json"
|
|
|
|
|
+
|
|
|
|
|
+ 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() -> Dict:
|
|
|
|
|
+ """加载特征名称到帖子来源的映射"""
|
|
|
|
|
+ script_dir = Path(__file__).parent
|
|
|
|
|
+ project_root = script_dir.parent.parent
|
|
|
|
|
+ mapping_file = project_root / "data" / "data_1118" / "特征名称_帖子来源.json"
|
|
|
|
|
+
|
|
|
|
|
+ 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_match_results_html(how_steps: List[Dict], feature_idx: int, insp_idx: int, post_idx: int, category_mapping: Dict = None, source_mapping: Dict = None) -> str:
|
|
|
|
|
+ """生成可折叠的匹配结果HTML(兼容新旧数据格式)"""
|
|
|
|
|
+ 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("特征名称", "")
|
|
|
|
|
+ match_results = feature_data.get("匹配结果", [])
|
|
|
|
|
+
|
|
|
|
|
+ if category_mapping is None:
|
|
|
|
|
+ category_mapping = {}
|
|
|
|
|
+
|
|
|
|
|
+ # 检测数据格式(新格式使用中文字段"分数",旧格式使用英文"score")
|
|
|
|
|
+ is_new_format = False
|
|
|
|
|
+ if match_results and len(match_results) > 0:
|
|
|
|
|
+ first_match = match_results[0].get("匹配结果", {})
|
|
|
|
|
+ is_new_format = "分数" in first_match
|
|
|
|
|
+
|
|
|
|
|
+ # 按分数排序(兼容新旧格式)
|
|
|
|
|
+ if is_new_format:
|
|
|
|
|
+ sorted_matches = sorted(match_results, key=lambda x: x.get("匹配结果", {}).get("分数", 0), reverse=True)
|
|
|
|
|
+ else:
|
|
|
|
|
+ sorted_matches = sorted(match_results, key=lambda x: x.get("匹配结果", {}).get("score", 0), reverse=True)
|
|
|
|
|
+
|
|
|
|
|
+ # 统计信息(新格式不显示relation统计,只显示总数)
|
|
|
|
|
+ if is_new_format:
|
|
|
|
|
+ stats_html = f'<span class="stat-badge" style="background: #667eea;">总匹配数: {len(match_results)}</span>'
|
|
|
|
|
+ else:
|
|
|
|
|
+ # 旧格式:统计匹配类型
|
|
|
|
|
+ relation_counts = {}
|
|
|
|
|
+ for match in match_results:
|
|
|
|
|
+ relation = match.get("匹配结果", {}).get("relation", "unrelated")
|
|
|
|
|
+ relation_counts[relation] = relation_counts.get(relation, 0) + 1
|
|
|
|
|
+
|
|
|
|
|
+ # 生成统计信息
|
|
|
|
|
+ stats_items = []
|
|
|
|
|
+ for relation, count in sorted(relation_counts.items(), key=lambda x: x[1], reverse=True):
|
|
|
|
|
+ label = get_relation_label(relation)
|
|
|
|
|
+ color = get_relation_color(relation)
|
|
|
|
|
+ stats_items.append(f'<span class="stat-badge" style="background: {color};">{label}: {count}</span>')
|
|
|
|
|
+ stats_html = "".join(stats_items)
|
|
|
|
|
+
|
|
|
|
|
+ # 生成匹配项
|
|
|
|
|
+ matches_html = ""
|
|
|
|
|
+ for i, match in enumerate(sorted_matches):
|
|
|
|
|
+ persona_name = match.get("人设特征名称", "")
|
|
|
|
|
+ match_result = match.get("匹配结果", {})
|
|
|
|
|
+
|
|
|
|
|
+ # 兼容新旧格式
|
|
|
|
|
+ if is_new_format:
|
|
|
|
|
+ score = match_result.get("分数", 0.0)
|
|
|
|
|
+ explanation = match_result.get("说明", "")
|
|
|
|
|
+ # 新格式根据分数设置颜色
|
|
|
|
|
+ if score >= 0.7:
|
|
|
|
|
+ color = "#10b981" # 绿色 - 高相关
|
|
|
|
|
+ label = "高相关"
|
|
|
|
|
+ elif score >= 0.5:
|
|
|
|
|
+ color = "#3b82f6" # 蓝色 - 中相关
|
|
|
|
|
+ label = "中相关"
|
|
|
|
|
+ elif score >= 0.3:
|
|
|
|
|
+ color = "#f59e0b" # 橙色 - 低相关
|
|
|
|
|
+ label = "低相关"
|
|
|
|
|
+ else:
|
|
|
|
|
+ color = "#9ca3af" # 灰色 - 弱相关
|
|
|
|
|
+ label = "弱相关"
|
|
|
|
|
+ else:
|
|
|
|
|
+ relation = match_result.get("relation", "unrelated")
|
|
|
|
|
+ score = match_result.get("score", 0.0)
|
|
|
|
|
+ explanation = match_result.get("explanation", "")
|
|
|
|
|
+ color = get_relation_color(relation)
|
|
|
|
|
+ label = get_relation_label(relation)
|
|
|
|
|
+
|
|
|
|
|
+ match_id = f"post-{post_idx}-insp-{insp_idx}-feat-{feature_idx}-match-{i}"
|
|
|
|
|
+
|
|
|
|
|
+ # 获取该人设特征的分类信息
|
|
|
|
|
+ # 需要在三个类型中查找该特征
|
|
|
|
|
+ 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>
|
|
|
|
|
+ '''
|
|
|
|
|
+
|
|
|
|
|
+ matches_html += f'''
|
|
|
|
|
+ <div class="match-item-collapsible">
|
|
|
|
|
+ <div class="match-header" onclick="toggleMatch('{match_id}')">
|
|
|
|
|
+ <div class="match-header-left">
|
|
|
|
|
+ <span class="expand-icon" id="{match_id}-icon">▶</span>
|
|
|
|
|
+ <span class="persona-name">{categories_html} {html_module.escape(persona_name)}</span>
|
|
|
|
|
+ <span class="relation-badge" style="background: {color};">{label}</span>
|
|
|
|
|
+ <span class="score-badge">分数: {score:.2f}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="match-content" id="{match_id}-content" style="display: none;">
|
|
|
|
|
+ <div class="match-explanation">{html_module.escape(explanation)}</div>
|
|
|
|
|
+ {historical_posts_html}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ '''
|
|
|
|
|
+
|
|
|
|
|
+ section_id = f"post-{post_idx}-insp-{insp_idx}-feat-{feature_idx}-section"
|
|
|
|
|
+
|
|
|
|
|
+ html = f'''
|
|
|
|
|
+ <div class="match-results-section">
|
|
|
|
|
+ <div class="match-section-header collapsible-header" onclick="toggleFeatureSection('{section_id}')">
|
|
|
|
|
+ <div class="header-left">
|
|
|
|
|
+ <span class="expand-icon" id="{section_id}-icon">▼</span>
|
|
|
|
|
+ <h4>匹配结果: {html_module.escape(feature_name)}</h4>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="match-stats">{stats_html}</div>
|
|
|
|
|
+ </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) -> str:
|
|
|
|
|
+ """生成目录导航HTML"""
|
|
|
|
|
+ how_result = post_data.get("how解构结果", {})
|
|
|
|
|
+ inspiration_list = how_result.get("灵感点列表", [])
|
|
|
|
|
+
|
|
|
|
|
+ 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>')
|
|
|
|
|
+
|
|
|
|
|
+ # 灵感点
|
|
|
|
|
+ for insp_idx, inspiration_point in enumerate(inspiration_list):
|
|
|
|
|
+ name = inspiration_point.get("名称", f"灵感点 {insp_idx + 1}")
|
|
|
|
|
+ name_short = name[:18] + "..." if len(name) > 18 else name
|
|
|
|
|
+
|
|
|
|
|
+ toc_items.append(f'<div class="toc-item toc-level-1" onclick="scrollToSection(\'post-{post_idx}-insp-{insp_idx}\')"><span class="toc-badge toc-badge-inspiration">灵感点</span> {html_module.escape(name_short)}</div>')
|
|
|
|
|
+
|
|
|
|
|
+ # 特征列表
|
|
|
|
|
+ how_steps = inspiration_point.get("how步骤列表", [])
|
|
|
|
|
+ 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}")
|
|
|
|
|
+ toc_items.append(f'<div class="toc-item toc-level-2" onclick="scrollToSection(\'post-{post_idx}-feat-{insp_idx}-{feat_idx}\')"><span class="toc-badge toc-badge-feature">特征</span> {html_module.escape(feature_name)}</div>')
|
|
|
|
|
+
|
|
|
|
|
+ return f'''
|
|
|
|
|
+ <div class="toc-container">
|
|
|
|
|
+ <div class="toc-header">目录导航</div>
|
|
|
|
|
+ <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"""
|
|
|
|
|
+ # 生成目录
|
|
|
|
|
+ toc_html = generate_toc_html(post_data, post_idx)
|
|
|
|
|
+
|
|
|
|
|
+ # 1. 帖子详情
|
|
|
|
|
+ post_detail_html = generate_post_detail_html(post_data, post_idx)
|
|
|
|
|
+
|
|
|
|
|
+ # 2. 灵感点详情和匹配结果
|
|
|
|
|
+ how_result = post_data.get("how解构结果", {})
|
|
|
|
|
+ inspiration_list = how_result.get("灵感点列表", [])
|
|
|
|
|
+
|
|
|
|
|
+ # 生成所有灵感点的详情HTML(只包含灵感点详情,不包含匹配结果)
|
|
|
|
|
+ inspirations_detail_html = ""
|
|
|
|
|
+ for insp_idx, inspiration_point in enumerate(inspiration_list):
|
|
|
|
|
+ inspiration_detail = generate_inspiration_detail_html(inspiration_point)
|
|
|
|
|
+ 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)
|
|
|
|
|
+ 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="content-with-toc">
|
|
|
|
|
+ {toc_html}
|
|
|
|
|
+ <div class="main-content">
|
|
|
|
|
+ <!-- 第一个框:左右分栏(帖子详情 + 灵感点详情) -->
|
|
|
|
|
+ <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>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ '''
|
|
|
|
|
+ return html
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def generate_combined_html(posts_data: List[Dict], category_mapping: Dict = None, source_mapping: Dict = None) -> str:
|
|
|
|
|
+ """生成包含所有帖子的单一HTML(带标签页)"""
|
|
|
|
|
+
|
|
|
|
|
+ # 生成标签页按钮
|
|
|
|
|
+ tabs_html = ""
|
|
|
|
|
+ for i, post in enumerate(posts_data):
|
|
|
|
|
+ post_detail = post.get("帖子详情", {})
|
|
|
|
|
+ title = post_detail.get("title", "无标题")
|
|
|
|
|
+ active_class = "active" if i == 0 else ""
|
|
|
|
|
+ tabs_html += f'<button class="tab-button {active_class}" onclick="openTab(event, \'post-{i}\')">{html_module.escape(title)}</button>\n'
|
|
|
|
|
+
|
|
|
|
|
+ # 生成标签页内容
|
|
|
|
|
+ contents_html = ""
|
|
|
|
|
+ for i, post in enumerate(posts_data):
|
|
|
|
|
+ active_class = "active" if i == 0 else ""
|
|
|
|
|
+ content = generate_post_content_html(post, i, category_mapping, source_mapping)
|
|
|
|
|
+ contents_html += f'''
|
|
|
|
|
+ <div id="post-{i}" class="tab-content {active_class}">
|
|
|
|
|
+ {content}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ '''
|
|
|
|
|
+
|
|
|
|
|
+ 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;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ .container {{
|
|
|
|
|
+ max-width: 1600px;
|
|
|
|
|
+ margin: 0 auto;
|
|
|
|
|
+ background: white;
|
|
|
|
|
+ min-height: 100vh;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ .header {{
|
|
|
|
|
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
|
|
|
+ color: white;
|
|
|
|
|
+ padding: 30px;
|
|
|
|
|
+ text-align: center;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ .header h1 {{
|
|
|
|
|
+ font-size: 32px;
|
|
|
|
|
+ font-weight: bold;
|
|
|
|
|
+ margin-bottom: 10px;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ .header p {{
|
|
|
|
|
+ font-size: 16px;
|
|
|
|
|
+ opacity: 0.9;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ /* 标签页样式 */
|
|
|
|
|
+ .tabs-container {{
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ background: #f9fafb;
|
|
|
|
|
+ border-bottom: 2px solid #e5e7eb;
|
|
|
|
|
+ overflow-x: auto;
|
|
|
|
|
+ position: sticky;
|
|
|
|
|
+ top: 0;
|
|
|
|
|
+ z-index: 100;
|
|
|
|
|
+ box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ .tab-button {{
|
|
|
|
|
+ flex: 1;
|
|
|
|
|
+ min-width: 200px;
|
|
|
|
|
+ padding: 18px 30px;
|
|
|
|
|
+ background: transparent;
|
|
|
|
|
+ border: none;
|
|
|
|
|
+ border-bottom: 3px solid transparent;
|
|
|
|
|
+ cursor: pointer;
|
|
|
|
|
+ font-size: 15px;
|
|
|
|
|
+ font-weight: 500;
|
|
|
|
|
+ color: #6b7280;
|
|
|
|
|
+ transition: all 0.3s;
|
|
|
|
|
+ white-space: nowrap;
|
|
|
|
|
+ overflow: hidden;
|
|
|
|
|
+ text-overflow: ellipsis;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ .tab-button:hover {{
|
|
|
|
|
+ background: #f3f4f6;
|
|
|
|
|
+ color: #374151;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ .tab-button.active {{
|
|
|
|
|
+ color: #667eea;
|
|
|
|
|
+ border-bottom-color: #667eea;
|
|
|
|
|
+ background: white;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ .tab-content {{
|
|
|
|
|
+ display: none;
|
|
|
|
|
+ padding: 30px;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ .tab-content.active {{
|
|
|
|
|
+ display: block;
|
|
|
|
|
+ animation: fadeIn 0.3s;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ @keyframes fadeIn {{
|
|
|
|
|
+ from {{ opacity: 0; transform: translateY(10px); }}
|
|
|
|
|
+ to {{ opacity: 1; transform: translateY(0); }}
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ /* 目录和主内容布局 */
|
|
|
|
|
+ .content-with-toc {{
|
|
|
|
|
+ display: grid;
|
|
|
|
|
+ grid-template-columns: 280px 1fr;
|
|
|
|
|
+ gap: 30px;
|
|
|
|
|
+ align-items: start;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ .toc-container {{
|
|
|
|
|
+ position: sticky;
|
|
|
|
|
+ top: 80px;
|
|
|
|
|
+ background: white;
|
|
|
|
|
+ border: 1px solid #e5e7eb;
|
|
|
|
|
+ border-radius: 12px;
|
|
|
|
|
+ overflow: hidden;
|
|
|
|
|
+ max-height: calc(100vh - 100px);
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-direction: column;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ .toc-header {{
|
|
|
|
|
+ padding: 15px 20px;
|
|
|
|
|
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
|
|
|
+ color: white;
|
|
|
|
|
+ font-weight: 600;
|
|
|
|
|
+ font-size: 16px;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ .toc-content {{
|
|
|
|
|
+ padding: 10px;
|
|
|
|
|
+ overflow-y: auto;
|
|
|
|
|
+ flex: 1;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ .toc-item {{
|
|
|
|
|
+ padding: 10px 15px;
|
|
|
|
|
+ margin: 4px 0;
|
|
|
|
|
+ cursor: pointer;
|
|
|
|
|
+ border-radius: 6px;
|
|
|
|
|
+ font-size: 14px;
|
|
|
|
|
+ transition: all 0.2s;
|
|
|
|
|
+ user-select: none;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ .toc-item:hover {{
|
|
|
|
|
+ background: #f3f4f6;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ .toc-item.active {{
|
|
|
|
|
+ background: #e0e7ff;
|
|
|
|
|
+ color: #667eea;
|
|
|
|
|
+ font-weight: 600;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ .toc-level-1 {{
|
|
|
|
|
+ color: #111827;
|
|
|
|
|
+ font-weight: 500;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ .toc-level-2 {{
|
|
|
|
|
+ color: #6b7280;
|
|
|
|
|
+ font-size: 13px;
|
|
|
|
|
+ padding-left: 30px;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ .toc-badge {{
|
|
|
|
|
+ display: inline-block;
|
|
|
|
|
+ padding: 2px 8px;
|
|
|
|
|
+ border-radius: 10px;
|
|
|
|
|
+ font-size: 11px;
|
|
|
|
|
+ font-weight: 600;
|
|
|
|
|
+ margin-right: 6px;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ .toc-badge-post {{
|
|
|
|
|
+ background: #dbeafe;
|
|
|
|
|
+ color: #1e40af;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ .toc-badge-inspiration {{
|
|
|
|
|
+ background: #fef3c7;
|
|
|
|
|
+ color: #92400e;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ .toc-badge-feature {{
|
|
|
|
|
+ background: #e0e7ff;
|
|
|
|
|
+ color: #3730a3;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ .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: 1px solid #e5e7eb;
|
|
|
|
|
+ border-radius: 12px;
|
|
|
|
|
+ overflow: hidden;
|
|
|
|
|
+ box-shadow: 0 2px 8px rgba(0,0,0,0.05);
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ .step-header {{
|
|
|
|
|
+ padding: 20px 25px;
|
|
|
|
|
+ background: linear-gradient(135deg, #f9fafb 0%, #ffffff 100%);
|
|
|
|
|
+ border-bottom: 2px solid #e5e7eb;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ justify-content: space-between;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ .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;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ .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;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ /* 匹配结果部分 */
|
|
|
|
|
+ .match-results-section {{
|
|
|
|
|
+ padding: 25px;
|
|
|
|
|
+ border-bottom: 1px solid #e5e7eb;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ .match-results-section:last-child {{
|
|
|
|
|
+ border-bottom: none;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ .match-section-header {{
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ justify-content: space-between;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ margin-bottom: 20px;
|
|
|
|
|
+ padding-bottom: 15px;
|
|
|
|
|
+ border-bottom: 2px solid #e5e7eb;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ .collapsible-header {{
|
|
|
|
|
+ cursor: pointer;
|
|
|
|
|
+ user-select: none;
|
|
|
|
|
+ transition: background 0.2s;
|
|
|
|
|
+ padding: 10px;
|
|
|
|
|
+ margin: -10px;
|
|
|
|
|
+ border-radius: 6px;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ .collapsible-header:hover {{
|
|
|
|
|
+ background: #f9fafb;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ .header-left {{
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ gap: 10px;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ .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;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ .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: 12px;
|
|
|
|
|
+ transition: transform 0.2s;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ .expand-icon.expanded {{
|
|
|
|
|
+ transform: rotate(90deg);
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ .persona-name {{
|
|
|
|
|
+ font-weight: 600;
|
|
|
|
|
+ font-size: 14px;
|
|
|
|
|
+ color: #111827;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ .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;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ .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: 1000;
|
|
|
|
|
+ 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) {{
|
|
|
|
|
+ .content-with-toc {{
|
|
|
|
|
+ grid-template-columns: 1fr;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ .toc-container {{
|
|
|
|
|
+ position: relative;
|
|
|
|
|
+ top: 0;
|
|
|
|
|
+ max-height: 300px;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ .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: 20px;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ .header h1 {{
|
|
|
|
|
+ font-size: 24px;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ .tab-button {{
|
|
|
|
|
+ min-width: 150px;
|
|
|
|
|
+ padding: 15px 20px;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ .tab-content {{
|
|
|
|
|
+ padding: 15px;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ .post-info-section {{
|
|
|
|
|
+ padding: 20px;
|
|
|
|
|
+ }}
|
|
|
|
|
+ }}
|
|
|
|
|
+ </style>
|
|
|
|
|
+ </head>
|
|
|
|
|
+ <body>
|
|
|
|
|
+ <div class="container">
|
|
|
|
|
+ <div class="header">
|
|
|
|
|
+ <h1>How 解构结果可视化</h1>
|
|
|
|
|
+ <p>灵感点特征匹配分析</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="tabs-container">
|
|
|
|
|
+ {tabs_html}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {contents_html}
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 帖子详情模态框 -->
|
|
|
|
|
+ <div id="postDetailModal" class="post-detail-modal" onclick="closePostDetail(event)"></div>
|
|
|
|
|
+
|
|
|
|
|
+ <script>
|
|
|
|
|
+ function openTab(evt, tabId) {{
|
|
|
|
|
+ var tabContents = document.getElementsByClassName("tab-content");
|
|
|
|
|
+ for (var i = 0; i < tabContents.length; i++) {{
|
|
|
|
|
+ tabContents[i].classList.remove("active");
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ var tabButtons = document.getElementsByClassName("tab-button");
|
|
|
|
|
+ for (var i = 0; i < tabButtons.length; i++) {{
|
|
|
|
|
+ tabButtons[i].classList.remove("active");
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ document.getElementById(tabId).classList.add("active");
|
|
|
|
|
+ evt.currentTarget.classList.add("active");
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ function scrollToSection(sectionId) {{
|
|
|
|
|
+ var element = document.getElementById(sectionId);
|
|
|
|
|
+ if (element) {{
|
|
|
|
|
+ element.scrollIntoView({{ behavior: 'smooth', block: 'start' }});
|
|
|
|
|
+
|
|
|
|
|
+ // 更新目录项的active状态
|
|
|
|
|
+ var tocItems = document.querySelectorAll('.toc-item');
|
|
|
|
|
+ tocItems.forEach(function(item) {{
|
|
|
|
|
+ item.classList.remove('active');
|
|
|
|
|
+ }});
|
|
|
|
|
+ event.currentTarget.classList.add('active');
|
|
|
|
|
+ }}
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ 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(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 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 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="图片">`
|
|
|
|
|
+ ).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 mainContent = document.querySelector('.main-content');
|
|
|
|
|
+ if (!mainContent) return;
|
|
|
|
|
+
|
|
|
|
|
+ const sections = document.querySelectorAll('.post-detail-wrapper, .inspiration-detail-card, .step-section');
|
|
|
|
|
+ const tocItems = document.querySelectorAll('.toc-item');
|
|
|
|
|
+
|
|
|
|
|
+ let currentActive = null;
|
|
|
|
|
+ const scrollTop = mainContent.scrollTop;
|
|
|
|
|
+ const windowHeight = window.innerHeight;
|
|
|
|
|
+
|
|
|
|
|
+ // 找到当前在视口中的section
|
|
|
|
|
+ sections.forEach(section => {{
|
|
|
|
|
+ const rect = section.getBoundingClientRect();
|
|
|
|
|
+ const sectionTop = rect.top;
|
|
|
|
|
+ const sectionBottom = rect.bottom;
|
|
|
|
|
+
|
|
|
|
|
+ // 如果section的顶部在视口上半部分,认为它是当前激活的
|
|
|
|
|
+ if (sectionTop < windowHeight / 2 && sectionBottom > 0) {{
|
|
|
|
|
+ currentActive = section.id;
|
|
|
|
|
+ }}
|
|
|
|
|
+ }});
|
|
|
|
|
+
|
|
|
|
|
+ // 更新目录项的激活状态
|
|
|
|
|
+ tocItems.forEach(item => {{
|
|
|
|
|
+ const targetId = item.getAttribute('onclick')?.match(/'([^']+)'/)?.[1];
|
|
|
|
|
+ if (targetId === currentActive) {{
|
|
|
|
|
+ item.classList.add('active');
|
|
|
|
|
+ }} else {{
|
|
|
|
|
+ item.classList.remove('active');
|
|
|
|
|
+ }}
|
|
|
|
|
+ }});
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 监听滚动事件
|
|
|
|
|
+ document.addEventListener('DOMContentLoaded', function() {{
|
|
|
|
|
+ const mainContent = document.querySelector('.main-content');
|
|
|
|
|
+ if (mainContent) {{
|
|
|
|
|
+ mainContent.addEventListener('scroll', function() {{
|
|
|
|
|
+ // 使用节流避免频繁触发
|
|
|
|
|
+ if (this.scrollTimeout) clearTimeout(this.scrollTimeout);
|
|
|
|
|
+ this.scrollTimeout = setTimeout(updateTocActiveState, 50);
|
|
|
|
|
+ }});
|
|
|
|
|
+
|
|
|
|
|
+ // 初始化时更新一次
|
|
|
|
|
+ updateTocActiveState();
|
|
|
|
|
+ }}
|
|
|
|
|
+ }});
|
|
|
|
|
+ </script>
|
|
|
|
|
+ </body>
|
|
|
|
|
+ </html>
|
|
|
|
|
+ '''
|
|
|
|
|
+ return html
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def main():
|
|
|
|
|
+ """主函数"""
|
|
|
|
|
+ script_dir = Path(__file__).parent
|
|
|
|
|
+ project_root = script_dir.parent.parent
|
|
|
|
|
+ data_dir = project_root / "data" / "data_1118"
|
|
|
|
|
+
|
|
|
|
|
+ # V3: 更新输入目录为 当前帖子_how解构结果_v2
|
|
|
|
|
+ input_dir = data_dir / "当前帖子_how解构结果_v2"
|
|
|
|
|
+ output_file = data_dir / "当前帖子_how解构结果_v2_可视化.html"
|
|
|
|
|
+
|
|
|
|
|
+ print(f"读取 how 解构结果: {input_dir}")
|
|
|
|
|
+
|
|
|
|
|
+ # 加载特征分类映射
|
|
|
|
|
+ print(f"加载特征分类映射...")
|
|
|
|
|
+ category_mapping = load_feature_category_mapping()
|
|
|
|
|
+ print(f"已加载 {sum(len(v) for v in category_mapping.values())} 个特征分类")
|
|
|
|
|
+
|
|
|
|
|
+ # 加载特征来源映射
|
|
|
|
|
+ print(f"加载特征来源映射...")
|
|
|
|
|
+ source_mapping = load_feature_source_mapping()
|
|
|
|
|
+ print(f"已加载 {len(source_mapping)} 个特征的来源信息")
|
|
|
|
|
+
|
|
|
|
|
+ json_files = list(input_dir.glob("*_how_v2_*.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生成合并的 HTML...")
|
|
|
|
|
+ html_content = generate_combined_html(posts_data, category_mapping, source_mapping)
|
|
|
|
|
+
|
|
|
|
|
+ print(f"保存到: {output_file}")
|
|
|
|
|
+ with open(output_file, "w", encoding="utf-8") as f:
|
|
|
|
|
+ f.write(html_content)
|
|
|
|
|
+
|
|
|
|
|
+ print(f"\n完成! 可视化文件已保存")
|
|
|
|
|
+ print(f"请在浏览器中打开: {output_file}")
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+if __name__ == "__main__":
|
|
|
|
|
+ main()
|