|
|
@@ -0,0 +1,2180 @@
|
|
|
+#!/usr/bin/env python3
|
|
|
+"""
|
|
|
+Steps 可视化工具
|
|
|
+将 steps.json 转换为 HTML 可视化页面
|
|
|
+"""
|
|
|
+
|
|
|
+import json
|
|
|
+import argparse
|
|
|
+from pathlib import Path
|
|
|
+from datetime import datetime
|
|
|
+
|
|
|
+
|
|
|
+HTML_TEMPLATE = """<!DOCTYPE html>
|
|
|
+<html lang="zh-CN">
|
|
|
+<head>
|
|
|
+ <meta charset="UTF-8">
|
|
|
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
|
+ <title>Query Optimization Steps 可视化</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;
|
|
|
+ display: flex;
|
|
|
+ margin: 0;
|
|
|
+ padding: 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ /* 左侧导航 */
|
|
|
+ .sidebar {
|
|
|
+ width: 280px;
|
|
|
+ background: white;
|
|
|
+ height: 100vh;
|
|
|
+ position: fixed;
|
|
|
+ left: 0;
|
|
|
+ top: 0;
|
|
|
+ overflow-y: auto;
|
|
|
+ box-shadow: 2px 0 8px rgba(0,0,0,0.1);
|
|
|
+ z-index: 100;
|
|
|
+ }
|
|
|
+
|
|
|
+ .sidebar-header {
|
|
|
+ padding: 20px;
|
|
|
+ background: #2563eb;
|
|
|
+ color: white;
|
|
|
+ font-size: 18px;
|
|
|
+ font-weight: 600;
|
|
|
+ }
|
|
|
+
|
|
|
+ .toc {
|
|
|
+ padding: 10px 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ .toc-item {
|
|
|
+ padding: 10px 20px;
|
|
|
+ cursor: pointer;
|
|
|
+ transition: background 0.2s;
|
|
|
+ border-left: 3px solid transparent;
|
|
|
+ }
|
|
|
+
|
|
|
+ .toc-item:hover {
|
|
|
+ background: #f0f9ff;
|
|
|
+ }
|
|
|
+
|
|
|
+ .toc-item.active {
|
|
|
+ background: #eff6ff;
|
|
|
+ border-left-color: #2563eb;
|
|
|
+ color: #2563eb;
|
|
|
+ font-weight: 600;
|
|
|
+ }
|
|
|
+
|
|
|
+ .toc-item-level-0 {
|
|
|
+ font-weight: 600;
|
|
|
+ color: #1a1a1a;
|
|
|
+ font-size: 14px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .toc-item-level-1 {
|
|
|
+ padding-left: 35px;
|
|
|
+ font-size: 13px;
|
|
|
+ color: #666;
|
|
|
+ }
|
|
|
+
|
|
|
+ .toc-item-level-2 {
|
|
|
+ padding-left: 50px;
|
|
|
+ font-size: 12px;
|
|
|
+ color: #999;
|
|
|
+ }
|
|
|
+
|
|
|
+ .toc-toggle {
|
|
|
+ display: inline-block;
|
|
|
+ width: 16px;
|
|
|
+ height: 16px;
|
|
|
+ margin-left: 5px;
|
|
|
+ cursor: pointer;
|
|
|
+ transition: transform 0.2s;
|
|
|
+ float: right;
|
|
|
+ }
|
|
|
+
|
|
|
+ .toc-toggle.collapsed {
|
|
|
+ transform: rotate(-90deg);
|
|
|
+ }
|
|
|
+
|
|
|
+ .toc-children {
|
|
|
+ display: none;
|
|
|
+ }
|
|
|
+
|
|
|
+ .toc-children.expanded {
|
|
|
+ display: block;
|
|
|
+ }
|
|
|
+
|
|
|
+ .container {
|
|
|
+ margin-left: 280px;
|
|
|
+ width: calc(100% - 280px);
|
|
|
+ padding: 20px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .header {
|
|
|
+ background: white;
|
|
|
+ padding: 30px;
|
|
|
+ border-radius: 12px;
|
|
|
+ margin-bottom: 30px;
|
|
|
+ box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
|
|
+ }
|
|
|
+
|
|
|
+ .header h1 {
|
|
|
+ font-size: 32px;
|
|
|
+ margin-bottom: 20px;
|
|
|
+ color: #1a1a1a;
|
|
|
+ }
|
|
|
+
|
|
|
+ .question-box {
|
|
|
+ background: #f0f9ff;
|
|
|
+ padding: 20px;
|
|
|
+ border-radius: 8px;
|
|
|
+ margin-bottom: 20px;
|
|
|
+ border-left: 4px solid #0284c7;
|
|
|
+ }
|
|
|
+
|
|
|
+ .question-label {
|
|
|
+ font-size: 14px;
|
|
|
+ color: #0369a1;
|
|
|
+ margin-bottom: 8px;
|
|
|
+ font-weight: 600;
|
|
|
+ }
|
|
|
+
|
|
|
+ .question-text {
|
|
|
+ font-size: 18px;
|
|
|
+ color: #1a1a1a;
|
|
|
+ line-height: 1.6;
|
|
|
+ }
|
|
|
+
|
|
|
+ .overview {
|
|
|
+ display: flex;
|
|
|
+ gap: 30px;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ }
|
|
|
+
|
|
|
+ .overview-item {
|
|
|
+ flex: 1;
|
|
|
+ min-width: 150px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .overview-label {
|
|
|
+ font-size: 14px;
|
|
|
+ color: #666;
|
|
|
+ margin-bottom: 5px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .overview-value {
|
|
|
+ font-size: 28px;
|
|
|
+ font-weight: bold;
|
|
|
+ color: #2563eb;
|
|
|
+ }
|
|
|
+
|
|
|
+ .step-section {
|
|
|
+ background: white;
|
|
|
+ padding: 30px;
|
|
|
+ border-radius: 12px;
|
|
|
+ margin-bottom: 30px;
|
|
|
+ box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
|
|
+ }
|
|
|
+
|
|
|
+ .step-header {
|
|
|
+ border-bottom: 3px solid #2563eb;
|
|
|
+ padding-bottom: 15px;
|
|
|
+ margin-bottom: 20px;
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+ }
|
|
|
+
|
|
|
+ .step-title {
|
|
|
+ font-size: 26px;
|
|
|
+ color: #1a1a1a;
|
|
|
+ }
|
|
|
+
|
|
|
+ .step-type {
|
|
|
+ background: #e0e7ff;
|
|
|
+ color: #4338ca;
|
|
|
+ padding: 6px 15px;
|
|
|
+ border-radius: 20px;
|
|
|
+ font-size: 13px;
|
|
|
+ font-weight: 600;
|
|
|
+ font-family: monospace;
|
|
|
+ }
|
|
|
+
|
|
|
+ .step-content {
|
|
|
+ margin-top: 20px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .info-grid {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
|
+ gap: 20px;
|
|
|
+ margin-bottom: 20px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .info-item {
|
|
|
+ background: #f8f9fa;
|
|
|
+ padding: 15px;
|
|
|
+ border-radius: 8px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .info-label {
|
|
|
+ font-size: 13px;
|
|
|
+ color: #666;
|
|
|
+ margin-bottom: 5px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .info-value {
|
|
|
+ font-size: 20px;
|
|
|
+ font-weight: bold;
|
|
|
+ color: #1a1a1a;
|
|
|
+ }
|
|
|
+
|
|
|
+ .posts-grid {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
|
|
+ gap: 20px;
|
|
|
+ margin-top: 20px;
|
|
|
+ padding-top: 100px;
|
|
|
+ margin-top: -80px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .post-card {
|
|
|
+ background: white;
|
|
|
+ border-radius: 8px;
|
|
|
+ overflow: visible;
|
|
|
+ transition: transform 0.2s, box-shadow 0.2s;
|
|
|
+ border: 1px solid #e5e7eb;
|
|
|
+ cursor: pointer;
|
|
|
+ position: relative;
|
|
|
+ }
|
|
|
+
|
|
|
+ .post-card:hover {
|
|
|
+ transform: translateY(-4px);
|
|
|
+ box-shadow: 0 6px 16px rgba(0,0,0,0.15);
|
|
|
+ }
|
|
|
+
|
|
|
+ .post-image-wrapper {
|
|
|
+ width: 100%;
|
|
|
+ background: #f3f4f6;
|
|
|
+ position: relative;
|
|
|
+ padding-top: 133.33%; /* 3:4 aspect ratio */
|
|
|
+ overflow: hidden;
|
|
|
+ border-radius: 8px 8px 0 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ .post-image {
|
|
|
+ position: absolute;
|
|
|
+ top: 0;
|
|
|
+ left: 0;
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ object-fit: cover;
|
|
|
+ }
|
|
|
+
|
|
|
+ .no-image {
|
|
|
+ position: absolute;
|
|
|
+ top: 50%;
|
|
|
+ left: 50%;
|
|
|
+ transform: translate(-50%, -50%);
|
|
|
+ color: #9ca3af;
|
|
|
+ font-size: 14px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .post-type-badge {
|
|
|
+ position: absolute;
|
|
|
+ top: 10px;
|
|
|
+ right: 10px;
|
|
|
+ background: rgba(0, 0, 0, 0.7);
|
|
|
+ color: white;
|
|
|
+ padding: 4px 10px;
|
|
|
+ border-radius: 15px;
|
|
|
+ font-size: 11px;
|
|
|
+ font-weight: 600;
|
|
|
+ }
|
|
|
+
|
|
|
+ .post-info {
|
|
|
+ padding: 15px;
|
|
|
+ position: relative;
|
|
|
+ overflow: visible;
|
|
|
+ }
|
|
|
+
|
|
|
+ .post-title {
|
|
|
+ font-size: 14px;
|
|
|
+ font-weight: 600;
|
|
|
+ margin-bottom: 8px;
|
|
|
+ color: #1a1a1a;
|
|
|
+ display: -webkit-box;
|
|
|
+ -webkit-line-clamp: 2;
|
|
|
+ -webkit-box-orient: vertical;
|
|
|
+ overflow: hidden;
|
|
|
+ }
|
|
|
+
|
|
|
+ .post-desc {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #6b7280;
|
|
|
+ margin-bottom: 10px;
|
|
|
+ display: -webkit-box;
|
|
|
+ -webkit-line-clamp: 2;
|
|
|
+ -webkit-box-orient: vertical;
|
|
|
+ overflow: hidden;
|
|
|
+ }
|
|
|
+
|
|
|
+ .post-meta {
|
|
|
+ display: flex;
|
|
|
+ gap: 15px;
|
|
|
+ margin-bottom: 8px;
|
|
|
+ font-size: 12px;
|
|
|
+ color: #9ca3af;
|
|
|
+ }
|
|
|
+
|
|
|
+ .post-meta-item {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 4px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .post-author {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #6b7280;
|
|
|
+ margin-bottom: 8px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .post-id {
|
|
|
+ font-size: 10px;
|
|
|
+ color: #9ca3af;
|
|
|
+ font-family: monospace;
|
|
|
+ }
|
|
|
+
|
|
|
+ .evaluation-reason {
|
|
|
+ position: absolute;
|
|
|
+ bottom: calc(100% + 10px);
|
|
|
+ left: 50%;
|
|
|
+ transform: translateX(-50%);
|
|
|
+ background: #2d3748;
|
|
|
+ color: white;
|
|
|
+ padding: 12px 16px;
|
|
|
+ border-radius: 8px;
|
|
|
+ font-size: 12px;
|
|
|
+ line-height: 1.5;
|
|
|
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
|
|
+ z-index: 1000;
|
|
|
+ display: none;
|
|
|
+ white-space: normal;
|
|
|
+ width: 280px;
|
|
|
+ }
|
|
|
+
|
|
|
+ /* Tooltip 箭头 - 指向下方进度条 */
|
|
|
+ .evaluation-reason::after {
|
|
|
+ content: '';
|
|
|
+ position: absolute;
|
|
|
+ top: 100%;
|
|
|
+ left: 50%;
|
|
|
+ transform: translateX(-50%);
|
|
|
+ border: 6px solid transparent;
|
|
|
+ border-top-color: #2d3748;
|
|
|
+ }
|
|
|
+
|
|
|
+ .confidence-bar:hover .evaluation-reason {
|
|
|
+ display: block !important;
|
|
|
+ }
|
|
|
+
|
|
|
+ /* Debug: 让进度条更明显可悬停 */
|
|
|
+ .confidence-bar {
|
|
|
+ min-height: 32px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .evaluation-reason strong {
|
|
|
+ color: #fbbf24;
|
|
|
+ font-size: 13px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .evaluation-scores {
|
|
|
+ display: flex;
|
|
|
+ gap: 10px;
|
|
|
+ margin-top: 10px;
|
|
|
+ font-size: 12px;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ }
|
|
|
+
|
|
|
+ .score-item {
|
|
|
+ background: rgba(255, 255, 255, 0.15);
|
|
|
+ padding: 5px 10px;
|
|
|
+ border-radius: 12px;
|
|
|
+ color: #fbbf24;
|
|
|
+ border: 1px solid rgba(251, 191, 36, 0.3);
|
|
|
+ }
|
|
|
+
|
|
|
+ .confidence-bar {
|
|
|
+ width: 100%;
|
|
|
+ height: 32px;
|
|
|
+ background: #f3f4f6;
|
|
|
+ position: relative;
|
|
|
+ cursor: help;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ border-radius: 0 0 8px 8px;
|
|
|
+ overflow: hidden;
|
|
|
+ }
|
|
|
+
|
|
|
+ .confidence-bar-fill {
|
|
|
+ height: 100%;
|
|
|
+ transition: width 0.5s ease-out;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ padding: 0 12px;
|
|
|
+ position: relative;
|
|
|
+ }
|
|
|
+
|
|
|
+ .confidence-bar-fill.confidence-low {
|
|
|
+ background: linear-gradient(90deg, #ef4444, #f87171);
|
|
|
+ }
|
|
|
+
|
|
|
+ .confidence-bar-fill.confidence-medium {
|
|
|
+ background: linear-gradient(90deg, #f59e0b, #fbbf24);
|
|
|
+ }
|
|
|
+
|
|
|
+ .confidence-bar-fill.confidence-high {
|
|
|
+ background: linear-gradient(90deg, #10b981, #34d399);
|
|
|
+ }
|
|
|
+
|
|
|
+ .confidence-bar-text {
|
|
|
+ color: white;
|
|
|
+ font-size: 12px;
|
|
|
+ font-weight: 600;
|
|
|
+ white-space: nowrap;
|
|
|
+ position: relative;
|
|
|
+ z-index: 1;
|
|
|
+ text-shadow: 0 1px 2px rgba(0,0,0,0.2);
|
|
|
+ }
|
|
|
+
|
|
|
+ /* 保留旧的badge样式用于兼容 */
|
|
|
+ .confidence-badge {
|
|
|
+ background: #10b981;
|
|
|
+ color: white;
|
|
|
+ padding: 4px 10px;
|
|
|
+ border-radius: 15px;
|
|
|
+ font-size: 12px;
|
|
|
+ font-weight: bold;
|
|
|
+ display: inline-block;
|
|
|
+ margin-bottom: 10px;
|
|
|
+ position: relative;
|
|
|
+ cursor: help;
|
|
|
+ }
|
|
|
+
|
|
|
+ .confidence-low {
|
|
|
+ background: #ef4444;
|
|
|
+ }
|
|
|
+
|
|
|
+ .confidence-medium {
|
|
|
+ background: #f59e0b;
|
|
|
+ }
|
|
|
+
|
|
|
+ .confidence-high {
|
|
|
+ background: #10b981;
|
|
|
+ }
|
|
|
+
|
|
|
+ .query-list {
|
|
|
+ background: #f8f9fa;
|
|
|
+ padding: 20px;
|
|
|
+ border-radius: 8px;
|
|
|
+ margin-top: 15px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .query-item {
|
|
|
+ background: white;
|
|
|
+ padding: 15px;
|
|
|
+ border-radius: 6px;
|
|
|
+ margin-bottom: 10px;
|
|
|
+ border-left: 3px solid #2563eb;
|
|
|
+ }
|
|
|
+
|
|
|
+ .query-text {
|
|
|
+ font-size: 15px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #1a1a1a;
|
|
|
+ margin-bottom: 5px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .query-meta {
|
|
|
+ font-size: 13px;
|
|
|
+ color: #666;
|
|
|
+ }
|
|
|
+
|
|
|
+ .answer-box {
|
|
|
+ background: #f0fdf4;
|
|
|
+ border: 2px solid #10b981;
|
|
|
+ border-radius: 8px;
|
|
|
+ padding: 25px;
|
|
|
+ margin-top: 20px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .answer-header {
|
|
|
+ font-size: 18px;
|
|
|
+ color: #059669;
|
|
|
+ margin-bottom: 15px;
|
|
|
+ font-weight: 600;
|
|
|
+ }
|
|
|
+
|
|
|
+ .answer-content {
|
|
|
+ font-size: 15px;
|
|
|
+ line-height: 1.8;
|
|
|
+ color: #1a1a1a;
|
|
|
+ white-space: pre-wrap;
|
|
|
+ }
|
|
|
+
|
|
|
+ .answer-meta {
|
|
|
+ margin-top: 15px;
|
|
|
+ padding-top: 15px;
|
|
|
+ border-top: 1px solid #d1fae5;
|
|
|
+ display: flex;
|
|
|
+ gap: 20px;
|
|
|
+ font-size: 13px;
|
|
|
+ color: #059669;
|
|
|
+ }
|
|
|
+
|
|
|
+ .keyword-tags {
|
|
|
+ display: flex;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ gap: 10px;
|
|
|
+ margin-top: 15px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .keyword-tag {
|
|
|
+ background: #dbeafe;
|
|
|
+ color: #1e40af;
|
|
|
+ padding: 6px 12px;
|
|
|
+ border-radius: 15px;
|
|
|
+ font-size: 13px;
|
|
|
+ font-weight: 500;
|
|
|
+ }
|
|
|
+
|
|
|
+ .level-analysis {
|
|
|
+ background: #fef3c7;
|
|
|
+ border-left: 4px solid #f59e0b;
|
|
|
+ padding: 20px;
|
|
|
+ border-radius: 6px;
|
|
|
+ margin-top: 15px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .level-analysis-title {
|
|
|
+ font-size: 16px;
|
|
|
+ color: #92400e;
|
|
|
+ margin-bottom: 10px;
|
|
|
+ font-weight: 600;
|
|
|
+ }
|
|
|
+
|
|
|
+ .level-analysis-text {
|
|
|
+ font-size: 14px;
|
|
|
+ color: #78350f;
|
|
|
+ line-height: 1.8;
|
|
|
+ }
|
|
|
+
|
|
|
+ .timestamp {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #9ca3af;
|
|
|
+ margin-top: 10px;
|
|
|
+ }
|
|
|
+
|
|
|
+ a {
|
|
|
+ color: #2563eb;
|
|
|
+ text-decoration: none;
|
|
|
+ }
|
|
|
+
|
|
|
+ a:hover {
|
|
|
+ text-decoration: underline;
|
|
|
+ }
|
|
|
+
|
|
|
+ /* 模态框样式 */
|
|
|
+ .modal-overlay {
|
|
|
+ display: none;
|
|
|
+ position: fixed;
|
|
|
+ top: 0;
|
|
|
+ left: 0;
|
|
|
+ right: 0;
|
|
|
+ bottom: 0;
|
|
|
+ background: rgba(0, 0, 0, 0.85);
|
|
|
+ z-index: 1000;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ padding: 20px;
|
|
|
+ overflow-y: auto;
|
|
|
+ }
|
|
|
+
|
|
|
+ .modal-overlay.active {
|
|
|
+ display: flex;
|
|
|
+ }
|
|
|
+
|
|
|
+ .modal-content {
|
|
|
+ background: white;
|
|
|
+ border-radius: 12px;
|
|
|
+ max-width: 1000px;
|
|
|
+ width: 100%;
|
|
|
+ max-height: 90vh;
|
|
|
+ overflow-y: auto;
|
|
|
+ position: relative;
|
|
|
+ animation: modalSlideIn 0.3s;
|
|
|
+ }
|
|
|
+
|
|
|
+ @keyframes modalSlideIn {
|
|
|
+ from { opacity: 0; transform: translateY(-30px); }
|
|
|
+ to { opacity: 1; transform: translateY(0); }
|
|
|
+ }
|
|
|
+
|
|
|
+ .modal-close {
|
|
|
+ position: sticky;
|
|
|
+ top: 0;
|
|
|
+ right: 0;
|
|
|
+ background: white;
|
|
|
+ border: none;
|
|
|
+ font-size: 36px;
|
|
|
+ color: #6b7280;
|
|
|
+ cursor: pointer;
|
|
|
+ padding: 15px 25px;
|
|
|
+ z-index: 10;
|
|
|
+ text-align: right;
|
|
|
+ border-bottom: 2px solid #e5e7eb;
|
|
|
+ transition: color 0.2s;
|
|
|
+ }
|
|
|
+
|
|
|
+ .modal-close:hover {
|
|
|
+ color: #1f2937;
|
|
|
+ }
|
|
|
+
|
|
|
+ .modal-body {
|
|
|
+ padding: 30px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .modal-title {
|
|
|
+ font-size: 26px;
|
|
|
+ font-weight: 700;
|
|
|
+ color: #1a1a1a;
|
|
|
+ margin-bottom: 15px;
|
|
|
+ line-height: 1.4;
|
|
|
+ }
|
|
|
+
|
|
|
+ .modal-meta {
|
|
|
+ display: flex;
|
|
|
+ gap: 20px;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ margin-bottom: 25px;
|
|
|
+ padding-bottom: 20px;
|
|
|
+ border-bottom: 1px solid #e5e7eb;
|
|
|
+ }
|
|
|
+
|
|
|
+ .modal-meta-item {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 6px;
|
|
|
+ font-size: 14px;
|
|
|
+ color: #6b7280;
|
|
|
+ }
|
|
|
+
|
|
|
+ .modal-images {
|
|
|
+ margin-bottom: 25px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .modal-images-grid {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
|
|
+ gap: 12px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .modal-image-item {
|
|
|
+ border-radius: 8px;
|
|
|
+ overflow: hidden;
|
|
|
+ border: 2px solid #e5e7eb;
|
|
|
+ transition: border-color 0.2s;
|
|
|
+ cursor: pointer;
|
|
|
+ }
|
|
|
+
|
|
|
+ .modal-image-item:hover {
|
|
|
+ border-color: #2563eb;
|
|
|
+ }
|
|
|
+
|
|
|
+ .modal-image {
|
|
|
+ width: 100%;
|
|
|
+ height: auto;
|
|
|
+ display: block;
|
|
|
+ max-height: 250px;
|
|
|
+ object-fit: cover;
|
|
|
+ }
|
|
|
+
|
|
|
+ .modal-section {
|
|
|
+ margin-bottom: 25px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .modal-section-title {
|
|
|
+ font-size: 17px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #374151;
|
|
|
+ margin-bottom: 12px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .modal-text-content {
|
|
|
+ font-size: 15px;
|
|
|
+ color: #1f2937;
|
|
|
+ line-height: 1.8;
|
|
|
+ white-space: pre-wrap;
|
|
|
+ background: #f9fafb;
|
|
|
+ padding: 18px;
|
|
|
+ border-radius: 8px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .modal-evaluation {
|
|
|
+ background: #fef3c7;
|
|
|
+ border-left: 4px solid #f59e0b;
|
|
|
+ padding: 18px;
|
|
|
+ border-radius: 6px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .modal-link {
|
|
|
+ margin-top: 25px;
|
|
|
+ padding-top: 25px;
|
|
|
+ border-top: 2px solid #e5e7eb;
|
|
|
+ text-align: center;
|
|
|
+ }
|
|
|
+
|
|
|
+ .modal-link-btn {
|
|
|
+ display: inline-flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 10px;
|
|
|
+ padding: 12px 28px;
|
|
|
+ background: #2563eb;
|
|
|
+ color: white;
|
|
|
+ text-decoration: none;
|
|
|
+ border-radius: 8px;
|
|
|
+ font-size: 15px;
|
|
|
+ font-weight: 600;
|
|
|
+ transition: all 0.2s;
|
|
|
+ }
|
|
|
+
|
|
|
+ .modal-link-btn:hover {
|
|
|
+ background: #1d4ed8;
|
|
|
+ transform: translateY(-2px);
|
|
|
+ box-shadow: 0 4px 12px rgba(37, 99, 235, 0.3);
|
|
|
+ }
|
|
|
+
|
|
|
+ /* 卡片上的图片轮播指示器 */
|
|
|
+ .carousel-arrow {
|
|
|
+ position: absolute;
|
|
|
+ top: 50%;
|
|
|
+ transform: translateY(-50%);
|
|
|
+ background: rgba(0, 0, 0, 0.6);
|
|
|
+ color: white;
|
|
|
+ border: none;
|
|
|
+ width: 36px;
|
|
|
+ height: 36px;
|
|
|
+ border-radius: 50%;
|
|
|
+ font-size: 20px;
|
|
|
+ cursor: pointer;
|
|
|
+ z-index: 15;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ transition: all 0.2s;
|
|
|
+ opacity: 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ .post-image-wrapper:hover .carousel-arrow {
|
|
|
+ opacity: 1;
|
|
|
+ }
|
|
|
+
|
|
|
+ .carousel-arrow:hover {
|
|
|
+ background: rgba(0, 0, 0, 0.8);
|
|
|
+ transform: translateY(-50%) scale(1.1);
|
|
|
+ }
|
|
|
+
|
|
|
+ .carousel-arrow.left {
|
|
|
+ left: 8px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .carousel-arrow.right {
|
|
|
+ right: 8px;
|
|
|
+ }
|
|
|
+
|
|
|
+ /* 可折叠区域样式 */
|
|
|
+ .collapsible-section {
|
|
|
+ margin: 20px 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ .collapsible-header {
|
|
|
+ background: #f3f4f6;
|
|
|
+ padding: 12px 15px;
|
|
|
+ border-radius: 8px;
|
|
|
+ cursor: pointer;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 10px;
|
|
|
+ transition: background 0.2s;
|
|
|
+ user-select: none;
|
|
|
+ }
|
|
|
+
|
|
|
+ .collapsible-header:hover {
|
|
|
+ background: #e5e7eb;
|
|
|
+ }
|
|
|
+
|
|
|
+ .collapsible-toggle {
|
|
|
+ font-size: 14px;
|
|
|
+ transition: transform 0.2s;
|
|
|
+ }
|
|
|
+
|
|
|
+ .collapsible-toggle.collapsed {
|
|
|
+ transform: rotate(-90deg);
|
|
|
+ }
|
|
|
+
|
|
|
+ .collapsible-title {
|
|
|
+ font-weight: 600;
|
|
|
+ font-size: 16px;
|
|
|
+ color: #374151;
|
|
|
+ }
|
|
|
+
|
|
|
+ .collapsible-content {
|
|
|
+ max-height: 10000px;
|
|
|
+ overflow: hidden;
|
|
|
+ transition: max-height 0.3s ease-out, opacity 0.3s ease-out;
|
|
|
+ opacity: 1;
|
|
|
+ }
|
|
|
+
|
|
|
+ .collapsible-content.collapsed {
|
|
|
+ max-height: 0;
|
|
|
+ opacity: 0;
|
|
|
+ }
|
|
|
+ </style>
|
|
|
+</head>
|
|
|
+<body>
|
|
|
+ <!-- 左侧导航 -->
|
|
|
+ <div class="sidebar">
|
|
|
+ <div class="sidebar-header">📑 目录</div>
|
|
|
+ <div class="toc" id="toc"></div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 主内容区 -->
|
|
|
+ <div class="container">
|
|
|
+ {content}
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 模态框 -->
|
|
|
+ <div id="postModal" class="modal-overlay" onclick="if(event.target === this) closeModal()">
|
|
|
+ <div class="modal-content" onclick="event.stopPropagation()">
|
|
|
+ <button class="modal-close" onclick="closeModal()">×</button>
|
|
|
+ <div class="modal-body" id="modalBody">
|
|
|
+ <!-- 动态内容 -->
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <script>
|
|
|
+ // 模态框功能
|
|
|
+ function openModal(postData) {
|
|
|
+ const modal = document.getElementById('postModal');
|
|
|
+ const modalBody = document.getElementById('modalBody');
|
|
|
+
|
|
|
+ // 构建图片网格
|
|
|
+ let imagesHtml = '';
|
|
|
+ if (postData.images && postData.images.length > 0) {
|
|
|
+ imagesHtml = '<div class="modal-images"><div class="modal-images-grid">';
|
|
|
+ postData.images.forEach((img, idx) => {
|
|
|
+ imagesHtml += `<div class="modal-image-item"><img src="${img}" class="modal-image" alt="图片 ${idx + 1}"></div>`;
|
|
|
+ });
|
|
|
+ imagesHtml += '</div></div>';
|
|
|
+ }
|
|
|
+
|
|
|
+ // 构建评估详情
|
|
|
+ let evalHtml = '';
|
|
|
+ if (postData.evaluation) {
|
|
|
+ evalHtml = `
|
|
|
+ <div class="modal-section">
|
|
|
+ <div class="modal-section-title">💡 评估详情</div>
|
|
|
+ <div class="modal-evaluation">
|
|
|
+ <div style="margin-bottom: 12px;"><strong>评估理由:</strong></div>
|
|
|
+ <div style="color: #78350f; line-height: 1.8;">${postData.evaluation.reason || '无'}</div>
|
|
|
+ <div class="evaluation-scores" style="margin-top: 12px;">
|
|
|
+ <span class="score-item">📌 标题相关性: ${postData.evaluation.title_relevance?.toFixed(2) || '0.00'}</span>
|
|
|
+ <span class="score-item">📄 内容期望: ${postData.evaluation.content_expectation?.toFixed(2) || '0.00'}</span>
|
|
|
+ <span class="score-item">🎯 置信度: ${postData.evaluation.confidence_score?.toFixed(2) || '0.00'}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>`;
|
|
|
+ }
|
|
|
+
|
|
|
+ modalBody.innerHTML = `
|
|
|
+ <div class="modal-title">${postData.title}</div>
|
|
|
+ <div class="modal-meta">
|
|
|
+ <div class="modal-meta-item">👤 ${postData.user}</div>
|
|
|
+ <div class="modal-meta-item">❤️ ${postData.likes}</div>
|
|
|
+ <div class="modal-meta-item">⭐ ${postData.collects}</div>
|
|
|
+ <div class="modal-meta-item">💬 ${postData.comments}</div>
|
|
|
+ ${postData.type === 'video' ? '<div class="modal-meta-item">📹 视频</div>' : ''}
|
|
|
+ </div>
|
|
|
+ ${imagesHtml}
|
|
|
+ <div class="modal-section">
|
|
|
+ <div class="modal-section-title">📝 描述</div>
|
|
|
+ <div class="modal-text-content">${postData.desc || '无描述'}</div>
|
|
|
+ </div>
|
|
|
+ ${evalHtml}
|
|
|
+ <div class="modal-link">
|
|
|
+ <a href="${postData.url}" target="_blank" class="modal-link-btn">
|
|
|
+ 🔗 在小红书中查看
|
|
|
+ </a>
|
|
|
+ </div>
|
|
|
+ `;
|
|
|
+
|
|
|
+ modal.classList.add('active');
|
|
|
+ document.body.style.overflow = 'hidden';
|
|
|
+ }
|
|
|
+
|
|
|
+ function closeModal() {
|
|
|
+ const modal = document.getElementById('postModal');
|
|
|
+ modal.classList.remove('active');
|
|
|
+ document.body.style.overflow = '';
|
|
|
+ }
|
|
|
+
|
|
|
+ // ESC键关闭模态框
|
|
|
+ document.addEventListener('keydown', function(e) {
|
|
|
+ if (e.key === 'Escape') {
|
|
|
+ closeModal();
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ // 卡片上的图片轮播(使用左右箭头按钮)
|
|
|
+ function initCarousels() {
|
|
|
+ document.querySelectorAll('.post-card').forEach(card => {
|
|
|
+ const images = JSON.parse(card.dataset.images || '[]');
|
|
|
+ if (images.length <= 1) return;
|
|
|
+
|
|
|
+ let currentIndex = 0;
|
|
|
+ const imgElement = card.querySelector('.post-image');
|
|
|
+ const leftArrow = card.querySelector('.carousel-arrow.left');
|
|
|
+ const rightArrow = card.querySelector('.carousel-arrow.right');
|
|
|
+
|
|
|
+ // 左箭头点击
|
|
|
+ if (leftArrow) {
|
|
|
+ leftArrow.addEventListener('click', function(e) {
|
|
|
+ e.stopPropagation();
|
|
|
+ currentIndex = (currentIndex - 1 + images.length) % images.length;
|
|
|
+ if (imgElement) {
|
|
|
+ imgElement.src = images[currentIndex];
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // 右箭头点击
|
|
|
+ if (rightArrow) {
|
|
|
+ rightArrow.addEventListener('click', function(e) {
|
|
|
+ e.stopPropagation();
|
|
|
+ currentIndex = (currentIndex + 1) % images.length;
|
|
|
+ if (imgElement) {
|
|
|
+ imgElement.src = images[currentIndex];
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // 生成目录(显示步骤和可折叠的子项)
|
|
|
+ function generateTOC() {
|
|
|
+ const toc = document.getElementById('toc');
|
|
|
+ const sections = document.querySelectorAll('.step-section');
|
|
|
+
|
|
|
+ sections.forEach((section, index) => {
|
|
|
+ const title = section.querySelector('.step-title')?.textContent || `步骤 ${index + 1}`;
|
|
|
+ const id = `step-${index}`;
|
|
|
+ section.id = id;
|
|
|
+
|
|
|
+ // 查找该section下的直接子可折叠项(不包括嵌套的)
|
|
|
+ const collapsibleSections = section.querySelectorAll(':scope > .step-content > .collapsible-section[id]');
|
|
|
+
|
|
|
+ // 创建步骤项
|
|
|
+ const stepItem = document.createElement('div');
|
|
|
+ stepItem.className = 'toc-item toc-item-level-0';
|
|
|
+
|
|
|
+ if (collapsibleSections.length > 0) {
|
|
|
+ // 如果有子项,添加展开/折叠图标(箭头放在右侧)
|
|
|
+ const toggleId = `toc-toggle-${index}`;
|
|
|
+ stepItem.innerHTML = `<span>${title}</span><span class="toc-toggle" id="${toggleId}">▼</span>`;
|
|
|
+
|
|
|
+ const toggle = stepItem.querySelector('.toc-toggle');
|
|
|
+ const childrenId = `toc-children-${index}`;
|
|
|
+ toggle.onclick = (e) => {
|
|
|
+ e.stopPropagation();
|
|
|
+ toggle.classList.toggle('collapsed');
|
|
|
+ const children = document.getElementById(childrenId);
|
|
|
+ if (children) {
|
|
|
+ children.classList.toggle('expanded');
|
|
|
+ }
|
|
|
+ };
|
|
|
+ } else {
|
|
|
+ stepItem.textContent = title;
|
|
|
+ }
|
|
|
+
|
|
|
+ stepItem.onclick = (e) => {
|
|
|
+ if (!e.target.classList.contains('toc-toggle')) {
|
|
|
+ scrollToSection(id);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ toc.appendChild(stepItem);
|
|
|
+
|
|
|
+ // 添加子项目录(支持嵌套)
|
|
|
+ if (collapsibleSections.length > 0) {
|
|
|
+ const childrenContainer = document.createElement('div');
|
|
|
+ childrenContainer.id = `toc-children-${index}`;
|
|
|
+ childrenContainer.className = 'toc-children expanded';
|
|
|
+
|
|
|
+ collapsibleSections.forEach(collapsible => {
|
|
|
+ const subTitle = collapsible.getAttribute('data-title') || '子项';
|
|
|
+ const subId = collapsible.id;
|
|
|
+
|
|
|
+ const subItem = document.createElement('div');
|
|
|
+ subItem.className = 'toc-item toc-item-level-1';
|
|
|
+ subItem.textContent = subTitle;
|
|
|
+ subItem.onclick = () => scrollToSection(subId);
|
|
|
+
|
|
|
+ childrenContainer.appendChild(subItem);
|
|
|
+
|
|
|
+ // 查找该可折叠区域内的嵌套可折叠区域
|
|
|
+ const nestedCollapsibles = collapsible.querySelectorAll(':scope > .collapsible-content > .collapsible-section[id]');
|
|
|
+ if (nestedCollapsibles.length > 0) {
|
|
|
+ nestedCollapsibles.forEach(nested => {
|
|
|
+ const nestedTitle = nested.getAttribute('data-title') || '子项';
|
|
|
+ const nestedId = nested.id;
|
|
|
+
|
|
|
+ const nestedItem = document.createElement('div');
|
|
|
+ nestedItem.className = 'toc-item toc-item-level-2';
|
|
|
+ nestedItem.textContent = nestedTitle;
|
|
|
+ nestedItem.onclick = () => scrollToSection(nestedId);
|
|
|
+
|
|
|
+ childrenContainer.appendChild(nestedItem);
|
|
|
+ });
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ toc.appendChild(childrenContainer);
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // 滚动到指定section
|
|
|
+ function scrollToSection(id) {
|
|
|
+ const element = document.getElementById(id);
|
|
|
+ if (element) {
|
|
|
+ const offset = 80;
|
|
|
+ const elementPosition = element.getBoundingClientRect().top;
|
|
|
+ const offsetPosition = elementPosition + window.pageYOffset - offset;
|
|
|
+
|
|
|
+ window.scrollTo({
|
|
|
+ top: offsetPosition,
|
|
|
+ behavior: 'smooth'
|
|
|
+ });
|
|
|
+
|
|
|
+ // 更新active状态
|
|
|
+ document.querySelectorAll('.toc-item').forEach(item => item.classList.remove('active'));
|
|
|
+ event.target.classList.add('active');
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 滚动时高亮当前section
|
|
|
+ function updateActiveTOC() {
|
|
|
+ const sections = document.querySelectorAll('.step-section');
|
|
|
+ const tocItems = document.querySelectorAll('.toc-item');
|
|
|
+
|
|
|
+ let currentIndex = -1;
|
|
|
+ sections.forEach((section, index) => {
|
|
|
+ const rect = section.getBoundingClientRect();
|
|
|
+ if (rect.top <= 100) {
|
|
|
+ currentIndex = index;
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ tocItems.forEach((item, index) => {
|
|
|
+ item.classList.toggle('active', index === currentIndex);
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // 初始化可折叠区域
|
|
|
+ function initCollapsibles() {
|
|
|
+ document.querySelectorAll('.collapsible-header').forEach(header => {
|
|
|
+ header.addEventListener('click', function() {
|
|
|
+ const toggle = this.querySelector('.collapsible-toggle');
|
|
|
+ const content = this.nextElementSibling;
|
|
|
+
|
|
|
+ if (content && content.classList.contains('collapsible-content')) {
|
|
|
+ toggle.classList.toggle('collapsed');
|
|
|
+ content.classList.toggle('collapsed');
|
|
|
+ }
|
|
|
+ });
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // 页面加载完成后初始化
|
|
|
+ document.addEventListener('DOMContentLoaded', function() {
|
|
|
+ initCarousels();
|
|
|
+ generateTOC();
|
|
|
+ initCollapsibles();
|
|
|
+ window.addEventListener('scroll', updateActiveTOC);
|
|
|
+ updateActiveTOC();
|
|
|
+ });
|
|
|
+ </script>
|
|
|
+</body>
|
|
|
+</html>
|
|
|
+"""
|
|
|
+
|
|
|
+
|
|
|
+def make_collapsible(title, content, collapsed=True, section_id=None):
|
|
|
+ """创建可折叠区域的HTML"""
|
|
|
+ collapsed_class = " collapsed" if collapsed else ""
|
|
|
+ id_attr = f' id="{section_id}"' if section_id else ""
|
|
|
+ # 添加 data-title 属性用于目录生成
|
|
|
+ title_attr = f' data-title="{title}"' if section_id else ""
|
|
|
+ return f"""
|
|
|
+ <div class="collapsible-section"{id_attr}{title_attr}>
|
|
|
+ <div class="collapsible-header">
|
|
|
+ <span class="collapsible-toggle{collapsed_class}">▼</span>
|
|
|
+ <span class="collapsible-title">{title}</span>
|
|
|
+ </div>
|
|
|
+ <div class="collapsible-content{collapsed_class}">
|
|
|
+ {content}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ """
|
|
|
+
|
|
|
+
|
|
|
+def get_confidence_class(score):
|
|
|
+ """根据置信度分数返回CSS类"""
|
|
|
+ if score >= 0.7:
|
|
|
+ return "confidence-high"
|
|
|
+ elif score >= 0.5:
|
|
|
+ return "confidence-medium"
|
|
|
+ else:
|
|
|
+ return "confidence-low"
|
|
|
+
|
|
|
+
|
|
|
+def escape_js_string(s):
|
|
|
+ """转义JavaScript字符串"""
|
|
|
+ import json
|
|
|
+ return json.dumps(str(s) if s else "")
|
|
|
+
|
|
|
+
|
|
|
+def build_post_json_data(note, evaluation=None):
|
|
|
+ """构建帖子的JSON数据用于模态框"""
|
|
|
+ import json
|
|
|
+
|
|
|
+ image_list = note.get('image_list', [])
|
|
|
+ if not image_list and note.get('cover_image'):
|
|
|
+ cover = note.get('cover_image')
|
|
|
+ # cover_image 可能是字典或字符串
|
|
|
+ if isinstance(cover, dict):
|
|
|
+ image_list = [cover.get('image_url', '')]
|
|
|
+ else:
|
|
|
+ image_list = [cover]
|
|
|
+
|
|
|
+ # image_list 现在已经是 URL 字符串列表(由搜索API预处理)
|
|
|
+ images = [img if isinstance(img, str) else img.get('image_url', '') for img in image_list if img]
|
|
|
+
|
|
|
+ interact = note.get('interact_info', {})
|
|
|
+ user = note.get('user', {})
|
|
|
+
|
|
|
+ data = {
|
|
|
+ 'title': note.get('title', '无标题'),
|
|
|
+ 'desc': note.get('desc', ''),
|
|
|
+ 'user': user.get('nickname', '未知'),
|
|
|
+ 'likes': interact.get('liked_count', 0),
|
|
|
+ 'collects': interact.get('collected_count', 0),
|
|
|
+ 'comments': interact.get('comment_count', 0),
|
|
|
+ 'type': note.get('type', 'normal'),
|
|
|
+ 'url': note.get('note_url', ''),
|
|
|
+ 'images': images
|
|
|
+ }
|
|
|
+
|
|
|
+ if evaluation:
|
|
|
+ data['evaluation'] = {
|
|
|
+ 'reason': evaluation.get('reason', ''),
|
|
|
+ 'title_relevance': evaluation.get('title_relevance', 0),
|
|
|
+ 'content_expectation': evaluation.get('content_expectation', 0),
|
|
|
+ 'confidence_score': evaluation.get('confidence_score', 0)
|
|
|
+ }
|
|
|
+
|
|
|
+ return json.dumps(data, ensure_ascii=False)
|
|
|
+
|
|
|
+
|
|
|
+def render_header(steps_data):
|
|
|
+ """渲染页面头部"""
|
|
|
+ # 获取基本信息
|
|
|
+ first_step = steps_data[0] if steps_data else {}
|
|
|
+ last_step = steps_data[-1] if steps_data else {}
|
|
|
+
|
|
|
+ original_question = ""
|
|
|
+ keywords = []
|
|
|
+ total_steps = len(steps_data)
|
|
|
+ satisfied_notes = 0
|
|
|
+
|
|
|
+ # 提取关键信息
|
|
|
+ for step in steps_data:
|
|
|
+ if step.get("step_type") == "keyword_extraction":
|
|
|
+ original_question = step.get("data", {}).get("input_question", "")
|
|
|
+ keywords = step.get("data", {}).get("keywords", [])
|
|
|
+ elif step.get("step_type") == "final_result":
|
|
|
+ satisfied_notes = step.get("data", {}).get("satisfied_notes_count", 0)
|
|
|
+
|
|
|
+ keywords_html = "".join([f'<span class="keyword-tag">{k}</span>' for k in keywords])
|
|
|
+
|
|
|
+ html = f"""
|
|
|
+ <div class="header">
|
|
|
+ <h1>🔍 Query Optimization Steps</h1>
|
|
|
+ <div class="question-box">
|
|
|
+ <div class="question-label">原始问题</div>
|
|
|
+ <div class="question-text">{original_question}</div>
|
|
|
+ </div>
|
|
|
+ {f'<div class="keyword-tags">{keywords_html}</div>' if keywords else ''}
|
|
|
+ <div class="overview">
|
|
|
+ <div class="overview-item">
|
|
|
+ <div class="overview-label">总步骤数</div>
|
|
|
+ <div class="overview-value">{total_steps}</div>
|
|
|
+ </div>
|
|
|
+ <div class="overview-item">
|
|
|
+ <div class="overview-label">满足需求的帖子</div>
|
|
|
+ <div class="overview-value">{satisfied_notes}</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ """
|
|
|
+ return html
|
|
|
+
|
|
|
+
|
|
|
+def render_keyword_extraction(step):
|
|
|
+ """渲染关键词提取步骤"""
|
|
|
+ data = step.get("data", {})
|
|
|
+ keywords = data.get("keywords", [])
|
|
|
+ reasoning = data.get("reasoning", "")
|
|
|
+
|
|
|
+ keywords_html = "".join([f'<span class="keyword-tag">{k}</span>' for k in keywords])
|
|
|
+
|
|
|
+ html = f"""
|
|
|
+ <div class="step-section">
|
|
|
+ <div class="step-header">
|
|
|
+ <div class="step-title">步骤 {step['step_number']}: {step['step_name']}</div>
|
|
|
+ <div class="step-type">{step['step_type']}</div>
|
|
|
+ </div>
|
|
|
+ <div class="step-content">
|
|
|
+ <div class="keyword-tags">{keywords_html}</div>
|
|
|
+ {f'<p style="margin-top: 15px; color: #666; font-size: 14px;">{reasoning}</p>' if reasoning else ''}
|
|
|
+ </div>
|
|
|
+ <div class="timestamp">⏰ {step.get('timestamp', '')}</div>
|
|
|
+ </div>
|
|
|
+ """
|
|
|
+ return html
|
|
|
+
|
|
|
+
|
|
|
+def render_level_exploration(step):
|
|
|
+ """渲染层级探索步骤"""
|
|
|
+ data = step.get("data", {})
|
|
|
+ level = data.get("level", 0)
|
|
|
+ query_count = data.get("query_count", 0)
|
|
|
+ results = data.get("results", [])
|
|
|
+
|
|
|
+ queries_html = ""
|
|
|
+ for result in results:
|
|
|
+ query = result.get("query", "")
|
|
|
+ suggestions = result.get("suggestions", [])
|
|
|
+
|
|
|
+ # 使用标签样式显示推荐词
|
|
|
+ suggestions_tags = ""
|
|
|
+ for suggestion in suggestions:
|
|
|
+ suggestions_tags += f'<span class="keyword-tag" style="margin: 3px;">{suggestion}</span>'
|
|
|
+
|
|
|
+ queries_html += f"""
|
|
|
+ <div class="query-item">
|
|
|
+ <div class="query-text">{query}</div>
|
|
|
+ <div style="margin-top: 10px;">
|
|
|
+ <div style="color: #666; font-size: 13px; margin-bottom: 5px;">推荐词 ({len(suggestions)} 个):</div>
|
|
|
+ <div style="display: flex; flex-wrap: wrap; gap: 5px;">
|
|
|
+ {suggestions_tags}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ """
|
|
|
+
|
|
|
+ html = f"""
|
|
|
+ <div class="step-section">
|
|
|
+ <div class="step-header">
|
|
|
+ <div class="step-title">步骤 {step['step_number']}: Level {level} 探索</div>
|
|
|
+ <div class="step-type">{step['step_type']}</div>
|
|
|
+ </div>
|
|
|
+ <div class="step-content">
|
|
|
+ <div class="info-grid">
|
|
|
+ <div class="info-item">
|
|
|
+ <div class="info-label">探索query数</div>
|
|
|
+ <div class="info-value">{query_count}</div>
|
|
|
+ </div>
|
|
|
+ <div class="info-item">
|
|
|
+ <div class="info-label">获得推荐词总数</div>
|
|
|
+ <div class="info-value">{data.get('total_suggestions', 0)}</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="query-list">{queries_html}</div>
|
|
|
+ </div>
|
|
|
+ <div class="timestamp">⏰ {step.get('timestamp', '')}</div>
|
|
|
+ </div>
|
|
|
+ """
|
|
|
+ return html
|
|
|
+
|
|
|
+
|
|
|
+def render_level_analysis(step):
|
|
|
+ """渲染层级分析步骤"""
|
|
|
+ data = step.get("data", {})
|
|
|
+ level = data.get("level", 0)
|
|
|
+ key_findings = data.get("key_findings", "")
|
|
|
+ should_evaluate = data.get("should_evaluate_now", False)
|
|
|
+ promising_signals_count = data.get("promising_signals_count", 0)
|
|
|
+ next_combinations = data.get("next_combinations", [])
|
|
|
+ promising_signals = data.get("promising_signals", [])
|
|
|
+ reasoning = data.get("reasoning", "")
|
|
|
+ step_num = step['step_number']
|
|
|
+
|
|
|
+ # 渲染推理过程
|
|
|
+ reasoning_html = ""
|
|
|
+ if reasoning:
|
|
|
+ reasoning_html = f"""
|
|
|
+ <div style="margin-top: 20px;">
|
|
|
+ <div class="level-analysis">
|
|
|
+ <div class="level-analysis-title">💭 推理过程</div>
|
|
|
+ <div class="level-analysis-text">{reasoning}</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ """
|
|
|
+
|
|
|
+ # 渲染下一层探索
|
|
|
+ next_html = ""
|
|
|
+ if next_combinations:
|
|
|
+ next_items = "".join([f'<span class="keyword-tag">{q}</span>' for q in next_combinations])
|
|
|
+ next_html = f'<div style="margin-top: 15px;"><strong>下一层探索:</strong><div class="keyword-tags" style="margin-top: 10px;">{next_items}</div></div>'
|
|
|
+
|
|
|
+ # 渲染有价值的信号
|
|
|
+ signals_html = ""
|
|
|
+ if promising_signals:
|
|
|
+ signals_items = ""
|
|
|
+ for signal in promising_signals:
|
|
|
+ query = signal.get("query", "")
|
|
|
+ from_level = signal.get("from_level", "")
|
|
|
+ reason = signal.get("reason", "")
|
|
|
+
|
|
|
+ signals_items += f"""
|
|
|
+ <div class="query-item" style="border-left: 3px solid #10b981; padding-left: 15px;">
|
|
|
+ <div class="query-text" style="font-weight: 600;">{query}</div>
|
|
|
+ <div style="margin-top: 8px; color: #666; font-size: 13px;">
|
|
|
+ <span style="color: #10b981;">来自 Level {from_level}</span>
|
|
|
+ </div>
|
|
|
+ <div style="margin-top: 8px; color: #555; font-size: 14px; line-height: 1.5;">
|
|
|
+ {reason}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ """
|
|
|
+
|
|
|
+ signals_html = make_collapsible(
|
|
|
+ f"💡 有价值的信号 ({len(promising_signals)} 个)",
|
|
|
+ f'<div style="display: flex; flex-direction: column; gap: 15px; margin-top: 10px;">{signals_items}</div>',
|
|
|
+ collapsed=True,
|
|
|
+ section_id=f"step{step_num}-signals"
|
|
|
+ )
|
|
|
+
|
|
|
+ html = f"""
|
|
|
+ <div class="step-section">
|
|
|
+ <div class="step-header">
|
|
|
+ <div class="step-title">步骤 {step['step_number']}: Level {level} 分析</div>
|
|
|
+ <div class="step-type">{step['step_type']}</div>
|
|
|
+ </div>
|
|
|
+ <div class="step-content">
|
|
|
+ <div class="level-analysis">
|
|
|
+ <div class="level-analysis-title">🔎 关键发现</div>
|
|
|
+ <div class="level-analysis-text">{key_findings}</div>
|
|
|
+ </div>
|
|
|
+ <div class="info-grid" style="margin-top: 20px;">
|
|
|
+ <div class="info-item">
|
|
|
+ <div class="info-label">有价值信号数</div>
|
|
|
+ <div class="info-value">{promising_signals_count}</div>
|
|
|
+ </div>
|
|
|
+ <div class="info-item">
|
|
|
+ <div class="info-label">是否开始评估</div>
|
|
|
+ <div class="info-value">{'是' if should_evaluate else '否'}</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ {signals_html}
|
|
|
+ {reasoning_html}
|
|
|
+ {next_html}
|
|
|
+ </div>
|
|
|
+ <div class="timestamp">⏰ {step.get('timestamp', '')}</div>
|
|
|
+ </div>
|
|
|
+ """
|
|
|
+ return html
|
|
|
+
|
|
|
+
|
|
|
+def render_search_results(step):
|
|
|
+ """渲染搜索结果步骤"""
|
|
|
+ data = step.get("data", {})
|
|
|
+ search_results = data.get("search_results", [])
|
|
|
+
|
|
|
+ posts_html = ""
|
|
|
+ step_num = step['step_number']
|
|
|
+ for idx, sr in enumerate(search_results):
|
|
|
+ query = sr.get("query", "")
|
|
|
+ note_count = sr.get("note_count", 0)
|
|
|
+ notes_summary = sr.get("notes_summary", [])
|
|
|
+
|
|
|
+ # 渲染该query的帖子
|
|
|
+ posts_cards = ""
|
|
|
+ for note in notes_summary:
|
|
|
+ # 获取封面图
|
|
|
+ image_list = note.get('image_list', [])
|
|
|
+ if image_list:
|
|
|
+ # image_list 已经是 URL 字符串列表,第一张就是封面
|
|
|
+ cover_url = image_list[0] if isinstance(image_list[0], str) else image_list[0].get('image_url', '')
|
|
|
+ else:
|
|
|
+ cover = note.get("cover_image", {})
|
|
|
+ cover_url = cover.get("image_url", "") if isinstance(cover, dict) else cover if cover else ""
|
|
|
+
|
|
|
+ interact = note.get("interact_info", {})
|
|
|
+ user = note.get("user", {})
|
|
|
+
|
|
|
+ # image_list 现在已经是 URL 字符串列表
|
|
|
+ images = [img if isinstance(img, str) else img.get('image_url', '') for img in image_list if img]
|
|
|
+
|
|
|
+ # 构建帖子数据用于模态框
|
|
|
+ post_data = build_post_json_data(note)
|
|
|
+ images_json = json.dumps(images)
|
|
|
+
|
|
|
+ image_html = f'<img src="{cover_url}" class="post-image" alt="{note.get("title", "")}">' if cover_url else '<div class="no-image">无图片</div>'
|
|
|
+
|
|
|
+ type_badge = ""
|
|
|
+ if note.get("type") == "video":
|
|
|
+ type_badge = '<div class="post-type-badge">📹 视频</div>'
|
|
|
+
|
|
|
+ # 轮播箭头按钮
|
|
|
+ arrows_html = ""
|
|
|
+ if len(images) > 1:
|
|
|
+ arrows_html = '''
|
|
|
+ <button class="carousel-arrow left" onclick="event.stopPropagation()">‹</button>
|
|
|
+ <button class="carousel-arrow right" onclick="event.stopPropagation()">›</button>
|
|
|
+ '''
|
|
|
+
|
|
|
+ posts_cards += f"""
|
|
|
+ <div class="post-card" onclick='openModal({post_data})' data-images='{images_json}'>
|
|
|
+ <div class="post-image-wrapper">
|
|
|
+ {image_html}
|
|
|
+ {type_badge}
|
|
|
+ {arrows_html}
|
|
|
+ </div>
|
|
|
+ <div class="post-info">
|
|
|
+ <div class="post-title">{note.get('title', '无标题')}</div>
|
|
|
+ <div class="post-desc">{note.get('desc', '')}</div>
|
|
|
+ <div class="post-meta">
|
|
|
+ <div class="post-meta-item">❤️ {interact.get('liked_count', 0)}</div>
|
|
|
+ <div class="post-meta-item">⭐ {interact.get('collected_count', 0)}</div>
|
|
|
+ <div class="post-meta-item">💬 {interact.get('comment_count', 0)}</div>
|
|
|
+ </div>
|
|
|
+ <div class="post-author">👤 {user.get('nickname', '未知')}</div>
|
|
|
+ <div class="post-id">{note.get('note_id', '')}</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ """
|
|
|
+
|
|
|
+ # 使用可折叠区域包装每个query的搜索结果,添加唯一ID
|
|
|
+ query_content = f'<div class="posts-grid">{posts_cards}</div>'
|
|
|
+ posts_html += make_collapsible(
|
|
|
+ f"🔎 {query} (找到 {note_count} 个帖子)",
|
|
|
+ query_content,
|
|
|
+ collapsed=True,
|
|
|
+ section_id=f"step{step_num}-search-{idx}"
|
|
|
+ )
|
|
|
+
|
|
|
+ html = f"""
|
|
|
+ <div class="step-section">
|
|
|
+ <div class="step-header">
|
|
|
+ <div class="step-title">步骤 {step['step_number']}: 搜索结果</div>
|
|
|
+ <div class="step-type">{step['step_type']}</div>
|
|
|
+ </div>
|
|
|
+ <div class="step-content">
|
|
|
+ <div class="info-grid">
|
|
|
+ <div class="info-item">
|
|
|
+ <div class="info-label">搜索query数</div>
|
|
|
+ <div class="info-value">{data.get('qualified_count', 0)}</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ {posts_html}
|
|
|
+ </div>
|
|
|
+ <div class="timestamp">⏰ {step.get('timestamp', '')}</div>
|
|
|
+ </div>
|
|
|
+ """
|
|
|
+ return html
|
|
|
+
|
|
|
+
|
|
|
+def render_note_evaluations(step):
|
|
|
+ """渲染帖子评估步骤"""
|
|
|
+ data = step.get("data", {})
|
|
|
+ note_evaluations = data.get("note_evaluations", [])
|
|
|
+ total_satisfied = data.get("total_satisfied", 0)
|
|
|
+
|
|
|
+ evals_html = ""
|
|
|
+ step_num = step["step_number"]
|
|
|
+ for idx, query_eval in enumerate(note_evaluations):
|
|
|
+ query = query_eval.get("query", "")
|
|
|
+ satisfied_count = query_eval.get("satisfied_count", 0)
|
|
|
+ evaluated_notes = query_eval.get("evaluated_notes", [])
|
|
|
+
|
|
|
+ # 分离满足和不满足需求的帖子
|
|
|
+ satisfied_notes = [n for n in evaluated_notes if n.get('evaluation', {}).get('need_satisfaction')]
|
|
|
+ unsatisfied_notes = [n for n in evaluated_notes if not n.get('evaluation', {}).get('need_satisfaction')]
|
|
|
+
|
|
|
+ # 渲染满足需求的帖子
|
|
|
+ satisfied_cards = ""
|
|
|
+ for note in satisfied_notes:
|
|
|
+ # 获取封面图
|
|
|
+ image_list = note.get('image_list', [])
|
|
|
+ if image_list:
|
|
|
+ cover_url = image_list[0] if isinstance(image_list[0], str) else image_list[0].get('image_url', '')
|
|
|
+ else:
|
|
|
+ cover = note.get("cover_image", {})
|
|
|
+ cover_url = cover.get("image_url", "") if isinstance(cover, dict) else cover if cover else ""
|
|
|
+
|
|
|
+ interact = note.get("interact_info", {})
|
|
|
+ user = note.get("user", {})
|
|
|
+ evaluation = note.get("evaluation", {})
|
|
|
+ confidence = evaluation.get("confidence_score", 0)
|
|
|
+
|
|
|
+ # image_list 现在已经是 URL 字符串列表
|
|
|
+ images = [img if isinstance(img, str) else img.get('image_url', '') for img in image_list if img]
|
|
|
+
|
|
|
+ # 构建帖子数据用于模态框
|
|
|
+ post_data = build_post_json_data(note, evaluation)
|
|
|
+ images_json = json.dumps(images)
|
|
|
+
|
|
|
+ image_html = f'<img src="{cover_url}" class="post-image" alt="{note.get("title", "")}">' if cover_url else '<div class="no-image">无图片</div>'
|
|
|
+
|
|
|
+ type_badge = ""
|
|
|
+ if note.get("type") == "video":
|
|
|
+ type_badge = '<div class="post-type-badge">📹 视频</div>'
|
|
|
+
|
|
|
+ # 轮播箭头按钮
|
|
|
+ arrows_html = ""
|
|
|
+ if len(images) > 1:
|
|
|
+ arrows_html = '''
|
|
|
+ <button class="carousel-arrow left" onclick="event.stopPropagation()">‹</button>
|
|
|
+ <button class="carousel-arrow right" onclick="event.stopPropagation()">›</button>
|
|
|
+ '''
|
|
|
+
|
|
|
+ # 评估详情
|
|
|
+ eval_reason = evaluation.get("reason", "")
|
|
|
+ title_rel = evaluation.get("title_relevance", 0)
|
|
|
+ content_exp = evaluation.get("content_expectation", 0)
|
|
|
+
|
|
|
+ eval_details = ""
|
|
|
+
|
|
|
+ # 置信度百分比
|
|
|
+ confidence_percent = int(confidence * 100)
|
|
|
+
|
|
|
+ satisfied_cards += f"""
|
|
|
+ <div class="post-card" onclick='openModal({post_data})' data-images='{images_json}'>
|
|
|
+ <div class="post-image-wrapper">
|
|
|
+ {image_html}
|
|
|
+ {type_badge}
|
|
|
+ {arrows_html}
|
|
|
+ </div>
|
|
|
+ <div class="post-info">
|
|
|
+ <div class="post-title">{note.get('title', '无标题')}</div>
|
|
|
+ <div class="post-desc">{note.get('desc', '')}</div>
|
|
|
+ <div class="post-meta">
|
|
|
+ <div class="post-meta-item">❤️ {interact.get('liked_count', 0)}</div>
|
|
|
+ <div class="post-meta-item">⭐ {interact.get('collected_count', 0)}</div>
|
|
|
+ <div class="post-meta-item">💬 {interact.get('comment_count', 0)}</div>
|
|
|
+ </div>
|
|
|
+ <div class="post-author">👤 {user.get('nickname', '未知')}</div>
|
|
|
+ <div class="post-id">{note.get('note_id', '')}</div>
|
|
|
+ </div>
|
|
|
+ <div class="confidence-bar">
|
|
|
+ <div class="confidence-bar-fill {get_confidence_class(confidence)}" style="width: {confidence_percent}%">
|
|
|
+ <span class="confidence-bar-text">置信度: {confidence:.2f}</span>
|
|
|
+ </div>
|
|
|
+ {eval_details}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ """
|
|
|
+
|
|
|
+ # 渲染不满足需求的帖子
|
|
|
+ unsatisfied_cards = ""
|
|
|
+ for note in unsatisfied_notes:
|
|
|
+ # 获取封面图
|
|
|
+ image_list = note.get('image_list', [])
|
|
|
+ if image_list:
|
|
|
+ cover_url = image_list[0] if isinstance(image_list[0], str) else image_list[0].get('image_url', '')
|
|
|
+ else:
|
|
|
+ cover = note.get("cover_image", {})
|
|
|
+ cover_url = cover.get("image_url", "") if isinstance(cover, dict) else cover if cover else ""
|
|
|
+
|
|
|
+ interact = note.get("interact_info", {})
|
|
|
+ user = note.get("user", {})
|
|
|
+ evaluation = note.get("evaluation", {})
|
|
|
+ confidence = evaluation.get("confidence_score", 0)
|
|
|
+
|
|
|
+ # image_list 现在已经是 URL 字符串列表
|
|
|
+ images = [img if isinstance(img, str) else img.get('image_url', '') for img in image_list if img]
|
|
|
+
|
|
|
+ post_data = build_post_json_data(note, evaluation)
|
|
|
+ images_json = json.dumps(images)
|
|
|
+
|
|
|
+ image_html = f'<img src="{cover_url}" class="post-image" alt="{note.get("title", "")}">' if cover_url else '<div class="no-image">无图片</div>'
|
|
|
+
|
|
|
+ type_badge = ""
|
|
|
+ if note.get("type") == "video":
|
|
|
+ type_badge = '<div class="post-type-badge">📹 视频</div>'
|
|
|
+
|
|
|
+ arrows_html = ""
|
|
|
+ if len(images) > 1:
|
|
|
+ arrows_html = '''
|
|
|
+ <button class="carousel-arrow left" onclick="event.stopPropagation()">‹</button>
|
|
|
+ <button class="carousel-arrow right" onclick="event.stopPropagation()">›</button>
|
|
|
+ '''
|
|
|
+
|
|
|
+ eval_reason = evaluation.get("reason", "")
|
|
|
+ title_rel = evaluation.get("title_relevance", 0)
|
|
|
+ content_exp = evaluation.get("content_expectation", 0)
|
|
|
+
|
|
|
+ eval_details = ""
|
|
|
+
|
|
|
+ confidence_percent = int(confidence * 100)
|
|
|
+
|
|
|
+ unsatisfied_cards += f"""
|
|
|
+ <div class="post-card" onclick='openModal({post_data})' data-images='{images_json}'>
|
|
|
+ <div class="post-image-wrapper">
|
|
|
+ {image_html}
|
|
|
+ {type_badge}
|
|
|
+ {arrows_html}
|
|
|
+ </div>
|
|
|
+ <div class="post-info">
|
|
|
+ <div class="post-title">{note.get('title', '无标题')}</div>
|
|
|
+ <div class="post-desc">{note.get('desc', '')}</div>
|
|
|
+ <div class="post-meta">
|
|
|
+ <div class="post-meta-item">❤️ {interact.get('liked_count', 0)}</div>
|
|
|
+ <div class="post-meta-item">⭐ {interact.get('collected_count', 0)}</div>
|
|
|
+ <div class="post-meta-item">💬 {interact.get('comment_count', 0)}</div>
|
|
|
+ </div>
|
|
|
+ <div class="post-author">👤 {user.get('nickname', '未知')}</div>
|
|
|
+ <div class="post-id">{note.get('note_id', '')}</div>
|
|
|
+ </div>
|
|
|
+ <div class="confidence-bar">
|
|
|
+ <div class="confidence-bar-fill {get_confidence_class(confidence)}" style="width: {confidence_percent}%">
|
|
|
+ <span class="confidence-bar-text">置信度: {confidence:.2f}</span>
|
|
|
+ </div>
|
|
|
+ {eval_details}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ """
|
|
|
+
|
|
|
+ # 构建该query的评估结果,使用嵌套可折叠区域
|
|
|
+ query_sections = ""
|
|
|
+ if satisfied_cards:
|
|
|
+ query_sections += make_collapsible(
|
|
|
+ f"✅ 满足需求 ({len(satisfied_notes)} 个帖子)",
|
|
|
+ f'<div class="posts-grid">{satisfied_cards}</div>',
|
|
|
+ collapsed=True,
|
|
|
+ section_id=f"step{step_num}-eval-{idx}-satisfied"
|
|
|
+ )
|
|
|
+ if unsatisfied_cards:
|
|
|
+ query_sections += make_collapsible(
|
|
|
+ f"❌ 不满足需求 ({len(unsatisfied_notes)} 个帖子)",
|
|
|
+ f'<div class="posts-grid">{unsatisfied_cards}</div>',
|
|
|
+ collapsed=True,
|
|
|
+ section_id=f"step{step_num}-eval-{idx}-unsatisfied"
|
|
|
+ )
|
|
|
+
|
|
|
+ if query_sections:
|
|
|
+ # 使用可折叠区域包装每个query的评估结果
|
|
|
+ evals_html += make_collapsible(
|
|
|
+ f"📊 {query} ({satisfied_count}/{len(evaluated_notes)} 个满足需求)",
|
|
|
+ query_sections,
|
|
|
+ collapsed=True,
|
|
|
+ section_id=f"step{step_num}-eval-{idx}"
|
|
|
+ )
|
|
|
+
|
|
|
+ html = f"""
|
|
|
+ <div class="step-section">
|
|
|
+ <div class="step-header">
|
|
|
+ <div class="step-title">步骤 {step['step_number']}: 帖子评估结果</div>
|
|
|
+ <div class="step-type">{step['step_type']}</div>
|
|
|
+ </div>
|
|
|
+ <div class="step-content">
|
|
|
+ <div class="info-grid">
|
|
|
+ <div class="info-item">
|
|
|
+ <div class="info-label">评估的query数</div>
|
|
|
+ <div class="info-value">{data.get('query_count', 0)}</div>
|
|
|
+ </div>
|
|
|
+ <div class="info-item">
|
|
|
+ <div class="info-label">总帖子数</div>
|
|
|
+ <div class="info-value">{data.get('total_notes', 0)}</div>
|
|
|
+ </div>
|
|
|
+ <div class="info-item">
|
|
|
+ <div class="info-label">满足需求的帖子</div>
|
|
|
+ <div class="info-value">{total_satisfied}</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ {evals_html}
|
|
|
+ </div>
|
|
|
+ <div class="timestamp">⏰ {step.get('timestamp', '')}</div>
|
|
|
+ </div>
|
|
|
+ """
|
|
|
+ return html
|
|
|
+
|
|
|
+
|
|
|
+def render_answer_generation(step):
|
|
|
+ """渲染答案生成步骤"""
|
|
|
+ data = step.get("data", {})
|
|
|
+ result = data.get("result", {})
|
|
|
+ answer = result.get("answer", "")
|
|
|
+ confidence = result.get("confidence", 0)
|
|
|
+ summary = result.get("summary", "")
|
|
|
+ cited_notes = result.get("cited_notes", [])
|
|
|
+
|
|
|
+ # 渲染引用的帖子
|
|
|
+ cited_html = ""
|
|
|
+ for note in cited_notes:
|
|
|
+ # 获取封面图
|
|
|
+ image_list = note.get('image_list', [])
|
|
|
+ if image_list:
|
|
|
+ cover_url = image_list[0] if isinstance(image_list[0], str) else image_list[0].get('image_url', '')
|
|
|
+ else:
|
|
|
+ cover = note.get("cover_image", {})
|
|
|
+ cover_url = cover.get("image_url", "") if isinstance(cover, dict) else cover if cover else ""
|
|
|
+
|
|
|
+ interact = note.get("interact_info", {})
|
|
|
+ user = note.get("user", {})
|
|
|
+
|
|
|
+ # image_list 现在已经是 URL 字符串列表
|
|
|
+ images = [img if isinstance(img, str) else img.get('image_url', '') for img in image_list if img]
|
|
|
+
|
|
|
+ # 构建帖子数据用于模态框(包含评估信息)
|
|
|
+ eval_data = {
|
|
|
+ 'reason': note.get("reason", ""),
|
|
|
+ 'title_relevance': note.get("title_relevance", 0),
|
|
|
+ 'content_expectation': note.get("content_expectation", 0),
|
|
|
+ 'confidence_score': note.get('confidence_score', 0)
|
|
|
+ }
|
|
|
+ post_data = build_post_json_data(note, eval_data)
|
|
|
+ images_json = json.dumps(images)
|
|
|
+
|
|
|
+ image_html = f'<img src="{cover_url}" class="post-image" alt="{note.get("title", "")}">' if cover_url else '<div class="no-image">无图片</div>'
|
|
|
+
|
|
|
+ # 类型标识
|
|
|
+ type_badge = ""
|
|
|
+ if note.get("type") == "video":
|
|
|
+ type_badge = '<div class="post-type-badge">📹 视频</div>'
|
|
|
+
|
|
|
+ # 轮播箭头按钮
|
|
|
+ arrows_html = ""
|
|
|
+ if len(images) > 1:
|
|
|
+ arrows_html = '''
|
|
|
+ <button class="carousel-arrow left" onclick="event.stopPropagation()">‹</button>
|
|
|
+ <button class="carousel-arrow right" onclick="event.stopPropagation()">›</button>
|
|
|
+ '''
|
|
|
+
|
|
|
+ # 评估详情
|
|
|
+ eval_reason = note.get("reason", "")
|
|
|
+ title_rel = note.get("title_relevance", 0)
|
|
|
+ content_exp = note.get("content_expectation", 0)
|
|
|
+
|
|
|
+ eval_details = ""
|
|
|
+
|
|
|
+ # 置信度百分比
|
|
|
+ note_confidence = note.get('confidence_score', 0)
|
|
|
+ confidence_percent = int(note_confidence * 100)
|
|
|
+
|
|
|
+ cited_html += f"""
|
|
|
+ <div class="post-card" onclick='openModal({post_data})' data-images='{images_json}'>
|
|
|
+ <div class="post-image-wrapper">
|
|
|
+ {image_html}
|
|
|
+ {type_badge}
|
|
|
+ {arrows_html}
|
|
|
+ </div>
|
|
|
+ <div class="post-info">
|
|
|
+ <div class="post-title">[{note.get('index')}] {note.get('title', '无标题')}</div>
|
|
|
+ <div class="post-desc">{note.get('desc', '')}</div>
|
|
|
+ <div class="post-meta">
|
|
|
+ <div class="post-meta-item">❤️ {interact.get('liked_count', 0)}</div>
|
|
|
+ <div class="post-meta-item">⭐ {interact.get('collected_count', 0)}</div>
|
|
|
+ <div class="post-meta-item">💬 {interact.get('comment_count', 0)}</div>
|
|
|
+ </div>
|
|
|
+ <div class="post-author">👤 {user.get('nickname', '未知')}</div>
|
|
|
+ <div class="post-id">{note.get('note_id', '')}</div>
|
|
|
+ </div>
|
|
|
+ <div class="confidence-bar">
|
|
|
+ <div class="confidence-bar-fill {get_confidence_class(note_confidence)}" style="width: {confidence_percent}%">
|
|
|
+ <span class="confidence-bar-text">置信度: {note_confidence:.2f}</span>
|
|
|
+ </div>
|
|
|
+ {eval_details}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ """
|
|
|
+
|
|
|
+ # 使用可折叠区域包装引用的帖子
|
|
|
+ step_num = step['step_number']
|
|
|
+ cited_section = ""
|
|
|
+ if cited_html:
|
|
|
+ cited_section = make_collapsible(
|
|
|
+ f"📌 引用的帖子 ({len(cited_notes)} 个)",
|
|
|
+ f'<div class="posts-grid">{cited_html}</div>',
|
|
|
+ collapsed=True,
|
|
|
+ section_id=f"step{step_num}-cited"
|
|
|
+ )
|
|
|
+
|
|
|
+ html = f"""
|
|
|
+ <div class="step-section">
|
|
|
+ <div class="step-header">
|
|
|
+ <div class="step-title">步骤 {step['step_number']}: 生成答案</div>
|
|
|
+ <div class="step-type">{step['step_type']}</div>
|
|
|
+ </div>
|
|
|
+ <div class="step-content">
|
|
|
+ <div class="answer-box">
|
|
|
+ <div class="answer-header">📝 生成的答案</div>
|
|
|
+ <div class="answer-content">{answer}</div>
|
|
|
+ <div class="answer-meta">
|
|
|
+ <div><strong>置信度:</strong> {confidence:.2f}</div>
|
|
|
+ <div><strong>引用帖子:</strong> {len(cited_notes)} 个</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ {f'<p style="margin-top: 15px; color: #666;"><strong>摘要:</strong> {summary}</p>' if summary else ''}
|
|
|
+ {cited_section}
|
|
|
+ </div>
|
|
|
+ <div class="timestamp">⏰ {step.get('timestamp', '')}</div>
|
|
|
+ </div>
|
|
|
+ """
|
|
|
+ return html
|
|
|
+
|
|
|
+
|
|
|
+def render_final_result(step):
|
|
|
+ """渲染最终结果步骤"""
|
|
|
+ data = step.get("data", {})
|
|
|
+ success = data.get("success", False)
|
|
|
+ message = data.get("message", "")
|
|
|
+ satisfied_notes_count = data.get("satisfied_notes_count", 0)
|
|
|
+
|
|
|
+ status_color = "#10b981" if success else "#ef4444"
|
|
|
+ status_text = "✅ 成功" if success else "❌ 失败"
|
|
|
+
|
|
|
+ html = f"""
|
|
|
+ <div class="step-section" style="border: 3px solid {status_color};">
|
|
|
+ <div class="step-header">
|
|
|
+ <div class="step-title">步骤 {step['step_number']}: {step['step_name']}</div>
|
|
|
+ <div class="step-type">{step['step_type']}</div>
|
|
|
+ </div>
|
|
|
+ <div class="step-content">
|
|
|
+ <div class="info-grid">
|
|
|
+ <div class="info-item" style="background: {status_color}20;">
|
|
|
+ <div class="info-label">状态</div>
|
|
|
+ <div class="info-value" style="color: {status_color};">{status_text}</div>
|
|
|
+ </div>
|
|
|
+ <div class="info-item">
|
|
|
+ <div class="info-label">满足需求的帖子</div>
|
|
|
+ <div class="info-value">{satisfied_notes_count}</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <p style="margin-top: 20px; font-size: 15px; color: #666;">{message}</p>
|
|
|
+ </div>
|
|
|
+ <div class="timestamp">⏰ {step.get('timestamp', '')}</div>
|
|
|
+ </div>
|
|
|
+ """
|
|
|
+ return html
|
|
|
+
|
|
|
+
|
|
|
+def render_query_suggestion_evaluation(step):
|
|
|
+ """渲染候选query推荐词评估步骤"""
|
|
|
+ data = step.get("data", {})
|
|
|
+ candidate_count = data.get("candidate_count", 0)
|
|
|
+ results = data.get("results", [])
|
|
|
+
|
|
|
+ results_html = ""
|
|
|
+ step_num = step['step_number']
|
|
|
+ for idx, result in enumerate(results):
|
|
|
+ candidate = result.get("candidate", "")
|
|
|
+ suggestions = result.get("suggestions", [])
|
|
|
+ evaluations = result.get("evaluations", [])
|
|
|
+
|
|
|
+ # 渲染每个候选词的推荐词评估
|
|
|
+ eval_cards = ""
|
|
|
+ for evaluation in evaluations:
|
|
|
+ query = evaluation.get("query", "")
|
|
|
+ intent_match = evaluation.get("intent_match", False)
|
|
|
+ relevance_score = evaluation.get("relevance_score", 0)
|
|
|
+ reason = evaluation.get("reason", "")
|
|
|
+
|
|
|
+ intent_badge = "✅ 意图匹配" if intent_match else "❌ 意图不匹配"
|
|
|
+ intent_class = "confidence-high" if intent_match else "confidence-low"
|
|
|
+
|
|
|
+ eval_cards += f"""
|
|
|
+ <div class="query-item" style="margin: 10px 0; padding: 15px; background: white; border: 1px solid #e5e7eb; border-radius: 8px;">
|
|
|
+ <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
|
|
|
+ <div class="query-text" style="flex: 1;">{query}</div>
|
|
|
+ <div style="display: flex; gap: 10px; align-items: center;">
|
|
|
+ <span class="confidence-badge {intent_class}" style="margin: 0;">{intent_badge}</span>
|
|
|
+ <span class="confidence-badge confidence-medium" style="margin: 0;">相关性: {relevance_score:.2f}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div style="color: #666; font-size: 13px; line-height: 1.6; background: #f8f9fa; padding: 10px; border-radius: 4px;">
|
|
|
+ {reason}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ """
|
|
|
+
|
|
|
+ if eval_cards:
|
|
|
+ # 使用可折叠区域包装每个候选词的推荐词列表,添加唯一ID
|
|
|
+ results_html += make_collapsible(
|
|
|
+ f"候选词: {candidate} ({len(evaluations)} 个推荐词)",
|
|
|
+ eval_cards,
|
|
|
+ collapsed=True,
|
|
|
+ section_id=f"step{step_num}-candidate-{idx}"
|
|
|
+ )
|
|
|
+
|
|
|
+ html = f"""
|
|
|
+ <div class="step-section">
|
|
|
+ <div class="step-header">
|
|
|
+ <div class="step-title">步骤 {step['step_number']}: {step['step_name']}</div>
|
|
|
+ <div class="step-type">{step['step_type']}</div>
|
|
|
+ </div>
|
|
|
+ <div class="step-content">
|
|
|
+ <div class="info-grid">
|
|
|
+ <div class="info-item">
|
|
|
+ <div class="info-label">候选query数</div>
|
|
|
+ <div class="info-value">{candidate_count}</div>
|
|
|
+ </div>
|
|
|
+ <div class="info-item">
|
|
|
+ <div class="info-label">总推荐词数</div>
|
|
|
+ <div class="info-value">{sum(len(r.get('evaluations', [])) for r in results)}</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ {results_html}
|
|
|
+ </div>
|
|
|
+ <div class="timestamp">⏰ {step.get('timestamp', '')}</div>
|
|
|
+ </div>
|
|
|
+ """
|
|
|
+ return html
|
|
|
+
|
|
|
+
|
|
|
+def render_filter_qualified_queries(step):
|
|
|
+ """渲染筛选合格推荐词步骤"""
|
|
|
+ data = step.get("data", {})
|
|
|
+ input_count = data.get("input_evaluation_count", 0)
|
|
|
+ qualified_count = data.get("qualified_count", 0)
|
|
|
+ min_relevance = data.get("min_relevance_score", 0.7)
|
|
|
+ all_queries = data.get("all_queries", [])
|
|
|
+
|
|
|
+ # 如果没有all_queries,使用旧的qualified_queries
|
|
|
+ if not all_queries:
|
|
|
+ all_queries = data.get("qualified_queries", [])
|
|
|
+
|
|
|
+ # 分离合格和不合格的查询
|
|
|
+ qualified_html = ""
|
|
|
+ unqualified_html = ""
|
|
|
+
|
|
|
+ for item in all_queries:
|
|
|
+ query = item.get("query", "")
|
|
|
+ from_candidate = item.get("from_candidate", "")
|
|
|
+ intent_match = item.get("intent_match", False)
|
|
|
+ relevance_score = item.get("relevance_score", 0)
|
|
|
+ reason = item.get("reason", "")
|
|
|
+ is_qualified = item.get("is_qualified", True) # 默认为True以兼容旧数据
|
|
|
+
|
|
|
+ intent_badge = "✅ 意图匹配" if intent_match else "❌ 意图不匹配"
|
|
|
+ intent_class = "confidence-high" if intent_match else "confidence-low"
|
|
|
+
|
|
|
+ # 根据相关性分数确定badge颜色
|
|
|
+ if relevance_score >= 0.8:
|
|
|
+ score_class = "confidence-high"
|
|
|
+ elif relevance_score >= 0.6:
|
|
|
+ score_class = "confidence-medium"
|
|
|
+ else:
|
|
|
+ score_class = "confidence-low"
|
|
|
+
|
|
|
+ # 确定边框颜色和背景色
|
|
|
+ if is_qualified:
|
|
|
+ border_color = "#10b981"
|
|
|
+ bg_color = "#f0fdf4"
|
|
|
+ border_left_color = "#10b981"
|
|
|
+ else:
|
|
|
+ border_color = "#e5e7eb"
|
|
|
+ bg_color = "#f9fafb"
|
|
|
+ border_left_color = "#9ca3af"
|
|
|
+
|
|
|
+ query_html = f"""
|
|
|
+ <div class="query-item" style="margin: 15px 0; padding: 15px; background: white; border: 2px solid {border_color}; border-radius: 8px;">
|
|
|
+ <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
|
|
|
+ <div style="flex: 1;">
|
|
|
+ <div class="query-text">{query}</div>
|
|
|
+ <div style="color: #9ca3af; font-size: 12px; margin-top: 5px;">来自候选词: {from_candidate}</div>
|
|
|
+ </div>
|
|
|
+ <div style="display: flex; gap: 10px; align-items: center;">
|
|
|
+ <span class="confidence-badge {intent_class}" style="margin: 0;">{intent_badge}</span>
|
|
|
+ <span class="confidence-badge {score_class}" style="margin: 0;">相关性: {relevance_score:.2f}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div style="color: #666; font-size: 13px; line-height: 1.6; background: {bg_color}; padding: 10px; border-radius: 4px; border-left: 3px solid {border_left_color};">
|
|
|
+ {reason}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ """
|
|
|
+
|
|
|
+ if is_qualified:
|
|
|
+ qualified_html += query_html
|
|
|
+ else:
|
|
|
+ unqualified_html += query_html
|
|
|
+
|
|
|
+ # 构建HTML - 使用可折叠区域
|
|
|
+ step_num = step['step_number']
|
|
|
+ qualified_section = make_collapsible(
|
|
|
+ f"✅ 合格的推荐词 ({qualified_count})",
|
|
|
+ qualified_html,
|
|
|
+ collapsed=True,
|
|
|
+ section_id=f"step{step_num}-qualified"
|
|
|
+ ) if qualified_html else ''
|
|
|
+
|
|
|
+ unqualified_section = make_collapsible(
|
|
|
+ f"❌ 不合格的推荐词 ({input_count - qualified_count})",
|
|
|
+ unqualified_html,
|
|
|
+ collapsed=True,
|
|
|
+ section_id=f"step{step_num}-unqualified"
|
|
|
+ ) if unqualified_html else ''
|
|
|
+
|
|
|
+ html = f"""
|
|
|
+ <div class="step-section">
|
|
|
+ <div class="step-header">
|
|
|
+ <div class="step-title">步骤 {step['step_number']}: {step['step_name']}</div>
|
|
|
+ <div class="step-type">{step['step_type']}</div>
|
|
|
+ </div>
|
|
|
+ <div class="step-content">
|
|
|
+ <div class="info-grid">
|
|
|
+ <div class="info-item">
|
|
|
+ <div class="info-label">输入推荐词数</div>
|
|
|
+ <div class="info-value">{input_count}</div>
|
|
|
+ </div>
|
|
|
+ <div class="info-item">
|
|
|
+ <div class="info-label">合格推荐词数</div>
|
|
|
+ <div class="info-value">{qualified_count}</div>
|
|
|
+ </div>
|
|
|
+ <div class="info-item">
|
|
|
+ <div class="info-label">最低相关性</div>
|
|
|
+ <div class="info-value">{min_relevance:.2f}</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ {qualified_section}
|
|
|
+ {unqualified_section}
|
|
|
+ </div>
|
|
|
+ <div class="timestamp">⏰ {step.get('timestamp', '')}</div>
|
|
|
+ </div>
|
|
|
+ """
|
|
|
+ return html
|
|
|
+
|
|
|
+
|
|
|
+def render_generic_step(step):
|
|
|
+ """通用步骤渲染"""
|
|
|
+ data = step.get("data", {})
|
|
|
+
|
|
|
+ # 提取数据的简单展示
|
|
|
+ data_html = ""
|
|
|
+ if data:
|
|
|
+ data_html = "<div class='step-content'><pre style='background: #f8f9fa; padding: 15px; border-radius: 4px; overflow-x: auto; font-size: 12px;'>"
|
|
|
+ import json
|
|
|
+ data_html += json.dumps(data, ensure_ascii=False, indent=2)[:500] # 限制长度
|
|
|
+ if len(json.dumps(data)) > 500:
|
|
|
+ data_html += "\n..."
|
|
|
+ data_html += "</pre></div>"
|
|
|
+
|
|
|
+ return f"""
|
|
|
+ <div class="step-section">
|
|
|
+ <div class="step-header">
|
|
|
+ <div class="step-title">步骤 {step['step_number']}: {step['step_name']}</div>
|
|
|
+ <div class="step-type">{step['step_type']}</div>
|
|
|
+ </div>
|
|
|
+ {data_html}
|
|
|
+ <div class="timestamp">⏰ {step.get('timestamp', '')}</div>
|
|
|
+ </div>
|
|
|
+ """
|
|
|
+
|
|
|
+
|
|
|
+def render_step(step):
|
|
|
+ """根据步骤类型渲染对应的HTML"""
|
|
|
+ step_type = step.get("step_type", "")
|
|
|
+
|
|
|
+ renderers = {
|
|
|
+ "keyword_extraction": render_keyword_extraction,
|
|
|
+ "level_exploration": render_level_exploration,
|
|
|
+ "level_analysis": render_level_analysis,
|
|
|
+ "query_suggestion_evaluation": render_query_suggestion_evaluation,
|
|
|
+ "filter_qualified_queries": render_filter_qualified_queries,
|
|
|
+ "search_qualified_queries": render_search_results,
|
|
|
+ "evaluate_search_notes": render_note_evaluations,
|
|
|
+ "answer_generation": render_answer_generation,
|
|
|
+ "final_result": render_final_result,
|
|
|
+ }
|
|
|
+
|
|
|
+ renderer = renderers.get(step_type)
|
|
|
+ if renderer:
|
|
|
+ return renderer(step)
|
|
|
+ else:
|
|
|
+ # 使用通用渲染显示数据
|
|
|
+ return render_generic_step(step)
|
|
|
+
|
|
|
+
|
|
|
+def generate_html(steps_json_path, output_path=None):
|
|
|
+ """生成HTML可视化文件"""
|
|
|
+ # 读取 steps.json
|
|
|
+ with open(steps_json_path, 'r', encoding='utf-8') as f:
|
|
|
+ steps_data = json.load(f)
|
|
|
+
|
|
|
+ # 生成内容
|
|
|
+ content_parts = [render_header(steps_data)]
|
|
|
+
|
|
|
+ for step in steps_data:
|
|
|
+ content_parts.append(render_step(step))
|
|
|
+
|
|
|
+ content = "\n".join(content_parts)
|
|
|
+
|
|
|
+ # 生成最终HTML(使用replace而不是format来避免CSS中的花括号问题)
|
|
|
+ html = HTML_TEMPLATE.replace("{content}", content)
|
|
|
+
|
|
|
+ # 确定输出路径
|
|
|
+ if output_path is None:
|
|
|
+ steps_path = Path(steps_json_path)
|
|
|
+ output_path = steps_path.parent / "steps_visualization.html"
|
|
|
+
|
|
|
+ # 写入文件
|
|
|
+ with open(output_path, 'w', encoding='utf-8') as f:
|
|
|
+ f.write(html)
|
|
|
+
|
|
|
+ return output_path
|
|
|
+
|
|
|
+
|
|
|
+def main():
|
|
|
+ parser = argparse.ArgumentParser(description="Steps 可视化工具")
|
|
|
+ parser.add_argument("steps_json", type=str, help="steps.json 文件路径")
|
|
|
+ parser.add_argument("-o", "--output", type=str, help="输出HTML文件路径(可选)")
|
|
|
+
|
|
|
+ args = parser.parse_args()
|
|
|
+
|
|
|
+ # 生成可视化
|
|
|
+ output_path = generate_html(args.steps_json, args.output)
|
|
|
+
|
|
|
+ print(f"✅ 可视化生成成功!")
|
|
|
+ print(f"📄 输出文件: {output_path}")
|
|
|
+ output_abs = Path(output_path).absolute() if isinstance(output_path, str) else output_path.absolute()
|
|
|
+ print(f"\n💡 在浏览器中打开查看: file://{output_abs}")
|
|
|
+
|
|
|
+
|
|
|
+if __name__ == "__main__":
|
|
|
+ main()
|