|
|
@@ -0,0 +1,1049 @@
|
|
|
+"""
|
|
|
+Report Exporter component for UI.
|
|
|
+
|
|
|
+Implements Story 7.17: Report export functionality with
|
|
|
+HTML and PDF formats, statistics, and custom templates.
|
|
|
+"""
|
|
|
+
|
|
|
+from datetime import datetime, timedelta
|
|
|
+from pathlib import Path
|
|
|
+from typing import List, Dict, Optional, Any
|
|
|
+from dataclasses import dataclass, field
|
|
|
+from enum import Enum
|
|
|
+import json
|
|
|
+
|
|
|
+from PyQt6.QtWidgets import (
|
|
|
+ QWidget,
|
|
|
+ QVBoxLayout,
|
|
|
+ QHBoxLayout,
|
|
|
+ QPushButton,
|
|
|
+ QLabel,
|
|
|
+ QComboBox,
|
|
|
+ QCheckBox,
|
|
|
+ QFileDialog,
|
|
|
+ QMessageBox,
|
|
|
+ QGroupBox,
|
|
|
+ QFormLayout,
|
|
|
+ QLineEdit,
|
|
|
+ QTextEdit,
|
|
|
+ QProgressBar,
|
|
|
+ QDialog,
|
|
|
+ QDialogButtonBox,
|
|
|
+)
|
|
|
+from PyQt6.QtCore import Qt, pyqtSignal, QObject, QThread, QSettings
|
|
|
+from PyQt6.QtGui import QFont, QPageLayout,PageSize
|
|
|
+
|
|
|
+
|
|
|
+class ReportFormat(Enum):
|
|
|
+ """Report export formats."""
|
|
|
+ HTML = "html"
|
|
|
+ PDF = "pdf"
|
|
|
+ JSON = "json"
|
|
|
+
|
|
|
+
|
|
|
+class ReportSection(Enum):
|
|
|
+ """Report sections that can be included."""
|
|
|
+ SUMMARY = "summary"
|
|
|
+ STATISTICS = "statistics"
|
|
|
+ PROGRESS = "progress"
|
|
|
+ ERRORS = "errors"
|
|
|
+ GLOSSARY = "glossary"
|
|
|
+ TIMELINE = "timeline"
|
|
|
+
|
|
|
+
|
|
|
+@dataclass
|
|
|
+class TranslationStatistics:
|
|
|
+ """Statistics for a translation session."""
|
|
|
+
|
|
|
+ # File statistics
|
|
|
+ total_files: int = 0
|
|
|
+ completed_files: int = 0
|
|
|
+ failed_files: int = 0
|
|
|
+ pending_files: int = 0
|
|
|
+
|
|
|
+ # Chapter statistics
|
|
|
+ total_chapters: int = 0
|
|
|
+ completed_chapters: int = 0
|
|
|
+ failed_chapters: int = 0
|
|
|
+ pending_chapters: int = 0
|
|
|
+
|
|
|
+ # Word statistics
|
|
|
+ total_words: int = 0
|
|
|
+ translated_words: int = 0
|
|
|
+ remaining_words: int = 0
|
|
|
+
|
|
|
+ # Time statistics
|
|
|
+ elapsed_time_seconds: float = 0.0
|
|
|
+ estimated_remaining_seconds: float = 0.0
|
|
|
+
|
|
|
+ # Speed statistics
|
|
|
+ words_per_minute: float = 0.0
|
|
|
+ chapters_per_hour: float = 0.0
|
|
|
+
|
|
|
+ # Error statistics
|
|
|
+ total_errors: int = 0
|
|
|
+ error_types: Dict[str, int] = field(default_factory=dict)
|
|
|
+
|
|
|
+ # Glossary statistics
|
|
|
+ glossary_terms_used: int = 0
|
|
|
+ glossary_hit_count: int = 0
|
|
|
+
|
|
|
+ @property
|
|
|
+ def completion_percentage(self) -> float:
|
|
|
+ """Calculate completion percentage."""
|
|
|
+ if self.total_words == 0:
|
|
|
+ return 0.0
|
|
|
+ return (self.translated_words / self.total_words) * 100
|
|
|
+
|
|
|
+ @property
|
|
|
+ def formatted_elapsed_time(self) -> str:
|
|
|
+ """Format elapsed time as readable string."""
|
|
|
+ hours = int(self.elapsed_time_seconds // 3600)
|
|
|
+ minutes = int((self.elapsed_time_seconds % 3600) // 60)
|
|
|
+ seconds = int(self.elapsed_time_seconds % 60)
|
|
|
+
|
|
|
+ parts = []
|
|
|
+ if hours > 0:
|
|
|
+ parts.append(f"{hours}小时")
|
|
|
+ if minutes > 0:
|
|
|
+ parts.append(f"{minutes}分钟")
|
|
|
+ if seconds > 0 or not parts:
|
|
|
+ parts.append(f"{seconds}秒")
|
|
|
+
|
|
|
+ return " ".join(parts)
|
|
|
+
|
|
|
+ @property
|
|
|
+ def formatted_eta(self) -> str:
|
|
|
+ """Format estimated time remaining."""
|
|
|
+ if self.estimated_remaining_seconds < 0:
|
|
|
+ return "计算中..."
|
|
|
+
|
|
|
+ hours = int(self.estimated_remaining_seconds // 3600)
|
|
|
+ minutes = int((self.estimated_remaining_seconds % 3600) // 60)
|
|
|
+
|
|
|
+ if hours > 0:
|
|
|
+ return f"约{hours}小时{minutes}分钟"
|
|
|
+ return f"约{minutes}分钟"
|
|
|
+
|
|
|
+
|
|
|
+@dataclass
|
|
|
+class ErrorEntry:
|
|
|
+ """An error entry for the report."""
|
|
|
+
|
|
|
+ timestamp: datetime
|
|
|
+ file: str
|
|
|
+ chapter: str
|
|
|
+ error_type: str
|
|
|
+ message: str
|
|
|
+ severity: str = "error" # error, warning, critical
|
|
|
+
|
|
|
+
|
|
|
+@dataclass
|
|
|
+class ChapterProgress:
|
|
|
+ """Progress information for a single chapter."""
|
|
|
+
|
|
|
+ file_name: str
|
|
|
+ chapter_name: str
|
|
|
+ status: str
|
|
|
+ word_count: int
|
|
|
+ translated_words: int
|
|
|
+ start_time: Optional[datetime] = None
|
|
|
+ end_time: Optional[datetime] = None
|
|
|
+
|
|
|
+ @property
|
|
|
+ def completion_percentage(self) -> float:
|
|
|
+ """Calculate chapter completion."""
|
|
|
+ if self.word_count == 0:
|
|
|
+ return 0.0
|
|
|
+ return (self.translated_words / self.word_count) * 100
|
|
|
+
|
|
|
+
|
|
|
+@dataclass
|
|
|
+class ReportData:
|
|
|
+ """Complete data for generating a report."""
|
|
|
+
|
|
|
+ # Metadata
|
|
|
+ title: str = "翻译报告"
|
|
|
+ generated_at: datetime = field(default_factory=datetime.now)
|
|
|
+ project_name: str = ""
|
|
|
+ source_language: str = "中文"
|
|
|
+ target_language: str = "英文"
|
|
|
+
|
|
|
+ # Statistics
|
|
|
+ statistics: TranslationStatistics = field(default_factory=TranslationStatistics)
|
|
|
+
|
|
|
+ # Detailed data
|
|
|
+ chapter_progress: List[ChapterProgress] = field(default_factory=list)
|
|
|
+ errors: List[ErrorEntry] = field(default_factory=list)
|
|
|
+
|
|
|
+ # Timeline data (daily translation counts)
|
|
|
+ daily_progress: Dict[str, int] = field(default_factory=dict)
|
|
|
+
|
|
|
+ # Custom notes
|
|
|
+ notes: str = ""
|
|
|
+
|
|
|
+
|
|
|
+# HTML Template for reports
|
|
|
+DEFAULT_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>{{title}}</title>
|
|
|
+ <style>
|
|
|
+ * {
|
|
|
+ margin: 0;
|
|
|
+ padding: 0;
|
|
|
+ box-sizing: border-box;
|
|
|
+ }
|
|
|
+
|
|
|
+ body {
|
|
|
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif;
|
|
|
+ line-height: 1.6;
|
|
|
+ color: #2c3e50;
|
|
|
+ background: #f8f9fa;
|
|
|
+ padding: 20px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .container {
|
|
|
+ max-width: 900px;
|
|
|
+ margin: 0 auto;
|
|
|
+ background: white;
|
|
|
+ border-radius: 8px;
|
|
|
+ box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
|
|
+ overflow: hidden;
|
|
|
+ }
|
|
|
+
|
|
|
+ .header {
|
|
|
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
|
+ color: white;
|
|
|
+ padding: 30px;
|
|
|
+ text-align: center;
|
|
|
+ }
|
|
|
+
|
|
|
+ .header h1 {
|
|
|
+ font-size: 28px;
|
|
|
+ margin-bottom: 10px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .header .meta {
|
|
|
+ opacity: 0.9;
|
|
|
+ font-size: 14px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .section {
|
|
|
+ padding: 25px 30px;
|
|
|
+ border-bottom: 1px solid #eee;
|
|
|
+ }
|
|
|
+
|
|
|
+ .section:last-child {
|
|
|
+ border-bottom: none;
|
|
|
+ }
|
|
|
+
|
|
|
+ .section-title {
|
|
|
+ font-size: 20px;
|
|
|
+ font-weight: 600;
|
|
|
+ margin-bottom: 20px;
|
|
|
+ color: #2c3e50;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ }
|
|
|
+
|
|
|
+ .section-title::before {
|
|
|
+ content: '';
|
|
|
+ width: 4px;
|
|
|
+ height: 20px;
|
|
|
+ background: #667eea;
|
|
|
+ margin-right: 10px;
|
|
|
+ border-radius: 2px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .stats-grid {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
|
|
+ gap: 15px;
|
|
|
+ margin-bottom: 20px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .stat-card {
|
|
|
+ background: #f8f9fa;
|
|
|
+ border-radius: 8px;
|
|
|
+ padding: 15px;
|
|
|
+ text-align: center;
|
|
|
+ }
|
|
|
+
|
|
|
+ .stat-value {
|
|
|
+ font-size: 28px;
|
|
|
+ font-weight: 700;
|
|
|
+ color: #667eea;
|
|
|
+ }
|
|
|
+
|
|
|
+ .stat-label {
|
|
|
+ font-size: 13px;
|
|
|
+ color: #6c757d;
|
|
|
+ margin-top: 5px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .progress-bar-container {
|
|
|
+ background: #e9ecef;
|
|
|
+ border-radius: 10px;
|
|
|
+ height: 24px;
|
|
|
+ overflow: hidden;
|
|
|
+ margin: 20px 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ .progress-bar {
|
|
|
+ height: 100%;
|
|
|
+ background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ color: white;
|
|
|
+ font-weight: 600;
|
|
|
+ font-size: 14px;
|
|
|
+ transition: width 0.3s ease;
|
|
|
+ }
|
|
|
+
|
|
|
+ table {
|
|
|
+ width: 100%;
|
|
|
+ border-collapse: collapse;
|
|
|
+ margin-top: 15px;
|
|
|
+ }
|
|
|
+
|
|
|
+ th, td {
|
|
|
+ padding: 12px;
|
|
|
+ text-align: left;
|
|
|
+ border-bottom: 1px solid #dee2e6;
|
|
|
+ }
|
|
|
+
|
|
|
+ th {
|
|
|
+ background: #f8f9fa;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #495057;
|
|
|
+ }
|
|
|
+
|
|
|
+ tr:hover {
|
|
|
+ background: #f8f9fa;
|
|
|
+ }
|
|
|
+
|
|
|
+ .status-badge {
|
|
|
+ display: inline-block;
|
|
|
+ padding: 4px 12px;
|
|
|
+ border-radius: 12px;
|
|
|
+ font-size: 12px;
|
|
|
+ font-weight: 600;
|
|
|
+ }
|
|
|
+
|
|
|
+ .status-completed {
|
|
|
+ background: #d4edda;
|
|
|
+ color: #155724;
|
|
|
+ }
|
|
|
+
|
|
|
+ .status-pending {
|
|
|
+ background: #fff3cd;
|
|
|
+ color: #856404;
|
|
|
+ }
|
|
|
+
|
|
|
+ .status-failed {
|
|
|
+ background: #f8d7da;
|
|
|
+ color: #721c24;
|
|
|
+ }
|
|
|
+
|
|
|
+ .error-entry {
|
|
|
+ padding: 15px;
|
|
|
+ background: #fff5f5;
|
|
|
+ border-left: 4px solid #e53e3e;
|
|
|
+ margin-bottom: 10px;
|
|
|
+ border-radius: 4px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .error-time {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #718096;
|
|
|
+ }
|
|
|
+
|
|
|
+ .error-message {
|
|
|
+ margin-top: 5px;
|
|
|
+ color: #e53e3e;
|
|
|
+ }
|
|
|
+
|
|
|
+ .notes-section {
|
|
|
+ background: #fffbeb;
|
|
|
+ border-left: 4px solid #f59e0b;
|
|
|
+ padding: 15px;
|
|
|
+ border-radius: 4px;
|
|
|
+ margin-top: 15px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .footer {
|
|
|
+ text-align: center;
|
|
|
+ padding: 20px;
|
|
|
+ color: #6c757d;
|
|
|
+ font-size: 13px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .chart-placeholder {
|
|
|
+ background: #f8f9fa;
|
|
|
+ border: 2px dashed #dee2e6;
|
|
|
+ border-radius: 8px;
|
|
|
+ padding: 40px;
|
|
|
+ text-align: center;
|
|
|
+ color: #6c757d;
|
|
|
+ }
|
|
|
+ </style>
|
|
|
+</head>
|
|
|
+<body>
|
|
|
+ <div class="container">
|
|
|
+ <!-- Header -->
|
|
|
+ <div class="header">
|
|
|
+ <h1>{{title}}</h1>
|
|
|
+ <div class="meta">
|
|
|
+ {{project_name}} • {{generated_at}}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- Summary Section -->
|
|
|
+ <div class="section">
|
|
|
+ <h2 class="section-title">翻译概览</h2>
|
|
|
+
|
|
|
+ <div class="stats-grid">
|
|
|
+ <div class="stat-card">
|
|
|
+ <div class="stat-value">{{completion_percentage}}%</div>
|
|
|
+ <div class="stat-label">完成进度</div>
|
|
|
+ </div>
|
|
|
+ <div class="stat-card">
|
|
|
+ <div class="stat-value">{{translated_words}}</div>
|
|
|
+ <div class="stat-label">已翻译字数</div>
|
|
|
+ </div>
|
|
|
+ <div class="stat-card">
|
|
|
+ <div class="stat-value">{{total_words}}</div>
|
|
|
+ <div class="stat-label">总字数</div>
|
|
|
+ </div>
|
|
|
+ <div class="stat-card">
|
|
|
+ <div class="stat-value">{{words_per_minute}}</div>
|
|
|
+ <div class="stat-label">字/分钟</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="progress-bar-container">
|
|
|
+ <div class="progress-bar" style="width: {{completion_percentage}}%">
|
|
|
+ {{completion_percentage}}%
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <p><strong>预计剩余时间:</strong>{{eta}}</p>
|
|
|
+ <p><strong>已用时间:</strong>{{elapsed_time}}</p>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- Statistics Section -->
|
|
|
+ <div class="section">
|
|
|
+ <h2 class="section-title">详细统计</h2>
|
|
|
+
|
|
|
+ <table>
|
|
|
+ <tr>
|
|
|
+ <th>项目</th>
|
|
|
+ <th>数值</th>
|
|
|
+ </tr>
|
|
|
+ <tr>
|
|
|
+ <td>文件总数</td>
|
|
|
+ <td>{{total_files}}</td>
|
|
|
+ </tr>
|
|
|
+ <tr>
|
|
|
+ <td>已完成文件</td>
|
|
|
+ <td>{{completed_files}}</td>
|
|
|
+ </tr>
|
|
|
+ <tr>
|
|
|
+ <td>章节总数</td>
|
|
|
+ <td>{{total_chapters}}</td>
|
|
|
+ </tr>
|
|
|
+ <tr>
|
|
|
+ <td>已完成章节</td>
|
|
|
+ <td>{{completed_chapters}}</td>
|
|
|
+ </tr>
|
|
|
+ <tr>
|
|
|
+ <td>错误数量</td>
|
|
|
+ <td>{{total_errors}}</td>
|
|
|
+ </tr>
|
|
|
+ <tr>
|
|
|
+ <td>术语命中次数</td>
|
|
|
+ <td>{{glossary_hits}}</td>
|
|
|
+ </tr>
|
|
|
+ </table>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- Progress Section -->
|
|
|
+ <div class="section">
|
|
|
+ <h2 class="section-title">章节进度</h2>
|
|
|
+
|
|
|
+ <table>
|
|
|
+ <thead>
|
|
|
+ <tr>
|
|
|
+ <th>文件</th>
|
|
|
+ <th>章节</th>
|
|
|
+ <th>进度</th>
|
|
|
+ <th>状态</th>
|
|
|
+ </tr>
|
|
|
+ </thead>
|
|
|
+ <tbody>
|
|
|
+ {{#chapter_progress}}
|
|
|
+ <tr>
|
|
|
+ <td>{{file_name}}</td>
|
|
|
+ <td>{{chapter_name}}</td>
|
|
|
+ <td>{{completion}}%</td>
|
|
|
+ <td><span class="status-badge status-{{status_class}}">{{status}}</span></td>
|
|
|
+ </tr>
|
|
|
+ {{/chapter_progress}}
|
|
|
+ </tbody>
|
|
|
+ </table>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- Errors Section -->
|
|
|
+ {{#has_errors}}
|
|
|
+ <div class="section">
|
|
|
+ <h2 class="section-title">错误摘要</h2>
|
|
|
+
|
|
|
+ {{#errors}}
|
|
|
+ <div class="error-entry">
|
|
|
+ <div class="error-time">{{timestamp}} • {{file}} - {{chapter}}</div>
|
|
|
+ <div class="error-message">[{{error_type}}] {{message}}</div>
|
|
|
+ </div>
|
|
|
+ {{/errors}}
|
|
|
+ </div>
|
|
|
+ {{/has_errors}}
|
|
|
+
|
|
|
+ <!-- Notes Section -->
|
|
|
+ {{#has_notes}}
|
|
|
+ <div class="section">
|
|
|
+ <h2 class="section-title">备注</h2>
|
|
|
+ <div class="notes-section">
|
|
|
+ {{notes}}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ {{/has_notes}}
|
|
|
+
|
|
|
+ <!-- Footer -->
|
|
|
+ <div class="footer">
|
|
|
+ 报告生成时间: {{generated_at}} • BMAD Novel Translator
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+</body>
|
|
|
+</html>
|
|
|
+"""
|
|
|
+
|
|
|
+
|
|
|
+class ReportExporter(QObject):
|
|
|
+ """
|
|
|
+ Report exporter for generating HTML and PDF reports.
|
|
|
+
|
|
|
+ Features:
|
|
|
+ - HTML report with embedded styles
|
|
|
+ - PDF report generation
|
|
|
+ - Translation statistics
|
|
|
+ - Progress tracking
|
|
|
+ - Error summaries
|
|
|
+ - Custom template support
|
|
|
+ """
|
|
|
+
|
|
|
+ # Signals
|
|
|
+ export_progress = pyqtSignal(int) # Progress percentage
|
|
|
+ export_completed = pyqtSignal(str) # File path
|
|
|
+ export_failed = pyqtSignal(str) # Error message
|
|
|
+
|
|
|
+ # Settings keys
|
|
|
+ SETTINGS_LAST_FORMAT = "export/last_format"
|
|
|
+ SETTINGS_LAST_PATH = "export/last_path"
|
|
|
+ _SETTINGS_INCLUDED_SECTIONS = "export/included_sections"
|
|
|
+
|
|
|
+ def __init__(self, parent: Optional[QObject] = None) -> None:
|
|
|
+ """Initialize the report exporter."""
|
|
|
+ super().__init__(parent)
|
|
|
+ self._settings = QSettings("BMAD", "NovelTranslator")
|
|
|
+ self._template = DEFAULT_HTML_TEMPLATE
|
|
|
+
|
|
|
+ def set_template(self, template: str) -> None:
|
|
|
+ """
|
|
|
+ Set a custom HTML template.
|
|
|
+
|
|
|
+ Args:
|
|
|
+ template: HTML template string with {{placeholders}}
|
|
|
+ """
|
|
|
+ self._template = template
|
|
|
+
|
|
|
+ def load_template_from_file(self, path: Path) -> bool:
|
|
|
+ """
|
|
|
+ Load template from file.
|
|
|
+
|
|
|
+ Args:
|
|
|
+ path: Path to template file
|
|
|
+
|
|
|
+ Returns:
|
|
|
+ True if successful
|
|
|
+ """
|
|
|
+ try:
|
|
|
+ with open(path, "r", encoding="utf-8") as f:
|
|
|
+ self._template = f.read()
|
|
|
+ return True
|
|
|
+ except Exception:
|
|
|
+ return False
|
|
|
+
|
|
|
+ def _render_template(self, data: ReportData) -> str:
|
|
|
+ """
|
|
|
+ Render the HTML template with data.
|
|
|
+
|
|
|
+ Args:
|
|
|
+ data: Report data
|
|
|
+
|
|
|
+ Returns:
|
|
|
+ Rendered HTML string
|
|
|
+ """
|
|
|
+ html = self._template
|
|
|
+
|
|
|
+ # Replace simple placeholders
|
|
|
+ replacements = {
|
|
|
+ "{{title}}": data.title,
|
|
|
+ "{{generated_at}}": data.generated_at.strftime("%Y-%m-%d %H:%M:%S"),
|
|
|
+ "{{project_name}}": data.project_name or "未命名项目",
|
|
|
+ "{{completion_percentage}}": f"{data.statistics.completion_percentage:.1f}",
|
|
|
+ "{{translated_words}}": f"{data.statistics.translated_words:,}",
|
|
|
+ "{{total_words}}": f"{data.statistics.total_words:,}",
|
|
|
+ "{{words_per_minute}}": f"{data.statistics.words_per_minute:.1f}",
|
|
|
+ "{{eta}}": data.statistics.formatted_eta,
|
|
|
+ "{{elapsed_time}}": data.statistics.formatted_elapsed_time,
|
|
|
+ "{{total_files}}": str(data.statistics.total_files),
|
|
|
+ "{{completed_files}}": str(data.statistics.completed_files),
|
|
|
+ "{{total_chapters}}": str(data.statistics.total_chapters),
|
|
|
+ "{{completed_chapters}}": str(data.statistics.completed_chapters),
|
|
|
+ "{{total_errors}}": str(data.statistics.total_errors),
|
|
|
+ "{{glossary_hits}}": str(data.statistics.glossary_hit_count),
|
|
|
+ "{{has_notes}}": "true" if data.notes else "false",
|
|
|
+ "{{has_errors}}": "true" if data.errors else "false",
|
|
|
+ "{{notes}}": data.notes,
|
|
|
+ }
|
|
|
+
|
|
|
+ for key, value in replacements.items():
|
|
|
+ html = html.replace(key, value)
|
|
|
+
|
|
|
+ # Render chapter progress rows
|
|
|
+ if "{{#chapter_progress}}" in html:
|
|
|
+ rows = []
|
|
|
+ for chapter in data.chapter_progress[:20]: # Limit to 20 chapters
|
|
|
+ status_class = {
|
|
|
+ "completed": "completed",
|
|
|
+ "pending": "pending",
|
|
|
+ "failed": "failed",
|
|
|
+ }.get(chapter.status.lower(), "pending")
|
|
|
+
|
|
|
+ row = f"""
|
|
|
+ <tr>
|
|
|
+ <td>{chapter.file_name}</td>
|
|
|
+ <td>{chapter.chapter_name}</td>
|
|
|
+ <td>{chapter.completion_percentage:.1f}%</td>
|
|
|
+ <td><span class="status-badge status-{status_class}">{chapter.status}</span></td>
|
|
|
+ </tr>
|
|
|
+ """
|
|
|
+ rows.append(row)
|
|
|
+
|
|
|
+ # Replace the entire section
|
|
|
+ import re
|
|
|
+ pattern = r'{{#chapter_progress}}.*?{{/chapter_progress}}'
|
|
|
+ html = re.sub(pattern, "".join(rows), html, flags=re.DOTALL)
|
|
|
+
|
|
|
+ # Render errors
|
|
|
+ if "{{#errors}}" in html and data.errors:
|
|
|
+ error_divs = []
|
|
|
+ for error in data.errors[:50]: # Limit to 50 errors
|
|
|
+ div = f"""
|
|
|
+ <div class="error-entry">
|
|
|
+ <div class="error-time">{error.timestamp.strftime("%H:%M:%S")} • {error.file} - {error.chapter}</div>
|
|
|
+ <div class="error-message">[{error.error_type}] {error.message}</div>
|
|
|
+ </div>
|
|
|
+ """
|
|
|
+ error_divs.append(div)
|
|
|
+
|
|
|
+ pattern = r'{{#errors}}.*?{{/errors}}'
|
|
|
+ html = re.sub(pattern, "".join(error_divs), html, flags=re.DOTALL)
|
|
|
+ elif "{{#errors}}" in html:
|
|
|
+ # Remove errors section if no errors
|
|
|
+ pattern = r'{{#has_errors}}.*?{{/has_errors}}'
|
|
|
+ html = re.sub(pattern, "", html, flags=re.DOTALL)
|
|
|
+
|
|
|
+ # Handle conditional sections
|
|
|
+ html = re.sub(r'{{#has_notes}}.*?{{/has_notes}}', "", html, flags=re.DOTALL)
|
|
|
+ html = re.sub(r'{{#has_errors}}.*?{{/has_errors}}', "", html, flags=re.DOTALL)
|
|
|
+
|
|
|
+ return html
|
|
|
+
|
|
|
+ def export_html(self, data: ReportData, path: Path) -> bool:
|
|
|
+ """
|
|
|
+ Export report to HTML format.
|
|
|
+
|
|
|
+ Args:
|
|
|
+ data: Report data
|
|
|
+ path: Output file path
|
|
|
+
|
|
|
+ Returns:
|
|
|
+ True if successful
|
|
|
+ """
|
|
|
+ try:
|
|
|
+ html = self._render_template(data)
|
|
|
+
|
|
|
+ with open(path, "w", encoding="utf-8") as f:
|
|
|
+ f.write(html)
|
|
|
+
|
|
|
+ return True
|
|
|
+ except Exception as e:
|
|
|
+ self.export_failed.emit(str(e))
|
|
|
+ return False
|
|
|
+
|
|
|
+ def export_pdf(self, data: ReportData, path: Path) -> bool:
|
|
|
+ """
|
|
|
+ Export report to PDF format.
|
|
|
+
|
|
|
+ Args:
|
|
|
+ data: Report data
|
|
|
+ path: Output file path
|
|
|
+
|
|
|
+ Returns:
|
|
|
+ True if successful
|
|
|
+ """
|
|
|
+ try:
|
|
|
+ # First generate HTML
|
|
|
+ html = self._render_template(data)
|
|
|
+
|
|
|
+ # Try to use weasyprint if available
|
|
|
+ try:
|
|
|
+ from weasyprint import HTML
|
|
|
+ HTML(string=html).write_pdf(str(path))
|
|
|
+ return True
|
|
|
+ except ImportError:
|
|
|
+ # Fallback: use PyQt's text document with simple formatting
|
|
|
+ from PyQt6.QtWidgets import QTextDocument
|
|
|
+ from PyQt6.QtPrintSupport import QPrinter
|
|
|
+
|
|
|
+ doc = QTextDocument()
|
|
|
+ doc.setHtml(html)
|
|
|
+
|
|
|
+ printer = QPrinter()
|
|
|
+ printer.setOutputFormat(QPrinter.OutputFormat.PdfFormat)
|
|
|
+ printer.setOutputFileName(str(path))
|
|
|
+ printer.setPageSize(QPageSize(QPageSize.PageSizeId.A4))
|
|
|
+
|
|
|
+ doc.print(printer)
|
|
|
+ return True
|
|
|
+
|
|
|
+ except Exception as e:
|
|
|
+ self.export_failed.emit(str(e))
|
|
|
+ return False
|
|
|
+
|
|
|
+ def export_json(self, data: ReportData, path: Path) -> bool:
|
|
|
+ """
|
|
|
+ Export report data to JSON format.
|
|
|
+
|
|
|
+ Args:
|
|
|
+ data: Report data
|
|
|
+ path: Output file path
|
|
|
+
|
|
|
+ Returns:
|
|
|
+ True if successful
|
|
|
+ """
|
|
|
+ try:
|
|
|
+ report_dict = {
|
|
|
+ "title": data.title,
|
|
|
+ "generated_at": data.generated_at.isoformat(),
|
|
|
+ "project_name": data.project_name,
|
|
|
+ "source_language": data.source_language,
|
|
|
+ "target_language": data.target_language,
|
|
|
+ "statistics": {
|
|
|
+ "total_files": data.statistics.total_files,
|
|
|
+ "completed_files": data.statistics.completed_files,
|
|
|
+ "failed_files": data.statistics.failed_files,
|
|
|
+ "pending_files": data.statistics.pending_files,
|
|
|
+ "total_chapters": data.statistics.total_chapters,
|
|
|
+ "completed_chapters": data.statistics.completed_chapters,
|
|
|
+ "failed_chapters": data.statistics.failed_chapters,
|
|
|
+ "pending_chapters": data.statistics.pending_chapters,
|
|
|
+ "total_words": data.statistics.total_words,
|
|
|
+ "translated_words": data.statistics.translated_words,
|
|
|
+ "remaining_words": data.statistics.remaining_words,
|
|
|
+ "elapsed_time_seconds": data.statistics.elapsed_time_seconds,
|
|
|
+ "estimated_remaining_seconds": data.statistics.estimated_remaining_seconds,
|
|
|
+ "words_per_minute": data.statistics.words_per_minute,
|
|
|
+ "chapters_per_hour": data.statistics.chapters_per_hour,
|
|
|
+ "total_errors": data.statistics.total_errors,
|
|
|
+ "error_types": data.statistics.error_types,
|
|
|
+ "glossary_terms_used": data.statistics.glossary_terms_used,
|
|
|
+ "glossary_hit_count": data.statistics.glossary_hit_count,
|
|
|
+ "completion_percentage": data.statistics.completion_percentage,
|
|
|
+ },
|
|
|
+ "chapter_progress": [
|
|
|
+ {
|
|
|
+ "file_name": cp.file_name,
|
|
|
+ "chapter_name": cp.chapter_name,
|
|
|
+ "status": cp.status,
|
|
|
+ "word_count": cp.word_count,
|
|
|
+ "translated_words": cp.translated_words,
|
|
|
+ "completion_percentage": cp.completion_percentage,
|
|
|
+ "start_time": cp.start_time.isoformat() if cp.start_time else None,
|
|
|
+ "end_time": cp.end_time.isoformat() if cp.end_time else None,
|
|
|
+ }
|
|
|
+ for cp in data.chapter_progress
|
|
|
+ ],
|
|
|
+ "errors": [
|
|
|
+ {
|
|
|
+ "timestamp": err.timestamp.isoformat(),
|
|
|
+ "file": err.file,
|
|
|
+ "chapter": err.chapter,
|
|
|
+ "error_type": err.error_type,
|
|
|
+ "message": err.message,
|
|
|
+ "severity": err.severity,
|
|
|
+ }
|
|
|
+ for err in data.errors
|
|
|
+ ],
|
|
|
+ "daily_progress": data.daily_progress,
|
|
|
+ "notes": data.notes,
|
|
|
+ }
|
|
|
+
|
|
|
+ with open(path, "w", encoding="utf-8") as f:
|
|
|
+ json.dump(report_dict, f, ensure_ascii=False, indent=2)
|
|
|
+
|
|
|
+ return True
|
|
|
+ except Exception as e:
|
|
|
+ self.export_failed.emit(str(e))
|
|
|
+ return False
|
|
|
+
|
|
|
+ def export(self, data: ReportData, format: ReportFormat, path: Path) -> bool:
|
|
|
+ """
|
|
|
+ Export report in the specified format.
|
|
|
+
|
|
|
+ Args:
|
|
|
+ data: Report data
|
|
|
+ format: Export format
|
|
|
+ path: Output file path
|
|
|
+
|
|
|
+ Returns:
|
|
|
+ True if successful
|
|
|
+ """
|
|
|
+ self.export_progress.emit(0)
|
|
|
+
|
|
|
+ if format == ReportFormat.HTML:
|
|
|
+ result = self.export_html(data, path)
|
|
|
+ elif format == ReportFormat.PDF:
|
|
|
+ self.export_progress.emit(50)
|
|
|
+ result = self.export_pdf(data, path)
|
|
|
+ elif format == ReportFormat.JSON:
|
|
|
+ result = self.export_json(data, path)
|
|
|
+ else:
|
|
|
+ result = False
|
|
|
+
|
|
|
+ if result:
|
|
|
+ self.export_progress.emit(100)
|
|
|
+ self.export_completed.emit(str(path))
|
|
|
+
|
|
|
+ # Save settings
|
|
|
+ self._settings.setValue(self.SETTINGS_LAST_FORMAT, format.value)
|
|
|
+ self._settings.setValue(self.SETTINGS_LAST_PATH, str(path.parent))
|
|
|
+ else:
|
|
|
+ self.export_failed.emit("导出失败")
|
|
|
+
|
|
|
+ return result
|
|
|
+
|
|
|
+
|
|
|
+class ReportExporterDialog(QDialog):
|
|
|
+ """
|
|
|
+ Dialog for exporting reports.
|
|
|
+
|
|
|
+ Allows users to:
|
|
|
+ - Select export format
|
|
|
+ - Choose included sections
|
|
|
+ - Add custom notes
|
|
|
+ - Preview before export
|
|
|
+ """
|
|
|
+
|
|
|
+ def __init__(
|
|
|
+ self,
|
|
|
+ data: ReportData,
|
|
|
+ exporter: Optional[ReportExporter] = None,
|
|
|
+ parent: Optional[QWidget] = None,
|
|
|
+ ) -> None:
|
|
|
+ """Initialize the dialog."""
|
|
|
+ super().__init__(parent)
|
|
|
+
|
|
|
+ self._data = data
|
|
|
+ self._exporter = exporter or ReportExporter(self)
|
|
|
+ self._current_format = ReportFormat.HTML
|
|
|
+
|
|
|
+ self._setup_ui()
|
|
|
+ self._connect_signals()
|
|
|
+
|
|
|
+ def _setup_ui(self) -> None:
|
|
|
+ """Set up the dialog UI."""
|
|
|
+ self.setWindowTitle("导出报告")
|
|
|
+ self.setMinimumWidth(500)
|
|
|
+
|
|
|
+ layout = QVBoxLayout(self)
|
|
|
+
|
|
|
+ # Format selection
|
|
|
+ format_group = QGroupBox("导出格式")
|
|
|
+ format_layout = QFormLayout(format_group)
|
|
|
+
|
|
|
+ self._format_combo = QComboBox()
|
|
|
+ self._format_combo.addItem("HTML 网页", ReportFormat.HTML)
|
|
|
+ self._format_combo.addItem("PDF 文档", ReportFormat.PDF)
|
|
|
+ self._format_combo.addItem("JSON 数据", ReportFormat.JSON)
|
|
|
+ self._format_combo.setCurrentIndex(0)
|
|
|
+ format_layout.addRow("格式:", self._format_combo)
|
|
|
+
|
|
|
+ layout.addWidget(format_group)
|
|
|
+
|
|
|
+ # Report info
|
|
|
+ info_group = QGroupBox("报告信息")
|
|
|
+ info_layout = QFormLayout(info_group)
|
|
|
+
|
|
|
+ self._title_input = QLineEdit(self._data.title)
|
|
|
+ info_layout.addRow("标题:", self._title_input)
|
|
|
+
|
|
|
+ self._project_input = QLineEdit(self._data.project_name)
|
|
|
+ info_layout.addRow("项目名称:", self._project_input)
|
|
|
+
|
|
|
+ layout.addWidget(info_group)
|
|
|
+
|
|
|
+ # Sections to include
|
|
|
+ sections_group = QGroupBox("包含内容")
|
|
|
+ sections_layout = QVBoxLayout(sections_group)
|
|
|
+
|
|
|
+ self._include_summary = QCheckBox("翻译概览")
|
|
|
+ self._include_summary.setChecked(True)
|
|
|
+ sections_layout.addWidget(self._include_summary)
|
|
|
+
|
|
|
+ self._include_stats = QCheckBox("详细统计")
|
|
|
+ self._include_stats.setChecked(True)
|
|
|
+ sections_layout.addWidget(self._include_stats)
|
|
|
+
|
|
|
+ self._include_progress = QCheckBox("章节进度")
|
|
|
+ self._include_progress.setChecked(True)
|
|
|
+ sections_layout.addWidget(self._include_progress)
|
|
|
+
|
|
|
+ self._include_errors = QCheckBox("错误摘要")
|
|
|
+ self._include_errors.setChecked(True)
|
|
|
+ sections_layout.addWidget(self._include_errors)
|
|
|
+
|
|
|
+ layout.addWidget(sections_group)
|
|
|
+
|
|
|
+ # Notes
|
|
|
+ notes_group = QGroupBox("备注")
|
|
|
+ notes_layout = QVBoxLayout(notes_group)
|
|
|
+
|
|
|
+ self._notes_input = QTextEdit()
|
|
|
+ self._notes_input.setPlaceholderText("可选:添加报告备注...")
|
|
|
+ self._notes_input.setMaximumHeight(100)
|
|
|
+ self._notes_input.setPlainText(self._data.notes)
|
|
|
+ notes_layout.addWidget(self._notes_input)
|
|
|
+
|
|
|
+ layout.addWidget(notes_group)
|
|
|
+
|
|
|
+ # Progress
|
|
|
+ self._progress_bar = QProgressBar()
|
|
|
+ self._progress_bar.setRange(0, 100)
|
|
|
+ self._progress_bar.setValue(0)
|
|
|
+ self._progress_bar.setVisible(False)
|
|
|
+ layout.addWidget(self._progress_bar)
|
|
|
+
|
|
|
+ # Buttons
|
|
|
+ button_box = QDialogButtonBox(
|
|
|
+ QDialogButtonBox.StandardButton.Ok |
|
|
|
+ QDialogButtonBox.StandardButton.Cancel
|
|
|
+ )
|
|
|
+ self._export_btn = button_box.button(QDialogButtonBox.StandardButton.Ok)
|
|
|
+ self._export_btn.setText("导出")
|
|
|
+ button_box.rejected.connect(self.reject)
|
|
|
+ layout.addWidget(button_box)
|
|
|
+
|
|
|
+ self._button_box = button_box
|
|
|
+
|
|
|
+ def _connect_signals(self) -> None:
|
|
|
+ """Connect internal signals."""
|
|
|
+ self._exporter.export_progress.connect(self._on_progress)
|
|
|
+ self._exporter.export_completed.connect(self._on_completed)
|
|
|
+ self._exporter.export_failed.connect(self._on_failed)
|
|
|
+ self._export_btn.clicked.connect(self._on_export)
|
|
|
+ self._format_combo.currentIndexChanged.connect(self._on_format_changed)
|
|
|
+
|
|
|
+ def _on_format_changed(self, index: int) -> None:
|
|
|
+ """Handle format selection change."""
|
|
|
+ self._current_format = self._format_combo.currentData()
|
|
|
+
|
|
|
+ def _on_export(self) -> None:
|
|
|
+ """Handle export button click."""
|
|
|
+ # Update data from UI
|
|
|
+ self._data.title = self._title_input.text()
|
|
|
+ self._data.project_name = self._project_input.text()
|
|
|
+ self._data.notes = self._notes_input.toPlainText()
|
|
|
+
|
|
|
+ # Get save path
|
|
|
+ default_name = f"{self._data.title}_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
|
|
+ default_path = Path(default_name)
|
|
|
+
|
|
|
+ if self._current_format == ReportFormat.HTML:
|
|
|
+ file_filter = "HTML Files (*.html);;All Files (*)"
|
|
|
+ default_name += ".html"
|
|
|
+ elif self._current_format == ReportFormat.PDF:
|
|
|
+ file_filter = "PDF Files (*.pdf);;All Files (*)"
|
|
|
+ default_name += ".pdf"
|
|
|
+ else:
|
|
|
+ file_filter = "JSON Files (*.json);;All Files (*)"
|
|
|
+ default_name += ".json"
|
|
|
+
|
|
|
+ from PyQt6.QtWidgets import QFileDialog
|
|
|
+ file_path, _ = QFileDialog.getSaveFileName(
|
|
|
+ self,
|
|
|
+ "导出报告",
|
|
|
+ str(default_path),
|
|
|
+ file_filter
|
|
|
+ )
|
|
|
+
|
|
|
+ if file_path:
|
|
|
+ self._progress_bar.setVisible(True)
|
|
|
+ self._export_btn.setEnabled(False)
|
|
|
+
|
|
|
+ # Perform export
|
|
|
+ self._exporter.export(self._data, self._current_format, Path(file_path))
|
|
|
+
|
|
|
+ def _on_progress(self, value: int) -> None:
|
|
|
+ """Handle export progress update."""
|
|
|
+ self._progress_bar.setValue(value)
|
|
|
+
|
|
|
+ def _on_completed(self, path: str) -> None:
|
|
|
+ """Handle export completion."""
|
|
|
+ self._progress_bar.setVisible(False)
|
|
|
+ self._export_btn.setEnabled(True)
|
|
|
+
|
|
|
+ QMessageBox.information(
|
|
|
+ self,
|
|
|
+ "导出成功",
|
|
|
+ f"报告已导出到:\n{path}"
|
|
|
+ )
|
|
|
+ self.accept()
|
|
|
+
|
|
|
+ def _on_failed(self, error: str) -> None:
|
|
|
+ """Handle export failure."""
|
|
|
+ self._progress_bar.setVisible(False)
|
|
|
+ self._export_btn.setEnabled(True)
|
|
|
+
|
|
|
+ QMessageBox.warning(
|
|
|
+ self,
|
|
|
+ "导出失败",
|
|
|
+ f"导出报告时发生错误:\n{error}"
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+# Singleton instance
|
|
|
+_exporter_instance: Optional[ReportExporter] = None
|
|
|
+
|
|
|
+
|
|
|
+def get_report_exporter() -> ReportExporter:
|
|
|
+ """Get the singleton report exporter instance."""
|
|
|
+ global _exporter_instance
|
|
|
+ if _exporter_instance is None:
|
|
|
+ _exporter_instance = ReportExporter()
|
|
|
+ return _exporter_instance
|