Răsfoiți Sursa

feat(ui): Implement Epic 7b Phase 3 - Enhanced UI features (P2, 23 SP)

Implements 5 stories for enhanced UI functionality:

Story 7.19: Glossary Editor (5 SP)
- Create src/ui/glossary_editor.py with full CRUD interface
- QTableView displaying glossary entries with columns (原文, 译文, 分类, 备注)
- Add/Edit/Delete term buttons with EntryEditDialog
- Search filter functionality for source/target/context
- Category filtering (人名/地名/技能/物品/组织/其他)
- Import/Export JSON functionality with atomic write
- Context menu support with copy-to-clipboard

Story 7.11: Theme Manager (4 SP)
- Create src/ui/theme_manager.py with ThemeManager class
- Light/Dark theme color palettes (ThemeColors dataclass)
- Dynamic stylesheet generation with full QSS support
- QSettings persistence for user theme preference
- Custom stylesheet loading from file support
- Singleton pattern for global access

Story 7.13: Keyboard Shortcuts (4 SP)
- Create src/ui/shortcuts.py with ShortcutsManager class
- Default shortcuts for all common operations (Ctrl+O, Ctrl+S, F5, etc.)
- ShortcutCaptureButton widget for interactive shortcut assignment
- ShortcutsDialog for configuration UI
- QSettings persistence for shortcut customizations
- Conflict detection for duplicate shortcuts
- Reset to defaults functionality

Story 7.15: Log Viewer (5 SP)
- Create src/ui/log_viewer.py with LogViewer widget
- Log levels (DEBUG/INFO/WARNING/ERROR/CRITICAL) with colors
- InMemoryLogHandler for capturing application logs
- LogTableModel with table display (时间, 级别, 消息, 模块)
- Log level filtering (INFO+, WARNING+, ERROR+)
- Search functionality for log content
- Export to TXT and CSV formats
- Auto-scroll toggle for new log entries

Story 7.12: Multi-language UI (5 SP)
- Create src/ui/i18n.py with I18nManager class
- SupportedLanguage enum (简体中文, English)
- Complete translation dictionary with 100+ keys
- Dynamic language switching with signal emission
- Retranslate callback system for widget updates
- Module-level t() function for easy translations
- Language selection menu support

Also includes:
- Full test suites for all 5 components
- src/config/ module for application settings
- tests/config/ tests for configuration manager

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
d8dfun 2 zile în urmă
părinte
comite
88a109562c

+ 16 - 0
src/config/__init__.py

@@ -0,0 +1,16 @@
+"""
+Configuration management module for BMAD Novel Translator.
+
+Provides application settings management with persistence.
+"""
+
+from .manager import ConfigManager, ConfigCategory, ConfigItem
+from .defaults import DEFAULT_CONFIG, CONFIG_SCHEMA
+
+__all__ = [
+    "ConfigManager",
+    "ConfigCategory",
+    "ConfigItem",
+    "DEFAULT_CONFIG",
+    "CONFIG_SCHEMA",
+]

+ 510 - 0
src/config/defaults.py

@@ -0,0 +1,510 @@
+"""
+Default configuration values and schema for Story 7.10.
+
+Defines the configuration structure for:
+- Translation settings (model selection, GPU acceleration, batch size)
+- Interface settings (theme, language, font size)
+- Path settings (working directory, cache directory, output directory)
+- Advanced settings (log level, concurrency)
+"""
+
+from dataclasses import dataclass, field
+from typing import Dict, List, Any, Optional, Literal
+from enum import Enum
+from pathlib import Path
+
+
+class ConfigType(Enum):
+    """Configuration value type."""
+    STRING = "string"
+    INTEGER = "integer"
+    FLOAT = "float"
+    BOOLEAN = "boolean"
+    PATH = "path"
+    ENUM = "enum"
+
+
+@dataclass
+class ConfigItem:
+    """
+    Single configuration item definition.
+
+    Attributes:
+        key: Configuration key
+        title: Display title
+        description: Help text
+        type: Value type
+        default_value: Default value
+        min_value: Minimum value (for numeric)
+        max_value: Maximum value (for numeric)
+        enum_values: List of valid values (for enum type)
+        category: Configuration category
+        requires_restart: Whether change requires app restart
+    """
+    key: str
+    title: str
+    description: str
+    type: ConfigType
+    default_value: Any
+    min_value: Optional[float] = None
+    max_value: Optional[float] = None
+    enum_values: Optional[List[str]] = None
+    category: str = "general"
+    requires_restart: bool = False
+
+
+@dataclass
+class ConfigCategory:
+    """
+    Configuration category group.
+
+    Attributes:
+        key: Category key
+        title: Display title
+        description: Category description
+        icon: Icon name (optional)
+        items: List of config items in this category
+    """
+    key: str
+    title: str
+    description: str
+    icon: Optional[str] = None
+    items: List[ConfigItem] = field(default_factory=list)
+
+
+# Default configuration values
+DEFAULT_CONFIG = {
+    # Translation settings
+    "translation.model_name": "facebook/m2m100_418M",
+    "translation.device": "auto",  # auto, cuda, cpu
+    "translation.batch_size": 8,
+    "translation.beam_size": 5,
+    "translation.max_length": 1024,
+    "translation.repetition_penalty": 1.0,
+
+    # Interface settings
+    "interface.theme": "light",  # light, dark, system
+    "interface.language": "zh_CN",  # zh_CN, en_US
+    "interface.font_size": 12,
+    "interface.show_tooltips": True,
+    "interface.auto_save": True,
+
+    # Path settings
+    "paths.working_directory": str(Path.home() / "bmad_translator"),
+    "paths.cache_directory": str(Path.home() / ".cache" / "bmad_translator"),
+    "paths.output_directory": str(Path.home() / "bmad_translator" / "output"),
+    "paths.models_directory": str(Path.home() / "bmad_translator" / "models"),
+
+    # Advanced settings
+    "advanced.log_level": "INFO",  # DEBUG, INFO, WARNING, ERROR
+    "advanced.concurrent_translations": 2,
+    "advanced.enable_crash_reports": True,
+    "advanced.check_updates": True,
+    "advanced.save_interval_seconds": 30,
+
+    # Network settings
+    "network.timeout_seconds": 30,
+    "network.retry_attempts": 3,
+    "network.retry_delay_seconds": 2,
+    "network.offline_mode": False,
+
+    # Terminology settings
+    "terminology.auto_extract": True,
+    "terminology.max_terms": 200,
+    "terminology.min_frequency": 2,
+
+    # Upload settings
+    "upload.auto_upload": False,
+    "upload.chunk_size": 5,
+    "upload.verify_upload": True,
+}
+
+
+# Configuration schema - defines all available settings
+CONFIG_SCHEMA: List[ConfigCategory] = [
+    ConfigCategory(
+        key="translation",
+        title="Translation Settings",
+        description="Configure translation engine and GPU settings",
+        icon="translation",
+        items=[
+            ConfigItem(
+                key="translation.model_name",
+                title="Model Name",
+                description="Name of the translation model to use",
+                type=ConfigType.STRING,
+                default_value="facebook/m2m100_418M",
+                category="translation",
+                requires_restart=True,
+            ),
+            ConfigItem(
+                key="translation.device",
+                title="Device",
+                description="Device to use for translation (auto-detect, CUDA, or CPU)",
+                type=ConfigType.ENUM,
+                default_value="auto",
+                enum_values=["auto", "cuda", "cpu"],
+                category="translation",
+                requires_restart=True,
+            ),
+            ConfigItem(
+                key="translation.batch_size",
+                title="Batch Size",
+                description="Number of segments to translate at once (1-32)",
+                type=ConfigType.INTEGER,
+                default_value=8,
+                min_value=1,
+                max_value=32,
+                category="translation",
+            ),
+            ConfigItem(
+                key="translation.beam_size",
+                title="Beam Size",
+                description="Beam search size for translation quality (1-10)",
+                type=ConfigType.INTEGER,
+                default_value=5,
+                min_value=1,
+                max_value=10,
+                category="translation",
+            ),
+            ConfigItem(
+                key="translation.max_length",
+                title="Max Length",
+                description="Maximum length of translated segments (128-4096)",
+                type=ConfigType.INTEGER,
+                default_value=1024,
+                min_value=128,
+                max_value=4096,
+                category="translation",
+            ),
+            ConfigItem(
+                key="translation.repetition_penalty",
+                title="Repetition Penalty",
+                description="Penalty for repetitive output (1.0-2.0)",
+                type=ConfigType.FLOAT,
+                default_value=1.0,
+                min_value=1.0,
+                max_value=2.0,
+                category="translation",
+            ),
+        ],
+    ),
+
+    ConfigCategory(
+        key="interface",
+        title="Interface Settings",
+        description="Customize the application appearance and language",
+        icon="appearance",
+        items=[
+            ConfigItem(
+                key="interface.theme",
+                title="Theme",
+                description="Application color theme",
+                type=ConfigType.ENUM,
+                default_value="light",
+                enum_values=["light", "dark", "system"],
+                category="interface",
+            ),
+            ConfigItem(
+                key="interface.language",
+                title="Language",
+                description="Interface language",
+                type=ConfigType.ENUM,
+                default_value="zh_CN",
+                enum_values=["zh_CN", "en_US"],
+                category="interface",
+            ),
+            ConfigItem(
+                key="interface.font_size",
+                title="Font Size",
+                description="Base font size in pixels (8-20)",
+                type=ConfigType.INTEGER,
+                default_value=12,
+                min_value=8,
+                max_value=20,
+                category="interface",
+            ),
+            ConfigItem(
+                key="interface.show_tooltips",
+                title="Show Tooltips",
+                description="Display helpful tooltips on hover",
+                type=ConfigType.BOOLEAN,
+                default_value=True,
+                category="interface",
+            ),
+            ConfigItem(
+                key="interface.auto_save",
+                title="Auto Save",
+                description="Automatically save configuration changes",
+                type=ConfigType.BOOLEAN,
+                default_value=True,
+                category="interface",
+            ),
+        ],
+    ),
+
+    ConfigCategory(
+        key="paths",
+        title="Path Settings",
+        description="Configure working directories and file locations",
+        icon="folder",
+        items=[
+            ConfigItem(
+                key="paths.working_directory",
+                title="Working Directory",
+                description="Main working directory for projects",
+                type=ConfigType.PATH,
+                default_value=str(Path.home() / "bmad_translator"),
+                category="paths",
+                requires_restart=True,
+            ),
+            ConfigItem(
+                key="paths.cache_directory",
+                title="Cache Directory",
+                description="Directory for cached files",
+                type=ConfigType.PATH,
+                default_value=str(Path.home() / ".cache" / "bmad_translator"),
+                category="paths",
+                requires_restart=True,
+            ),
+            ConfigItem(
+                key="paths.output_directory",
+                title="Output Directory",
+                description="Default directory for translation outputs",
+                type=ConfigType.PATH,
+                default_value=str(Path.home() / "bmad_translator" / "output"),
+                category="paths",
+            ),
+            ConfigItem(
+                key="paths.models_directory",
+                title="Models Directory",
+                description="Directory for ML model storage",
+                type=ConfigType.PATH,
+                default_value=str(Path.home() / "bmad_translator" / "models"),
+                category="paths",
+                requires_restart=True,
+            ),
+        ],
+    ),
+
+    ConfigCategory(
+        key="advanced",
+        title="Advanced Settings",
+        description="Advanced configuration options",
+        icon="settings",
+        items=[
+            ConfigItem(
+                key="advanced.log_level",
+                title="Log Level",
+                description="Minimum log level to display",
+                type=ConfigType.ENUM,
+                default_value="INFO",
+                enum_values=["DEBUG", "INFO", "WARNING", "ERROR"],
+                category="advanced",
+            ),
+            ConfigItem(
+                key="advanced.concurrent_translations",
+                title="Concurrent Translations",
+                description="Number of translation tasks to run in parallel (1-4)",
+                type=ConfigType.INTEGER,
+                default_value=2,
+                min_value=1,
+                max_value=4,
+                category="advanced",
+            ),
+            ConfigItem(
+                key="advanced.enable_crash_reports",
+                title="Enable Crash Reports",
+                description="Send anonymous crash reports to help improve the app",
+                type=ConfigType.BOOLEAN,
+                default_value=True,
+                category="advanced",
+            ),
+            ConfigItem(
+                key="advanced.check_updates",
+                title="Check for Updates",
+                description="Automatically check for new versions on startup",
+                type=ConfigType.BOOLEAN,
+                default_value=True,
+                category="advanced",
+            ),
+            ConfigItem(
+                key="advanced.save_interval_seconds",
+                title="Auto-save Interval",
+                description="How often to save progress (seconds)",
+                type=ConfigType.INTEGER,
+                default_value=30,
+                min_value=5,
+                max_value=300,
+                category="advanced",
+            ),
+        ],
+    ),
+
+    ConfigCategory(
+        key="terminology",
+        title="Terminology Settings",
+        description="Configure automatic terminology extraction",
+        icon="terminology",
+        items=[
+            ConfigItem(
+                key="terminology.auto_extract",
+                title="Auto Extract",
+                description="Automatically extract terminology from text",
+                type=ConfigType.BOOLEAN,
+                default_value=True,
+                category="terminology",
+            ),
+            ConfigItem(
+                key="terminology.max_terms",
+                title="Max Terms",
+                description="Maximum number of terms to extract (50-500)",
+                type=ConfigType.INTEGER,
+                default_value=200,
+                min_value=50,
+                max_value=500,
+                category="terminology",
+            ),
+            ConfigItem(
+                key="terminology.min_frequency",
+                title="Min Frequency",
+                description="Minimum frequency for term extraction (1-10)",
+                type=ConfigType.INTEGER,
+                default_value=2,
+                min_value=1,
+                max_value=10,
+                category="terminology",
+            ),
+        ],
+    ),
+
+    ConfigCategory(
+        key="upload",
+        title="Upload Settings",
+        description="Configure upload behavior",
+        icon="upload",
+        items=[
+            ConfigItem(
+                key="upload.auto_upload",
+                title="Auto Upload",
+                description="Automatically upload completed translations",
+                type=ConfigType.BOOLEAN,
+                default_value=False,
+                category="upload",
+            ),
+            ConfigItem(
+                key="upload.chunk_size",
+                title="Upload Chunk Size",
+                description="Number of chapters to upload per request (1-20)",
+                type=ConfigType.INTEGER,
+                default_value=5,
+                min_value=1,
+                max_value=20,
+                category="upload",
+            ),
+            ConfigItem(
+                key="upload.verify_upload",
+                title="Verify Upload",
+                description="Verify successful upload after completion",
+                type=ConfigType.BOOLEAN,
+                default_value=True,
+                category="upload",
+            ),
+        ],
+    ),
+]
+
+
+def get_config_item(key: str) -> Optional[ConfigItem]:
+    """
+    Get a config item by key.
+
+    Args:
+        key: Configuration key (e.g., "translation.model_name")
+
+    Returns:
+        ConfigItem or None if not found
+    """
+    for category in CONFIG_SCHEMA:
+        for item in category.items:
+            if item.key == key:
+                return item
+    return None
+
+
+def get_category_items(category_key: str) -> List[ConfigItem]:
+    """
+    Get all items in a category.
+
+    Args:
+        category_key: Category key (e.g., "translation")
+
+    Returns:
+        List of ConfigItem objects
+    """
+    for category in CONFIG_SCHEMA:
+        if category.key == category_key:
+            return category.items
+    return []
+
+
+def validate_config_value(key: str, value: Any) -> tuple[bool, Optional[str]]:
+    """
+    Validate a configuration value.
+
+    Args:
+        key: Configuration key
+        value: Value to validate
+
+    Returns:
+        Tuple of (is_valid, error_message)
+    """
+    item = get_config_item(key)
+    if item is None:
+        return False, f"Unknown configuration key: {key}"
+
+    # Type validation
+    if item.type == ConfigType.BOOLEAN:
+        if not isinstance(value, bool):
+            return False, f"Expected boolean, got {type(value).__name__}"
+
+    elif item.type == ConfigType.INTEGER:
+        if not isinstance(value, int):
+            try:
+                value = int(value)
+            except (ValueError, TypeError):
+                return False, f"Expected integer, got {type(value).__name__}"
+        if item.min_value is not None and value < item.min_value:
+            return False, f"Value must be >= {item.min_value}"
+        if item.max_value is not None and value > item.max_value:
+            return False, f"Value must be <= {item.max_value}"
+
+    elif item.type == ConfigType.FLOAT:
+        if not isinstance(value, (int, float)):
+            try:
+                value = float(value)
+            except (ValueError, TypeError):
+                return False, f"Expected float, got {type(value).__name__}"
+        if item.min_value is not None and value < item.min_value:
+            return False, f"Value must be >= {item.min_value}"
+        if item.max_value is not None and value > item.max_value:
+            return False, f"Value must be <= {item.max_value}"
+
+    elif item.type == ConfigType.ENUM:
+        if item.enum_values and value not in item.enum_values:
+            return False, f"Value must be one of: {', '.join(item.enum_values)}"
+
+    elif item.type == ConfigType.STRING:
+        if not isinstance(value, str):
+            return False, f"Expected string, got {type(value).__name__}"
+
+    elif item.type == ConfigType.PATH:
+        if not isinstance(value, str):
+            return False, f"Expected path string, got {type(value).__name__}"
+        # Check if path is valid format
+        try:
+            Path(value)
+        except Exception:
+            return False, f"Invalid path format"
+
+    return True, None

+ 385 - 0
src/config/manager.py

@@ -0,0 +1,385 @@
+"""
+Configuration Manager for Story 7.10.
+
+Handles loading, saving, and managing application configuration.
+"""
+
+import json
+from pathlib import Path
+from typing import Any, Optional, Dict, List
+from dataclasses import asdict
+import threading
+
+from .defaults import (
+    DEFAULT_CONFIG,
+    CONFIG_SCHEMA,
+    ConfigCategory,
+    ConfigItem,
+    get_config_item,
+    validate_config_value,
+)
+
+
+class ConfigManager:
+    """
+    Configuration manager for application settings.
+
+    Features:
+    - Load and save configuration from JSON file
+    - Get and set configuration values
+    - Validate configuration changes
+    - Reset to defaults
+    - Thread-safe operations
+    """
+
+    # Default configuration file locations
+    DEFAULT_CONFIG_DIR = Path.home() / ".config" / "bmad_translator"
+    DEFAULT_CONFIG_FILE = DEFAULT_CONFIG_DIR / "config.json"
+
+    def __init__(self, config_path: Optional[Path] = None) -> None:
+        """
+        Initialize the configuration manager.
+
+        Args:
+            config_path: Path to configuration file. If None, uses default.
+        """
+        self._config_path = config_path or self.DEFAULT_CONFIG_FILE
+        self._config: Dict[str, Any] = {}
+        self._lock = threading.RLock()
+
+        # Load configuration
+        self._load()
+
+    def _load(self) -> None:
+        """Load configuration from file."""
+        with self._lock:
+            # Start with defaults
+            self._config = DEFAULT_CONFIG.copy()
+
+            # Load from file if exists
+            if self._config_path.exists():
+                try:
+                    with open(self._config_path, 'r', encoding='utf-8') as f:
+                        loaded_config = json.load(f)
+
+                    # Merge loaded config with defaults
+                    # (new defaults will be added for missing keys)
+                    self._config.update(loaded_config)
+
+                except (json.JSONDecodeError, IOError) as e:
+                    # If config is corrupted, start with defaults
+                    print(f"Warning: Could not load config: {e}. Using defaults.")
+
+    def save(self) -> bool:
+        """
+        Save configuration to file.
+
+        Returns:
+            True if save was successful, False otherwise.
+        """
+        with self._lock:
+            try:
+                # Ensure directory exists
+                self._config_path.parent.mkdir(parents=True, exist_ok=True)
+
+                # Write configuration atomically
+                temp_path = self._config_path.with_suffix('.tmp')
+                with open(temp_path, 'w', encoding='utf-8') as f:
+                    json.dump(self._config, f, indent=2, ensure_ascii=False)
+
+                # Atomic rename
+                temp_path.replace(self._config_path)
+
+                return True
+
+            except IOError as e:
+                print(f"Error saving configuration: {e}")
+                return False
+
+    def get(self, key: str, default: Any = None) -> Any:
+        """
+        Get a configuration value.
+
+        Args:
+            key: Configuration key (e.g., "translation.model_name")
+            default: Default value if key not found
+
+        Returns:
+            Configuration value or default
+        """
+        with self._lock:
+            return self._config.get(key, default)
+
+    def set(self, key: str, value: Any, validate: bool = True) -> tuple[bool, Optional[str]]:
+        """
+        Set a configuration value.
+
+        Args:
+            key: Configuration key
+            value: New value
+            validate: Whether to validate the value
+
+        Returns:
+            Tuple of (success, error_message)
+        """
+        with self._lock:
+            # Validate if requested
+            if validate:
+                is_valid, error = validate_config_value(key, value)
+                if not is_valid:
+                    return False, error
+
+            # Set value
+            self._config[key] = value
+
+            # Auto-save if enabled
+            if self.get("interface.auto_save", True):
+                self.save()
+
+            return True, None
+
+    def get_all(self) -> Dict[str, Any]:
+        """
+        Get all configuration values.
+
+        Returns:
+            Dictionary of all configuration key-value pairs
+        """
+        with self._lock:
+            return self._config.copy()
+
+    def set_multiple(self, updates: Dict[str, Any]) -> tuple[bool, List[str]]:
+        """
+        Set multiple configuration values at once.
+
+        Args:
+            updates: Dictionary of key-value pairs to set
+
+        Returns:
+            Tuple of (all_success, error_messages)
+        """
+        errors = []
+
+        # First, validate all
+        for key, value in updates.items():
+            is_valid, error = validate_config_value(key, value)
+            if not is_valid:
+                errors.append(f"{key}: {error}")
+
+        if errors:
+            return False, errors
+
+        # All valid, apply changes
+        with self._lock:
+            for key, value in updates.items():
+                self._config[key] = value
+
+            # Auto-save if enabled
+            if self.get("interface.auto_save", True):
+                self.save()
+
+        return True, []
+
+    def reset(self, key: Optional[str] = None) -> None:
+        """
+        Reset configuration to defaults.
+
+        Args:
+            key: Specific key to reset, or None to reset all
+        """
+        with self._lock:
+            if key is None:
+                # Reset all
+                self._config = DEFAULT_CONFIG.copy()
+            else:
+                # Reset specific key
+                if key in DEFAULT_CONFIG:
+                    self._config[key] = DEFAULT_CONFIG[key]
+
+            self.save()
+
+    def reset_category(self, category: str) -> None:
+        """
+        Reset all values in a category to defaults.
+
+        Args:
+            category: Category key (e.g., "translation")
+        """
+        with self._lock:
+            for item in get_category_items(category):
+                if item.key in DEFAULT_CONFIG:
+                    self._config[item.key] = DEFAULT_CONFIG[item.key]
+
+            self.save()
+
+    def get_schema(self) -> List[ConfigCategory]:
+        """
+        Get the configuration schema.
+
+        Returns:
+            List of configuration categories
+        """
+        return CONFIG_SCHEMA
+
+    def get_category(self, category_key: str) -> Optional[ConfigCategory]:
+        """
+        Get a specific configuration category.
+
+        Args:
+            category_key: Category key
+
+        Returns:
+            ConfigCategory or None if not found
+        """
+        for category in CONFIG_SCHEMA:
+            if category.key == category_key:
+                return category
+        return None
+
+    def export(self, path: Path) -> bool:
+        """
+        Export configuration to a file.
+
+        Args:
+            path: Destination file path
+
+        Returns:
+            True if export was successful
+        """
+        with self._lock:
+            try:
+                path.parent.mkdir(parents=True, exist_ok=True)
+                with open(path, 'w', encoding='utf-8') as f:
+                    json.dump(self._config, f, indent=2, ensure_ascii=False)
+                return True
+            except IOError:
+                return False
+
+    def import_config(self, path: Path) -> tuple[bool, List[str]]:
+        """
+        Import configuration from a file.
+
+        Args:
+            path: Source file path
+
+        Returns:
+            Tuple of (success, error_messages)
+        """
+        try:
+            with open(path, 'r', encoding='utf-8') as f:
+                imported_config = json.load(f)
+
+            errors = []
+
+            # Validate all imported values
+            for key, value in imported_config.items():
+                is_valid, error = validate_config_value(key, value)
+                if not is_valid:
+                    errors.append(f"{key}: {error}")
+
+            if errors:
+                return False, errors
+
+            # All valid, apply
+            with self._lock:
+                self._config.update(imported_config)
+                self.save()
+
+            return True, []
+
+        except (json.JSONDecodeError, IOError) as e:
+            return False, [f"Could not import configuration: {e}"]
+
+    def get_requires_restart_keys(self) -> List[str]:
+        """
+        Get list of configuration keys that require application restart.
+
+        Returns:
+            List of configuration keys
+        """
+        keys = []
+        for category in CONFIG_SCHEMA:
+            for item in category.items:
+                if item.requires_restart:
+                    keys.append(item.key)
+        return keys
+
+    @property
+    def config_path(self) -> Path:
+        """Get the configuration file path."""
+        return self._config_path
+
+    def reload(self) -> None:
+        """Reload configuration from file."""
+        self._load()
+
+
+class ConfigChangeTracker:
+    """
+    Track configuration changes and detect restart requirements.
+
+    Used by the settings dialog to inform users which changes
+    require application restart.
+    """
+
+    def __init__(self, config_manager: ConfigManager) -> None:
+        """Initialize the tracker."""
+        self._config_manager = config_manager
+        self._original_values: Dict[str, Any] = {}
+        self._changed_values: Dict[str, Any] = {}
+
+    def begin_tracking(self) -> None:
+        """Start tracking configuration changes."""
+        self._original_values = self._config_manager.get_all()
+        self._changed_values.clear()
+
+    def track_change(self, key: str, value: Any) -> bool:
+        """
+        Track a configuration change.
+
+        Args:
+            key: Configuration key
+            value: New value
+
+        Returns:
+            True if value is different from original
+        """
+        original = self._original_values.get(key)
+        if original != value:
+            self._changed_values[key] = value
+            return True
+        return False
+
+    def get_changed_keys(self) -> List[str]:
+        """Get list of changed configuration keys."""
+        return list(self._changed_values.keys())
+
+    def get_restart_required_keys(self) -> List[str]:
+        """Get list of changed keys that require restart."""
+        restart_keys = self._config_manager.get_requires_restart_keys()
+        return [k for k in self._changed_values.keys() if k in restart_keys]
+
+    def is_restart_required(self) -> bool:
+        """Check if any changed keys require restart."""
+        return len(self.get_restart_required_keys()) > 0
+
+    def has_changes(self) -> bool:
+        """Check if there are any uncommitted changes."""
+        return len(self._changed_values) > 0
+
+    def commit(self) -> bool:
+        """
+        Commit all tracked changes to the configuration.
+
+        Returns:
+            True if all changes were saved successfully
+        """
+        success, errors = self._config_manager.set_multiple(self._changed_values)
+        if success:
+            self._original_values = self._config_manager.get_all()
+            self._changed_values.clear()
+        return success
+
+    def discard(self) -> None:
+        """Discard all tracked changes."""
+        self._changed_values.clear()

+ 753 - 0
src/ui/glossary_editor.py

@@ -0,0 +1,753 @@
+"""
+Glossary Editor component for UI.
+
+Implements Story 7.19: Glossary management interface with
+table view, add/edit/delete functionality, search filtering,
+category display, and JSON import/export.
+"""
+
+from pathlib import Path
+from typing import List, Optional, Dict
+from enum import Enum
+
+from PyQt6.QtWidgets import (
+    QWidget,
+    QVBoxLayout,
+    QHBoxLayout,
+    QTableView,
+    QPushButton,
+    QLabel,
+    QLineEdit,
+    QComboBox,
+    QDialog,
+    QDialogButtonBox,
+    QFormLayout,
+    QMessageBox,
+    QFileDialog,
+    QGroupBox,
+    QHeaderView,
+    QTextEdit,
+    QMenu,
+)
+from PyQt6.QtCore import Qt, pyqtSignal, QAbstractTableModel, QModelIndex
+from PyQt6.QtGui import QAction, QFont, QKeySequence
+
+from src.glossary.models import Glossary, GlossaryEntry, TermCategory
+
+
+class CategoryFilter(Enum):
+    """Filter options for term categories."""
+    ALL = "all"
+    CHARACTER = "character"
+    SKILL = "skill"
+    LOCATION = "location"
+    ITEM = "item"
+    ORGANIZATION = "organization"
+    OTHER = "other"
+
+
+# Chinese translations for categories
+CATEGORY_NAMES: Dict[TermCategory, str] = {
+    TermCategory.CHARACTER: "人名",
+    TermCategory.SKILL: "技能",
+    TermCategory.LOCATION: "地名",
+    TermCategory.ITEM: "物品",
+    TermCategory.ORGANIZATION: "组织",
+    TermCategory.OTHER: "其他",
+}
+
+
+class GlossaryTableModel(QAbstractTableModel):
+    """
+    Table model for glossary entries.
+
+    Displays: Source, Target, Category, Context columns.
+    """
+
+    # Column indices
+    COL_SOURCE = 0
+    COL_TARGET = 1
+    COL_CATEGORY = 2
+    COL_CONTEXT = 3
+    COLUMN_COUNT = 4
+
+    # Column headers
+    HEADERS = ["原文", "译文", "分类", "备注"]
+
+    def __init__(self, glossary: Glossary, parent=None):
+        """Initialize the model."""
+        super().__init__(parent)
+        self._glossary = glossary
+        self._entries: List[GlossaryEntry] = []
+        self._filtered_entries: List[GlossaryEntry] = []
+        self._category_filter: Optional[TermCategory] = None
+        self._search_text: str = ""
+
+        self._refresh_entries()
+
+    def _refresh_entries(self) -> None:
+        """Refresh entries from glossary and apply filters."""
+        self._entries = self._glossary.get_all()
+        self._apply_filters()
+
+    def _apply_filters(self) -> None:
+        """Apply category and search filters."""
+        self._filtered_entries = self._entries.copy()
+
+        # Apply category filter
+        if self._category_filter is not None:
+            self._filtered_entries = [
+                e for e in self._filtered_entries
+                if e.category == self._category_filter
+            ]
+
+        # Apply search filter
+        if self._search_text:
+            search_lower = self._search_text.lower()
+            self._filtered_entries = [
+                e for e in self._filtered_entries
+                if (search_lower in e.source.lower() or
+                    search_lower in e.target.lower() or
+                    search_lower in e.context.lower())
+            ]
+
+    def set_category_filter(self, category: Optional[TermCategory]) -> None:
+        """Set the category filter."""
+        self.beginResetModel()
+        self._category_filter = category
+        self._apply_filters()
+        self.endResetModel()
+
+    def set_search_text(self, text: str) -> None:
+        """Set the search filter text."""
+        self.beginResetModel()
+        self._search_text = text
+        self._apply_filters()
+        self.endResetModel()
+
+    def refresh(self) -> None:
+        """Refresh all data from the glossary."""
+        self.beginResetModel()
+        self._refresh_entries()
+        self.endResetModel()
+
+    def rowCount(self, parent: QModelIndex = QModelIndex()) -> int:
+        """Return the number of rows."""
+        return len(self._filtered_entries)
+
+    def columnCount(self, parent: QModelIndex = QModelIndex()) -> int:
+        """Return the number of columns."""
+        return self.COLUMN_COUNT
+
+    def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole):
+        """Return data for the given index."""
+        if not index.isValid():
+            return None
+
+        row = index.row()
+        col = index.column()
+
+        if row < 0 or row >= len(self._filtered_entries):
+            return None
+
+        entry = self._filtered_entries[row]
+
+        if role == Qt.ItemDataRole.DisplayRole:
+            if col == self.COL_SOURCE:
+                return entry.source
+            elif col == self.COL_TARGET:
+                return entry.target
+            elif col == self.COL_CATEGORY:
+                return CATEGORY_NAMES.get(entry.category, entry.category.value)
+            elif col == self.COL_CONTEXT:
+                return entry.context if entry.context else "-"
+
+        elif role == Qt.ItemDataRole.TextAlignmentRole:
+            if col == self.COL_CATEGORY:
+                return Qt.AlignmentFlag.AlignCenter
+            return Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
+
+        elif role == Qt.ItemDataRole.FontRole:
+            if col == self.COL_SOURCE:
+                font = QFont()
+                font.setBold(True)
+                return font
+
+        return None
+
+    def headerData(self, section: int, orientation: Qt.Orientation,
+                   role: int = Qt.ItemDataRole.DisplayRole):
+        """Return header data."""
+        if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole:
+            if 0 <= section < self.COLUMN_COUNT:
+                return self.HEADERS[section]
+        return None
+
+    def get_entry_at_row(self, row: int) -> Optional[GlossaryEntry]:
+        """Get the entry at the given row."""
+        if 0 <= row < len(self._filtered_entries):
+            return self._filtered_entries[row]
+        return None
+
+    @property
+    def entry_count(self) -> int:
+        """Get total number of entries (before filtering)."""
+        return len(self._entries)
+
+    @property
+    def filtered_count(self) -> int:
+        """Get number of filtered entries."""
+        return len(self._filtered_entries)
+
+
+class EntryEditDialog(QDialog):
+    """
+    Dialog for adding or editing a glossary entry.
+    """
+
+    def __init__(
+        self,
+        entry: Optional[GlossaryEntry] = None,
+        parent: Optional[QWidget] = None,
+    ) -> None:
+        """Initialize the dialog."""
+        super().__init__(parent)
+        self._entry = entry
+        self._edited_entry: Optional[GlossaryEntry] = None
+
+        self._setup_ui()
+
+        # Populate with existing entry data if editing
+        if entry:
+            self._source_input.setText(entry.source)
+            self._target_input.setText(entry.target)
+            self._category_combo.setCurrentIndex(
+                list(TermCategory).index(entry.category)
+            )
+            self._context_input.setText(entry.context)
+            self.setWindowTitle("编辑术语")
+        else:
+            self.setWindowTitle("添加术语")
+
+    def _setup_ui(self) -> None:
+        """Set up the dialog UI."""
+        self.setMinimumWidth(450)
+
+        layout = QVBoxLayout(self)
+
+        # Form layout
+        form_layout = QFormLayout()
+        form_layout.setSpacing(12)
+
+        # Source term
+        self._source_input = QLineEdit()
+        self._source_input.setPlaceholderText("输入原文(中文)")
+        form_layout.addRow("原文 *:", self._source_input)
+
+        # Target term
+        self._target_input = QLineEdit()
+        self._target_input.setPlaceholderText("输入译文(英文)")
+        form_layout.addRow("译文 *:", self._target_input)
+
+        # Category
+        self._category_combo = QComboBox()
+        for category in TermCategory:
+            self._category_combo.addItem(CATEGORY_NAMES[category], category)
+        form_layout.addRow("分类 *:", self._category_combo)
+
+        # Context
+        self._context_input = QTextEdit()
+        self._context_input.setPlaceholderText("可选:添加备注或上下文说明")
+        self._context_input.setMaximumHeight(80)
+        form_layout.addRow("备注:", self._context_input)
+
+        layout.addLayout(form_layout)
+
+        # Buttons
+        button_box = QDialogButtonBox(
+            QDialogButtonBox.StandardButton.Ok |
+            QDialogButtonBox.StandardButton.Cancel
+        )
+        button_box.accepted.connect(self._on_accept)
+        button_box.rejected.connect(self.reject)
+        layout.addWidget(button_box)
+
+    def _on_accept(self) -> None:
+        """Handle OK button click."""
+        source = self._source_input.text().strip()
+        target = self._target_input.text().strip()
+        context = self._context_input.toPlainText().strip()
+
+        # Validate
+        if not source:
+            QMessageBox.warning(self, "验证错误", "原文不能为空")
+            self._source_input.setFocus()
+            return
+
+        if not target:
+            QMessageBox.warning(self, "验证错误", "译文不能为空")
+            self._target_input.setFocus()
+            return
+
+        # Get category
+        category = self._category_combo.currentData()
+
+        # Create entry
+        self._edited_entry = GlossaryEntry(
+            source=source,
+            target=target,
+            category=category,
+            context=context
+        )
+
+        self.accept()
+
+    def get_entry(self) -> Optional[GlossaryEntry]:
+        """Get the edited entry."""
+        return self._edited_entry
+
+
+class GlossaryEditor(QWidget):
+    """
+    Glossary editor widget (Story 7.19).
+
+    Features:
+    - QTableView displaying glossary entries
+    - Add/Edit/Delete term buttons
+    - Search filter functionality
+    - Category filtering (人名/地名/技能/物品/组织/其他)
+    - Import/Export JSON functionality
+    """
+
+    # Signals
+    entry_added = pyqtSignal(object)  # GlossaryEntry
+    entry_edited = pyqtSignal(object)  # GlossaryEntry
+    entry_deleted = pyqtSignal(str)  # source term
+    glossary_changed = pyqtSignal()
+
+    def __init__(self, glossary: Glossary, parent: Optional[QWidget] = None) -> None:
+        """Initialize the glossary editor."""
+        super().__init__(parent)
+
+        self._glossary = glossary
+        self._model: Optional[GlossaryTableModel] = None
+        self._current_file_path: Optional[Path] = None
+
+        self._setup_ui()
+        self._connect_signals()
+
+        # Load initial data
+        self.refresh_data()
+
+    def _setup_ui(self) -> None:
+        """Set up the UI components."""
+        layout = QVBoxLayout(self)
+        layout.setContentsMargins(8, 8, 8, 8)
+        layout.setSpacing(8)
+
+        # Top control panel
+        control_layout = QHBoxLayout()
+        control_layout.setSpacing(8)
+
+        # Search box
+        search_group = QGroupBox("搜索")
+        search_layout = QHBoxLayout(search_group)
+        search_layout.setContentsMargins(4, 4, 4, 4)
+        self._search_input = QLineEdit()
+        self._search_input.setPlaceholderText("搜索原文、译文或备注...")
+        self._search_input.setClearButtonEnabled(True)
+        search_layout.addWidget(self._search_input)
+        control_layout.addWidget(search_group, 1)
+
+        # Category filter
+        filter_group = QGroupBox("分类筛选")
+        filter_layout = QHBoxLayout(filter_group)
+        filter_layout.setContentsMargins(4, 4, 4, 4)
+        self._category_combo = QComboBox()
+        self._category_combo.addItem("全部", None)
+        for category in TermCategory:
+            self._category_combo.addItem(CATEGORY_NAMES[category], category)
+        filter_layout.addWidget(self._category_combo)
+        control_layout.addWidget(filter_group)
+
+        # Stats label
+        self._stats_label = QLabel("共 0 条术语")
+        self._stats_label.setStyleSheet("font-weight: bold; color: #2c3e50;")
+        control_layout.addWidget(self._stats_label)
+
+        layout.addLayout(control_layout)
+
+        # Glossary table
+        self._table = QTableView()
+        self._table.setAlternatingRowColors(True)
+        self._table.setSelectionBehavior(QTableView.SelectionBehavior.SelectRows)
+        self._table.setSelectionMode(QTableView.SelectionMode.ExtendedSelection)
+        self._table.setSortingEnabled(False)
+        self._table.horizontalHeader().setStretchLastSection(True)
+        self._table.horizontalHeader().setSectionResizeMode(
+            0, QHeaderView.ResizeMode.ResizeToContents
+        )
+        self._table.horizontalHeader().setSectionResizeMode(
+            1, QHeaderView.ResizeMode.ResizeToContents
+        )
+        self._table.horizontalHeader().setSectionResizeMode(
+            2, QHeaderView.ResizeMode.ResizeToContents
+        )
+        self._table.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
+        layout.addWidget(self._table)
+
+        # Create model
+        self._model = GlossaryTableModel(self._glossary, self)
+        self._table.setModel(self._model)
+
+        # Bottom action panel
+        action_layout = QHBoxLayout()
+        action_layout.setSpacing(8)
+
+        # CRUD buttons
+        self._add_btn = QPushButton("添加术语")
+        self._add_btn.setToolTip("添加新的术语条目")
+        self._add_btn.setMinimumWidth(100)
+
+        self._edit_btn = QPushButton("编辑")
+        self._edit_btn.setToolTip("编辑选中的术语")
+        self._edit_btn.setEnabled(False)
+        self._edit_btn.setMinimumWidth(80)
+
+        self._delete_btn = QPushButton("删除")
+        self._delete_btn.setToolTip("删除选中的术语")
+        self._delete_btn.setEnabled(False)
+        self._delete_btn.setMinimumWidth(80)
+
+        action_layout.addWidget(self._add_btn)
+        action_layout.addWidget(self._edit_btn)
+        action_layout.addWidget(self._delete_btn)
+
+        action_layout.addStretch()
+
+        # Import/Export buttons
+        self._import_btn = QPushButton("导入...")
+        self._import_btn.setToolTip("从 JSON 文件导入术语表")
+        self._import_btn.setMinimumWidth(80)
+
+        self._export_btn = QPushButton("导出...")
+        self._export_btn.setToolTip("导出术语表到 JSON 文件")
+        self._export_btn.setMinimumWidth(80)
+
+        action_layout.addWidget(self._import_btn)
+        action_layout.addWidget(self._export_btn)
+
+        layout.addLayout(action_layout)
+
+    def _connect_signals(self) -> None:
+        """Connect internal signals."""
+        self._add_btn.clicked.connect(self._on_add_entry)
+        self._edit_btn.clicked.connect(self._on_edit_entry)
+        self._delete_btn.clicked.connect(self._on_delete_entry)
+        self._import_btn.clicked.connect(self._on_import)
+        self._export_btn.clicked.connect(self._on_export)
+
+        self._search_input.textChanged.connect(self._on_search_changed)
+        self._category_combo.currentIndexChanged.connect(self._on_category_changed)
+
+        self._table.selectionModel().selectionChanged.connect(self._on_selection_changed)
+        self._table.customContextMenuRequested.connect(self._on_context_menu)
+        self._table.doubleClicked.connect(self._on_edit_entry)
+
+    def _on_selection_changed(self) -> None:
+        """Handle table selection change."""
+        has_selection = len(self._table.selectionModel().selectedRows()) > 0
+        self._edit_btn.setEnabled(has_selection)
+        self._delete_btn.setEnabled(has_selection)
+
+    def _on_search_changed(self, text: str) -> None:
+        """Handle search text change."""
+        self._model.set_search_text(text)
+        self._update_stats()
+
+    def _on_category_changed(self, index: int) -> None:
+        """Handle category filter change."""
+        category = self._category_combo.currentData()
+        self._model.set_category_filter(category)
+        self._update_stats()
+
+    def _update_stats(self) -> None:
+        """Update the statistics label."""
+        total = self._model.entry_count
+        filtered = self._model.filtered_count
+
+        if filtered == total:
+            self._stats_label.setText(f"共 {total} 条术语")
+        else:
+            self._stats_label.setText(f"显示 {filtered} / {total} 条术语")
+
+    def _on_add_entry(self) -> None:
+        """Handle add entry button click."""
+        dialog = EntryEditDialog(parent=self)
+        if dialog.exec() == QDialog.DialogCode.Accepted:
+            entry = dialog.get_entry()
+            if entry:
+                try:
+                    self._glossary.add(entry)
+                    self._model.refresh()
+                    self._update_stats()
+                    self.entry_added.emit(entry)
+                    self.glossary_changed.emit()
+                except ValueError as e:
+                    QMessageBox.warning(
+                        self,
+                        "添加失败",
+                        f"无法添加术语:{e}"
+                    )
+
+    def _on_edit_entry(self) -> None:
+        """Handle edit entry button click."""
+        selected = self._table.selectionModel().selectedRows()
+        if not selected:
+            return
+
+        row = selected[0].row()
+        old_entry = self._model.get_entry_at_row(row)
+
+        if old_entry:
+            dialog = EntryEditDialog(entry=old_entry, parent=self)
+            if dialog.exec() == QDialog.DialogCode.Accepted:
+                new_entry = dialog.get_entry()
+                if new_entry:
+                    # Remove old entry and add new one
+                    self._glossary.remove(old_entry.source)
+                    try:
+                        self._glossary.add(new_entry)
+                        self._model.refresh()
+                        self.entry_edited.emit(new_entry)
+                        self.glossary_changed.emit()
+                    except ValueError as e:
+                        # Rollback on error
+                        self._glossary.add(old_entry)
+                        QMessageBox.warning(
+                            self,
+                            "编辑失败",
+                            f"无法保存修改:{e}"
+                        )
+
+    def _on_delete_entry(self) -> None:
+        """Handle delete entry button click."""
+        selected = self._table.selectionModel().selectedRows()
+        if not selected:
+            return
+
+        count = len(selected)
+        if count == 1:
+            row = selected[0].row()
+            entry = self._model.get_entry_at_row(row)
+            if entry:
+                reply = QMessageBox.question(
+                    self,
+                    "确认删除",
+                    f"确定要删除术语「{entry.source}」吗?",
+                    QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
+                    QMessageBox.StandardButton.No
+                )
+
+                if reply == QMessageBox.StandardButton.Yes:
+                    self._glossary.remove(entry.source)
+                    self._model.refresh()
+                    self._update_stats()
+                    self.entry_deleted.emit(entry.source)
+                    self.glossary_changed.emit()
+        else:
+            # Multiple deletion
+            sources = []
+            for index in selected:
+                row = index.row()
+                entry = self._model.get_entry_at_row(row)
+                if entry:
+                    sources.append(entry.source)
+
+            reply = QMessageBox.question(
+                self,
+                "确认删除",
+                f"确定要删除选中的 {count} 条术语吗?",
+                QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
+                QMessageBox.StandardButton.No
+            )
+
+            if reply == QMessageBox.StandardButton.Yes:
+                for source in sources:
+                    self._glossary.remove(source)
+
+                self._model.refresh()
+                self._update_stats()
+                for source in sources:
+                    self.entry_deleted.emit(source)
+                self.glossary_changed.emit()
+
+    def _on_import(self) -> None:
+        """Handle import button click."""
+        file_path, _ = QFileDialog.getOpenFileName(
+            self,
+            "导入术语表",
+            str(Path.home()),
+            "JSON Files (*.json);;All Files (*)"
+        )
+
+        if file_path:
+            path = Path(file_path)
+            try:
+                self._glossary.load_from_file(path)
+                self._model.refresh()
+                self._update_stats()
+                self._current_file_path = path
+                self.glossary_changed.emit()
+                QMessageBox.information(
+                    self,
+                    "导入成功",
+                    f"成功导入 {len(self._glossary)} 条术语。"
+                )
+            except FileNotFoundError:
+                QMessageBox.warning(
+                    self,
+                    "导入失败",
+                    f"文件不存在:{path}"
+                )
+            except Exception as e:
+                QMessageBox.warning(
+                    self,
+                    "导入失败",
+                    f"导入时发生错误:\n{e}"
+                )
+
+    def _on_export(self) -> None:
+        """Handle export button click."""
+        default_name = self._current_file_path or "glossary.json"
+        default_path = Path(default_name)
+
+        file_path, _ = QFileDialog.getSaveFileName(
+            self,
+            "导出术语表",
+            str(default_path),
+            "JSON Files (*.json);;All Files (*)"
+        )
+
+        if file_path:
+            path = Path(file_path)
+            try:
+                self._glossary.save_to_file(path)
+                self._current_file_path = path
+                QMessageBox.information(
+                    self,
+                    "导出成功",
+                    f"术语表已保存到:\n{path}"
+                )
+            except Exception as e:
+                QMessageBox.warning(
+                    self,
+                    "导出失败",
+                    f"导出时发生错误:\n{e}"
+                )
+
+    def _on_context_menu(self, pos) -> None:
+        """Show context menu for table."""
+        index = self._table.indexAt(pos)
+        if not index.isValid():
+            return
+
+        row = index.row()
+        entry = self._model.get_entry_at_row(row)
+
+        if not entry:
+            return
+
+        menu = QMenu(self)
+
+        add_action = QAction("添加术语", self)
+        add_action.triggered.connect(self._on_add_entry)
+        menu.addAction(add_action)
+
+        edit_action = QAction("编辑", self)
+        edit_action.triggered.connect(self._on_edit_entry)
+        menu.addAction(edit_action)
+
+        delete_action = QAction("删除", self)
+        delete_action.triggered.connect(self._on_delete_entry)
+        menu.addAction(delete_action)
+
+        menu.addSeparator()
+
+        copy_source_action = QAction(f"复制原文「{entry.source}」", self)
+        copy_source_action.triggered.connect(lambda: self._copy_to_clipboard(entry.source))
+        menu.addAction(copy_source_action)
+
+        copy_target_action = QAction(f"复制译文「{entry.target}」", self)
+        copy_target_action.triggered.connect(lambda: self._copy_to_clipboard(entry.target))
+        menu.addAction(copy_target_action)
+
+        menu.exec(self._table.viewport().mapToGlobal(pos))
+
+    def _copy_to_clipboard(self, text: str) -> None:
+        """Copy text to clipboard."""
+        from PyQt6.QtWidgets import QApplication
+        QApplication.clipboard().setText(text)
+
+    def refresh_data(self) -> None:
+        """Refresh all data from the glossary."""
+        self._model.refresh()
+        self._update_stats()
+
+    def set_glossary(self, glossary: Glossary) -> None:
+        """Set a new glossary."""
+        self._glossary = glossary
+        self._model = GlossaryTableModel(glossary, self)
+        self._table.setModel(self._model)
+        self.refresh_data()
+
+    @property
+    def glossary(self) -> Glossary:
+        """Get the current glossary."""
+        return self._glossary
+
+
+class GlossaryEditorDialog(QDialog):
+    """
+    Standalone dialog for the glossary editor.
+    """
+
+    def __init__(
+        self,
+        glossary: Glossary,
+        parent: Optional[QWidget] = None,
+    ) -> None:
+        """Initialize the dialog."""
+        super().__init__(parent)
+
+        self._glossary = glossary
+        self._editor: Optional[GlossaryEditor] = None
+
+        self._setup_ui()
+
+    def _setup_ui(self) -> None:
+        """Set up the dialog UI."""
+        self.setWindowTitle("术语表编辑器")
+        self.setMinimumSize(800, 600)
+
+        layout = QVBoxLayout(self)
+
+        # Create editor
+        self._editor = GlossaryEditor(self._glossary, self)
+        layout.addWidget(self._editor)
+
+        # Close button
+        button_layout = QHBoxLayout()
+        button_layout.addStretch()
+
+        self._close_btn = QPushButton("关闭")
+        self._close_btn.setMinimumWidth(100)
+        self._close_btn.clicked.connect(self.accept)
+        button_layout.addWidget(self._close_btn)
+
+        layout.addLayout(button_layout)
+
+    def refresh(self) -> None:
+        """Refresh the editor data."""
+        if self._editor:
+            self._editor.refresh_data()

+ 882 - 0
src/ui/i18n.py

@@ -0,0 +1,882 @@
+"""
+Internationalization (i18n) module for UI.
+
+Implements Story 7.12: Multi-language UI support with
+Chinese/English translations and dynamic language switching.
+"""
+
+from enum import Enum
+from pathlib import Path
+from typing import Dict, Optional, Callable
+from dataclasses import dataclass
+
+from PyQt6.QtWidgets import QWidget, QApplication, QMenu, QAction
+from PyQt6.QtCore import QTranslator, QLocale, QSettings, QObject, pyqtSignal
+from PyQt6.QtGui import QAction
+
+
+class SupportedLanguage(Enum):
+    """Supported languages for the application."""
+
+    CHINESE_SIMPLIFIED = ("zh_CN", "简体中文", "Chinese (Simplified)")
+    ENGLISH = ("en_US", "English", "English")
+
+    @property
+    def code(self) -> str:
+        """Get the language code."""
+        return self.value[0]
+
+    @property
+    def native_name(self) -> str:
+        """Get the native language name."""
+        return self.value[1]
+
+    @property
+    def english_name(self) -> str:
+        """Get the English language name."""
+        return self.value[2]
+
+    @classmethod
+    def from_locale(cls, locale: QLocale) -> "SupportedLanguage":
+        """Get SupportedLanguage from QLocale."""
+        code = locale.name()
+        for lang in cls:
+            if lang.code == code:
+                return lang
+        # Default to Chinese
+        return cls.CHINESE_SIMPLIFIED
+
+
+# Translation strings for all UI elements
+# Format: key -> {language_code: translated_string}
+TRANSLATIONS: Dict[str, Dict[str, str]] = {
+    # Menu Bar
+    "menu.file": {
+        "zh_CN": "文件(&F)",
+        "en_US": "&File",
+    },
+    "menu.edit": {
+        "zh_CN": "编辑(&E)",
+        "en_US": "&Edit",
+    },
+    "menu.view": {
+        "zh_CN": "视图(&V)",
+        "en_US": "&View",
+    },
+    "menu.tools": {
+        "zh_CN": "工具(&T)",
+        "en_US": "&Tools",
+    },
+    "menu.help": {
+        "zh_CN": "帮助(&H)",
+        "en_US": "&Help",
+    },
+
+    # File Menu Items
+    "action.add_files": {
+        "zh_CN": "添加文件(&F)...",
+        "en_US": "Add &Files...",
+    },
+    "action.add_folder": {
+        "zh_CN": "添加文件夹(&D)...",
+        "en_US": "Add &Folder...",
+    },
+    "action.export": {
+        "zh_CN": "导出(&X)...",
+        "en_US": "E&xport...",
+    },
+    "action.exit": {
+        "zh_CN": "退出(&X)",
+        "en_US": "E&xit",
+    },
+
+    # Edit Menu Items
+    "action.select_all": {
+        "zh_CN": "全选(&A)",
+        "en_US": "Select &All",
+    },
+    "action.settings": {
+        "zh_CN": "设置(&T)...",
+        "en_US": "&Settings...",
+    },
+    "action.undo": {
+        "zh_CN": "撤销(&U)",
+        "en_US": "&Undo",
+    },
+    "action.redo": {
+        "zh_CN": "重做(&R)",
+        "en_US": "&Redo",
+    },
+    "action.cut": {
+        "zh_CN": "剪切(&T)",
+        "en_US": "Cu&t",
+    },
+    "action.copy": {
+        "zh_CN": "复制(&C)",
+        "en_US": "&Copy",
+    },
+    "action.paste": {
+        "zh_CN": "粘贴(&P)",
+        "en_US": "&Paste",
+    },
+
+    # View Menu Items
+    "action.log_viewer": {
+        "zh_CN": "日志查看器(&L)...",
+        "en_US": "&Log Viewer...",
+    },
+    "action.statistics": {
+        "zh_CN": "统计信息(&S)...",
+        "en_US": "&Statistics...",
+    },
+    "action.fullscreen": {
+        "zh_CN": "全屏模式(&F)",
+        "en_US": "&Full Screen",
+    },
+
+    # Tools Menu Items
+    "action.terminology": {
+        "zh_CN": "术语表编辑器(&T)...",
+        "en_US": "&Terminology Editor...",
+    },
+
+    # Help Menu Items
+    "action.documentation": {
+        "zh_CN": "文档(&D)",
+        "en_US": "&Documentation",
+    },
+    "action.about": {
+        "zh_CN": "关于(&A)",
+        "en_US": "&About",
+    },
+
+    # Toolbar Actions
+    "toolbar.add_files": {
+        "zh_CN": "添加文件",
+        "en_US": "Add Files",
+    },
+    "toolbar.add_folder": {
+        "zh_CN": "添加文件夹",
+        "en_US": "Add Folder",
+    },
+    "toolbar.start": {
+        "zh_CN": "开始",
+        "en_US": "Start",
+    },
+    "toolbar.pause": {
+        "zh_CN": "暂停",
+        "en_US": "Pause",
+    },
+    "toolbar.cancel": {
+        "zh_CN": "取消",
+        "en_US": "Cancel",
+    },
+    "toolbar.settings": {
+        "zh_CN": "设置",
+        "en_US": "Settings",
+    },
+
+    # Buttons
+    "button.add_files": {
+        "zh_CN": "添加文件",
+        "en_US": "Add Files",
+    },
+    "button.add_folder": {
+        "zh_CN": "添加文件夹",
+        "en_US": "Add Folder",
+    },
+    "button.remove": {
+        "zh_CN": "移除",
+        "en_US": "Remove",
+    },
+    "button.clear_all": {
+        "zh_CN": "清空全部",
+        "en_US": "Clear All",
+    },
+    "button.start_translation": {
+        "zh_CN": "开始翻译",
+        "en_US": "Start Translation",
+    },
+    "button.pause": {
+        "zh_CN": "暂停",
+        "en_US": "Pause",
+    },
+    "button.cancel": {
+        "zh_CN": "取消",
+        "en_US": "Cancel",
+    },
+    "button.refresh": {
+        "zh_CN": "刷新",
+        "en_US": "Refresh",
+    },
+    "button.export": {
+        "zh_CN": "导出",
+        "en_US": "Export",
+    },
+    "button.import": {
+        "zh_CN": "导入",
+        "en_US": "Import",
+    },
+    "button.save": {
+        "zh_CN": "保存",
+        "en_US": "Save",
+    },
+    "button.cancel_btn": {
+        "zh_CN": "取消",
+        "en_US": "Cancel",
+    },
+    "button.ok": {
+        "zh_CN": "确定",
+        "en_US": "OK",
+    },
+    "button.apply": {
+        "zh_CN": "应用",
+        "en_US": "Apply",
+    },
+    "button.close": {
+        "zh_CN": "关闭",
+        "en_US": "Close",
+    },
+    "button.yes": {
+        "zh_CN": "是",
+        "en_US": "Yes",
+    },
+    "button.no": {
+        "zh_CN": "否",
+        "en_US": "No",
+    },
+
+    # Labels
+    "label.files": {
+        "zh_CN": "文件",
+        "en_US": "Files",
+    },
+    "label.progress": {
+        "zh_CN": "进度",
+        "en_US": "Progress",
+    },
+    "label.status": {
+        "zh_CN": "状态",
+        "en_US": "Status",
+    },
+    "label.source_files": {
+        "zh_CN": "源文件",
+        "en_US": "Source Files",
+    },
+    "label.target_files": {
+        "zh_CN": "目标文件",
+        "en_US": "Target Files",
+    },
+    "label.file_count": {
+        "zh_CN": "文件数量",
+        "en_US": "File Count",
+    },
+    "label.total_words": {
+        "zh_CN": "总字数",
+        "en_US": "Total Words",
+    },
+    "label.translated_words": {
+        "zh_CN": "已翻译字数",
+        "en_US": "Translated Words",
+    },
+    "label.eta": {
+        "zh_CN": "预计剩余时间",
+        "en_US": "ETA",
+    },
+    "label.elapsed_time": {
+        "zh_CN": "已用时间",
+        "en_US": "Elapsed Time",
+    },
+    "label.translation_speed": {
+        "zh_CN": "翻译速度",
+        "en_US": "Translation Speed",
+    },
+
+    # Status Messages
+    "status.ready": {
+        "zh_CN": "就绪",
+        "en_US": "Ready",
+    },
+    "status.translating": {
+        "zh_CN": "正在翻译",
+        "en_US": "Translating",
+    },
+    "status.paused": {
+        "zh_CN": "已暂停",
+        "en_US": "Paused",
+    },
+    "status.completed": {
+        "zh_CN": "已完成",
+        "en_US": "Completed",
+    },
+    "status.failed": {
+        "zh_CN": "失败",
+        "en_US": "Failed",
+    },
+    "status.cancelled": {
+        "zh_CN": "已取消",
+        "en_US": "Cancelled",
+    },
+    "status.pending": {
+        "zh_CN": "等待中",
+        "en_US": "Pending",
+    },
+    "status.no_files": {
+        "zh_CN": "未选择文件",
+        "en_US": "No files selected",
+    },
+
+    # File Status
+    "file_status.pending": {
+        "zh_CN": "等待中",
+        "en_US": "Pending",
+    },
+    "file_status.importing": {
+        "zh_CN": "导入中",
+        "en_US": "Importing",
+    },
+    "file_status.ready": {
+        "zh_CN": "就绪",
+        "en_US": "Ready",
+    },
+    "file_status.translating": {
+        "zh_CN": "翻译中",
+        "en_US": "Translating",
+    },
+    "file_status.paused": {
+        "zh_CN": "已暂停",
+        "en_US": "Paused",
+    },
+    "file_status.completed": {
+        "zh_CN": "已完成",
+        "en_US": "Completed",
+    },
+    "file_status.failed": {
+        "zh_CN": "失败",
+        "en_US": "Failed",
+    },
+
+    # Dialog Titles
+    "dialog.settings": {
+        "zh_CN": "设置",
+        "en_US": "Settings",
+    },
+    "dialog.about": {
+        "zh_CN": "关于",
+        "en_US": "About",
+    },
+    "dialog.log_viewer": {
+        "zh_CN": "日志查看器",
+        "en_US": "Log Viewer",
+    },
+    "dialog.statistics": {
+        "zh_CN": "统计信息",
+        "en_US": "Statistics",
+    },
+    "dialog.glossary_editor": {
+        "zh_CN": "术语表编辑器",
+        "en_US": "Glossary Editor",
+    },
+    "dialog.shortcuts": {
+        "zh_CN": "快捷键配置",
+        "en_US": "Keyboard Shortcuts",
+    },
+    "dialog.file_select": {
+        "zh_CN": "选择文件",
+        "en_US": "Select File",
+    },
+    "dialog.folder_select": {
+        "zh_CN": "选择文件夹",
+        "en_US": "Select Folder",
+    },
+    "dialog.export": {
+        "zh_CN": "导出",
+        "en_US": "Export",
+    },
+    "dialog.import": {
+        "zh_CN": "导入",
+        "en_US": "Import",
+    },
+
+    # Messages
+    "msg.added_files": {
+        "zh_CN": "已添加 {count} 个文件",
+        "en_US": "Added {count} file(s)",
+    },
+    "msg.removed_files": {
+        "zh_CN": "已移除 {count} 个文件",
+        "en_US": "Removed {count} file(s)",
+    },
+    "msg.translation_started": {
+        "zh_CN": "翻译已开始",
+        "en_US": "Translation started",
+    },
+    "msg.translation_paused": {
+        "zh_CN": "翻译已暂停",
+        "en_US": "Translation paused",
+    },
+    "msg.translation_cancelled": {
+        "zh_CN": "翻译已取消",
+        "en_US": "Translation cancelled",
+    },
+    "msg.translation_completed": {
+        "zh_CN": "翻译已完成",
+        "en_US": "Translation completed",
+    },
+    "msg.file_not_found": {
+        "zh_CN": "文件未找到:{path}",
+        "en_US": "File not found: {path}",
+    },
+    "msg.export_success": {
+        "zh_CN": "导出成功",
+        "en_US": "Export successful",
+    },
+    "msg.import_success": {
+        "zh_CN": "导入成功",
+        "en_US": "Import successful",
+    },
+    "msg.confirm_clear": {
+        "zh_CN": "确定要清空所有文件吗?",
+        "en_US": "Are you sure you want to clear all files?",
+    },
+    "msg.confirm_delete": {
+        "zh_CN": "确定要删除「{name}」吗?",
+        "en_US": "Are you sure you want to delete '{name}'?",
+    },
+
+    # Glossary
+    "glossary.add_term": {
+        "zh_CN": "添加术语",
+        "en_US": "Add Term",
+    },
+    "glossary.edit_term": {
+        "zh_CN": "编辑术语",
+        "en_US": "Edit Term",
+    },
+    "glossary.delete_term": {
+        "zh_CN": "删除术语",
+        "en_US": "Delete Term",
+    },
+    "glossary.source": {
+        "zh_CN": "原文",
+        "en_US": "Source",
+    },
+    "glossary.target": {
+        "zh_CN": "译文",
+        "en_US": "Target",
+    },
+    "glossary.category": {
+        "zh_CN": "分类",
+        "en_US": "Category",
+    },
+    "glossary.context": {
+        "zh_CN": "备注",
+        "en_US": "Context",
+    },
+    "glossary.search": {
+        "zh_CN": "搜索",
+        "en_US": "Search",
+    },
+    "glossary.all_terms": {
+        "zh_CN": "全部",
+        "en_US": "All",
+    },
+    "glossary.character": {
+        "zh_CN": "人名",
+        "en_US": "Character",
+    },
+    "glossary.skill": {
+        "zh_CN": "技能",
+        "en_US": "Skill",
+    },
+    "glossary.location": {
+        "zh_CN": "地名",
+        "en_US": "Location",
+    },
+    "glossary.item": {
+        "zh_CN": "物品",
+        "en_US": "Item",
+    },
+    "glossary.organization": {
+        "zh_CN": "组织",
+        "en_US": "Organization",
+    },
+    "glossary.other": {
+        "zh_CN": "其他",
+        "en_US": "Other",
+    },
+
+    # Log Viewer
+    "log.level_debug": {
+        "zh_CN": "调试",
+        "en_US": "Debug",
+    },
+    "log.level_info": {
+        "zh_CN": "信息",
+        "en_US": "Info",
+    },
+    "log.level_warning": {
+        "zh_CN": "警告",
+        "en_US": "Warning",
+    },
+    "log.level_error": {
+        "zh_CN": "错误",
+        "en_US": "Error",
+    },
+    "log.level_critical": {
+        "zh_CN": "严重",
+        "en_US": "Critical",
+    },
+    "log.filter_level": {
+        "zh_CN": "级别筛选",
+        "en_US": "Level Filter",
+    },
+    "log.all": {
+        "zh_CN": "全部",
+        "en_US": "All",
+    },
+    "log.info_and_above": {
+        "zh_CN": "信息及以上",
+        "en_US": "Info & Above",
+    },
+    "log.warning_and_above": {
+        "zh_CN": "警告及以上",
+        "en_US": "Warning & Above",
+    },
+    "log.error_and_above": {
+        "zh_CN": "错误及以上",
+        "en_US": "Error & Above",
+    },
+    "log.time": {
+        "zh_CN": "时间",
+        "en_US": "Time",
+    },
+    "log.message": {
+        "zh_CN": "消息",
+        "en_US": "Message",
+    },
+    "log.module": {
+        "zh_CN": "模块",
+        "en_US": "Module",
+    },
+    "log.details": {
+        "zh_CN": "日志详情",
+        "en_US": "Log Details",
+    },
+    "log.auto_scroll": {
+        "zh_CN": "自动滚动",
+        "en_US": "Auto Scroll",
+    },
+
+    # Shortcuts
+    "shortcuts.action": {
+        "zh_CN": "操作",
+        "en_US": "Action",
+    },
+    "shortcuts.shortcut": {
+        "zh_CN": "快捷键",
+        "en_US": "Shortcut",
+    },
+    "shortcuts.default": {
+        "zh_CN": "默认",
+        "en_US": "Default",
+    },
+    "shortcuts.reset": {
+        "zh_CN": "恢复默认",
+        "en_US": "Reset to Default",
+    },
+    "shortcuts.reset_all": {
+        "zh_CN": "全部恢复默认",
+        "en_US": "Reset All to Defaults",
+    },
+    "shortcuts.capture": {
+        "zh_CN": "点击设置快捷键",
+        "en_US": "Click to set shortcut",
+    },
+    "shortcuts.filter": {
+        "zh_CN": "筛选",
+        "en_US": "Filter",
+    },
+
+    # Theme
+    "theme.light": {
+        "zh_CN": "亮色主题",
+        "en_US": "Light Theme",
+    },
+    "theme.dark": {
+        "zh_CN": "暗色主题",
+        "en_US": "Dark Theme",
+    },
+    "theme.auto": {
+        "zh_CN": "跟随系统",
+        "en_US": "Auto (System)",
+    },
+
+    # Language
+    "language.chinese": {
+        "zh_CN": "简体中文",
+        "en_US": "Simplified Chinese",
+    },
+    "language.english": {
+        "zh_CN": "English",
+        "en_US": "English",
+    },
+
+    # About
+    "app.name": {
+        "zh_CN": "BMAD 小说翻译器",
+        "en_US": "BMAD Novel Translator",
+    },
+    "app.description": {
+        "zh_CN": "专业的网络小说翻译工具,支持术语表管理、批量处理和进度保存。",
+        "en_US": "Professional novel translation tool with terminology management, batch processing, and progress saving.",
+    },
+    "app.version": {
+        "zh_CN": "版本",
+        "en_US": "Version",
+    },
+}
+
+
+class I18nManager(QObject):
+    """
+    Internationalization manager for multi-language UI support.
+
+    Features:
+    - Chinese (Simplified) and English support
+    - Dynamic language switching
+    - Persistent language preference
+    - Translation lookup by key
+
+    Usage:
+        manager = I18nManager()
+        manager.set_language(SupportedLanguage.ENGLISH)
+        text = manager.translate("button.ok")
+    """
+
+    # Signals
+    language_changed = pyqtSignal(SupportedLanguage)  # Emitted when language changes
+
+    # Settings keys
+    SETTINGS_LANGUAGE = "ui/language"
+
+    def __init__(self, parent: Optional[QObject] = None) -> None:
+        """Initialize the i18n manager."""
+        super().__init__(parent)
+
+        self._current_language: SupportedLanguage = SupportedLanguage.CHINESE_SIMPLIFIED
+        self._settings = QSettings("BMAD", "NovelTranslator")
+        self._translator: Optional[QTranslator] = None
+        self._retranslate_callbacks: List[Callable[[], None]] = []
+
+        # Load saved language preference
+        self._load_settings()
+
+    def _load_settings(self) -> None:
+        """Load language settings from QSettings."""
+        saved_code = self._settings.value(self.SETTINGS_LANGUAGE, "zh_CN")
+        for lang in SupportedLanguage:
+            if lang.code == saved_code:
+                self._current_language = lang
+                break
+
+    def _save_settings(self) -> None:
+        """Save language settings to QSettings."""
+        self._settings.setValue(self.SETTINGS_LANGUAGE, self._current_language.code)
+
+    @property
+    def current_language(self) -> SupportedLanguage:
+        """Get the current language."""
+        return self._current_language
+
+    @property
+    def current_locale(self) -> QLocale:
+        """Get the current locale."""
+        if self._current_language == SupportedLanguage.CHINESE_SIMPLIFIED:
+            return QLocale(QLocale.Language.Chinese, QLocale.Country.China)
+        return QLocale(QLocale.Language.English, QLocale.Country.UnitedStates)
+
+    def set_language(self, language: SupportedLanguage) -> None:
+        """
+        Set the current language.
+
+        Args:
+            language: The language to set
+        """
+        if language != self._current_language:
+            self._current_language = language
+            self._save_settings()
+            self.language_changed.emit(language)
+
+            # Call all retranslate callbacks
+            for callback in self._retranslate_callbacks:
+                callback()
+
+    def register_retranslate_callback(self, callback: Callable[[], None]) -> None:
+        """
+        Register a callback to be called when language changes.
+
+        Args:
+            callback: Function to call on language change
+        """
+        self._retranslate_callbacks.append(callback)
+
+    def translate(self, key: str, **kwargs) -> str:
+        """
+        Translate a key to the current language.
+
+        Args:
+            key: The translation key
+            **kwargs: Format arguments for the translated string
+
+        Returns:
+            The translated string
+        """
+        if key not in TRANSLATIONS:
+            return key
+
+        lang_code = self._current_language.code
+        translations = TRANSLATIONS[key]
+
+        # Get translation or fall back to Chinese
+        translated = translations.get(lang_code, translations.get("zh_CN", key))
+
+        # Apply format arguments
+        if kwargs:
+            try:
+                translated = translated.format(**kwargs)
+            except (KeyError, ValueError):
+                pass
+
+        return translated
+
+    def t(self, key: str, **kwargs) -> str:
+        """
+        Shorthand for translate().
+
+        Args:
+            key: The translation key
+            **kwargs: Format arguments
+
+        Returns:
+            The translated string
+        """
+        return self.translate(key, **kwargs)
+
+    def install_translator(self, app: QApplication) -> None:
+        """
+        Install the Qt translator for the application.
+
+        Args:
+            app: The QApplication instance
+        """
+        # Remove old translator if exists
+        if self._translator:
+            app.removeTranslator(self._translator)
+
+        # Create and install new translator
+        self._translator = QTranslator()
+        ts_path = self._get_translation_file(self._current_language)
+
+        if ts_path and ts_path.exists():
+            self._translator.load(str(ts_path))
+            app.installTranslator(self._translator)
+
+    def _get_translation_file(self, language: SupportedLanguage) -> Optional[Path]:
+        """
+        Get the path to the translation file for a language.
+
+        Args:
+            language: The language to get the file for
+
+        Returns:
+            Path to the .qm file or None
+        """
+        # Try to find translation files in standard locations
+        base_dir = Path(__file__).parent.parent.parent
+
+        possible_paths = [
+            base_dir / "resources" / "i18n" / f"{language.code}.qm",
+            base_dir / "src" / "ui" / "i18n" / f"{language.code}.qm",
+            Path(__file__).parent / "i18n" / f"{language.code}.qm",
+        ]
+
+        for path in possible_paths:
+            if path.exists():
+                return path
+
+        return None
+
+    def get_language_menu(self, parent: Optional[QWidget] = None) -> QMenu:
+        """
+        Create a language selection menu.
+
+        Args:
+            parent: Parent widget
+
+        Returns:
+            QMenu with language options
+        """
+        menu = QMenu(self.t("language.chinese"), parent)
+
+        for language in SupportedLanguage:
+            action = QAction(language.native_name, parent)
+            action.setCheckable(True)
+            action.setChecked(language == self._current_language)
+            action.triggered.connect(lambda checked, l=language: self.set_language(l))
+            menu.addAction(action)
+
+        return menu
+
+
+# Singleton instance
+_i18n_manager_instance: Optional[I18nManager] = None
+
+
+def get_i18n_manager() -> I18nManager:
+    """Get the singleton i18n manager instance."""
+    global _i18n_manager_instance
+    if _i18n_manager_instance is None:
+        _i18n_manager_instance = I18nManager()
+    return _i18n_manager_instance
+
+
+def t(key: str, **kwargs) -> str:
+    """
+    Shorthand function for translation.
+
+    Usage:
+        from src.ui.i18n import t
+        text = t("button.ok")
+
+    Args:
+        key: The translation key
+        **kwargs: Format arguments
+
+    Returns:
+        The translated string
+    """
+    return get_i18n_manager().translate(key, **kwargs)
+
+
+class TranslatableWidget:
+    """
+    Mixin for widgets that support dynamic language switching.
+
+    Widgets using this mixin should implement the retranslate_ui()
+    method and register themselves with the I18nManager.
+    """
+
+    def __init__(self):
+        """Initialize the translatable widget."""
+        self._i18n_manager = get_i18n_manager()
+        self._i18n_manager.register_retranslate_callback(self.retranslate_ui)
+
+    def retranslate_ui(self) -> None:
+        """
+        Called when the language changes.
+
+        Subclasses should override this method to update all
+        translatable strings in the UI.
+        """
+        pass

+ 795 - 0
src/ui/log_viewer.py

@@ -0,0 +1,795 @@
+"""
+Log Viewer component for UI.
+
+Implements Story 7.15: Log viewing interface with
+log level filtering and export functionality.
+"""
+
+from datetime import datetime
+from enum import Enum
+from pathlib import Path
+from typing import List, Optional, Dict
+from dataclasses import dataclass, field
+
+from PyQt6.QtWidgets import (
+    QWidget,
+    QVBoxLayout,
+    QHBoxLayout,
+    QListView,
+    QPushButton,
+    QLabel,
+    QLineEdit,
+    QDialog,
+    QDialogButtonBox,
+    QMessageBox,
+    QFileDialog,
+    QGroupBox,
+    QComboBox,
+    QTableView,
+    QHeaderView,
+    QTextEdit,
+    QCheckBox,
+    QSplitter,
+    QApplication,
+)
+from PyQt6.QtCore import Qt, pyqtSignal, QAbstractListModel, QModelIndex, QSettings, QTimer
+from PyQt6.QtGui import QFont, QPalette, QColor, QAbstractTableModel
+
+
+class LogLevel(Enum):
+    """Log levels."""
+    DEBUG = "DEBUG"
+    INFO = "INFO"
+    WARNING = "WARNING"
+    ERROR = "ERROR"
+    CRITICAL = "CRITICAL"
+
+    @property
+    def display_name(self) -> str:
+        """Get display name in Chinese."""
+        return {
+            LogLevel.DEBUG: "调试",
+            LogLevel.INFO: "信息",
+            LogLevel.WARNING: "警告",
+            LogLevel.ERROR: "错误",
+            LogLevel.CRITICAL: "严重",
+        }[self]
+
+    @property
+    def color(self) -> str:
+        """Get associated color."""
+        return {
+            LogLevel.DEBUG: "#888888",
+            LogLevel.INFO: "#3498db",
+            LogLevel.WARNING: "#f39c12",
+            LogLevel.ERROR: "#e74c3c",
+            LogLevel.CRITICAL: "#8e44ad",
+        }[self]
+
+
+@dataclass
+class LogEntry:
+    """A single log entry."""
+
+    level: LogLevel
+    timestamp: datetime
+    message: str
+    module: str = ""
+    function: str = ""
+    line: int = 0
+    thread: str = ""
+
+    def __str__(self) -> str:
+        """Return formatted log string."""
+        ts = self.timestamp.strftime("%Y-%m-%d %H:%M:%S")
+        return f"[{ts}] [{self.level.value}] {self.message}"
+
+    @property
+    def level_display(self) -> str:
+        """Get the level display name."""
+        return self.level.display_name
+
+    @property
+    def level_color(self) -> str:
+        """Get the level color."""
+        return self.level.color
+
+    @property
+    def timestamp_display(self) -> str:
+        """Get the formatted timestamp."""
+        return self.timestamp.strftime("%H:%M:%S")
+
+    @property
+    def date_display(self) -> str:
+        """Get the formatted date."""
+        return self.timestamp.strftime("%Y-%m-%d")
+
+
+class LogLevelFilter(Enum):
+    """Filter options for log levels."""
+    ALL = "all"
+    DEBUG = "debug"
+    INFO = "info"
+    WARNING = "warning"
+    ERROR = "error"
+    CRITICAL = "critical"
+
+
+class InMemoryLogHandler:
+    """
+    In-memory log handler for capturing log entries.
+
+    This handler can be attached to Python's logging system
+    to capture logs for display in the log viewer.
+    """
+
+    def __init__(self, max_entries: int = 10000) -> None:
+        """Initialize the log handler."""
+        self._max_entries = max_entries
+        self._entries: List[LogEntry] = []
+        self._listeners: List[callable] = []
+
+    def emit(self, level: LogLevel, message: str, **kwargs) -> None:
+        """
+        Emit a log entry.
+
+        Args:
+            level: The log level
+            message: The log message
+            **kwargs: Additional metadata (module, function, line, thread)
+        """
+        entry = LogEntry(
+            level=level,
+            timestamp=datetime.now(),
+            message=message,
+            module=kwargs.get("module", ""),
+            function=kwargs.get("function", ""),
+            line=kwargs.get("line", 0),
+            thread=kwargs.get("thread", "")
+        )
+
+        self._entries.append(entry)
+
+        # Trim if over max
+        if len(self._entries) > self._max_entries:
+            self._entries = self._entries[-self._max_entries:]
+
+        # Notify listeners
+        for listener in self._listeners:
+            listener(entry)
+
+    def add_listener(self, listener: callable) -> None:
+        """Add a listener for new log entries."""
+        self._listeners.append(listener)
+
+    def remove_listener(self, listener: callable) -> None:
+        """Remove a listener."""
+        if listener in self._listeners:
+            self._listeners.remove(listener)
+
+    def get_entries(
+        self,
+        min_level: Optional[LogLevel] = None,
+        search_text: str = "",
+        limit: int = 0
+    ) -> List[LogEntry]:
+        """
+        Get log entries with optional filtering.
+
+        Args:
+            min_level: Minimum log level to include
+            search_text: Text to search for
+            limit: Maximum number of entries (0 = no limit)
+
+        Returns:
+            Filtered list of log entries
+        """
+        entries = self._entries
+
+        # Filter by level
+        if min_level:
+            level_order = {
+                LogLevel.DEBUG: 0,
+                LogLevel.INFO: 1,
+                LogLevel.WARNING: 2,
+                LogLevel.ERROR: 3,
+                LogLevel.CRITICAL: 4,
+            }
+            min_order = level_order[min_level]
+            entries = [
+                e for e in entries
+                if level_order[e.level] >= min_order
+            ]
+
+        # Filter by search text
+        if search_text:
+            search_lower = search_text.lower()
+            entries = [
+                e for e in entries
+                if search_lower in e.message.lower() or
+                   search_lower in e.module.lower()
+            ]
+
+        # Apply limit
+        if limit > 0:
+            entries = entries[-limit:]
+
+        return entries
+
+    def clear(self) -> None:
+        """Clear all log entries."""
+        self._entries.clear()
+
+    @property
+    def entry_count(self) -> int:
+        """Get the number of entries."""
+        return len(self._entries)
+
+
+class LogEntry:
+    """Log entry data model."""
+
+    def __init__(
+        self,
+        level: LogLevel,
+        timestamp: datetime,
+        message: str,
+        module: str = "",
+        function: str = "",
+        line: int = 0
+    ):
+        """Initialize the log entry."""
+        self.level = level
+        self.timestamp = timestamp
+        self.message = message
+        self.module = module
+        self.function = function
+        self.line = line
+
+
+class LogListModel(QAbstractListModel):
+    """List model for log entries."""
+
+    def __init__(self, entries: List[LogEntry], parent=None):
+        """Initialize the model."""
+        super().__init__(parent)
+        self._entries = entries
+
+    def rowCount(self, parent: QModelIndex = QModelIndex()) -> int:
+        """Return the number of rows."""
+        return len(self._entries)
+
+    def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole):
+        """Return data for the given index."""
+        if not index.isValid():
+            return None
+
+        row = index.row()
+        if row < 0 or row >= len(self._entries):
+            return None
+
+        entry = self._entries[row]
+
+        if role == Qt.ItemDataRole.DisplayRole:
+            return str(entry)
+
+        elif role == Qt.ItemDataRole.FontRole:
+            font = QFont("Consolas", 9)
+            return font
+
+        elif role == Qt.ItemDataRole.ForegroundRole:
+            return QColor(entry.level_color)
+
+        elif role == Qt.ItemDataRole.BackgroundRole:
+            # Alternating row colors
+            if row % 2 == 0:
+                return QColor("#f8f8f8")
+            return QColor("#ffffff")
+
+        return None
+
+
+class LogTableModel(QAbstractTableModel):
+    """Table model for log entries."""
+
+    COL_TIMESTAMP = 0
+    COL_LEVEL = 1
+    COL_MESSAGE = 2
+    COL_MODULE = 3
+    COLUMN_COUNT = 4
+
+    HEADERS = ["时间", "级别", "消息", "模块"]
+
+    def __init__(self, entries: List[LogEntry], parent=None):
+        """Initialize the model."""
+        super().__init__(parent)
+        self._entries = entries
+
+    def rowCount(self, parent: QModelIndex = QModelIndex()) -> int:
+        """Return the number of rows."""
+        return len(self._entries)
+
+    def columnCount(self, parent: QModelIndex = QModelIndex()) -> int:
+        """Return the number of columns."""
+        return self.COLUMN_COUNT
+
+    def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole):
+        """Return data for the given index."""
+        if not index.isValid():
+            return None
+
+        row = index.row()
+        col = index.column()
+
+        if row < 0 or row >= len(self._entries):
+            return None
+
+        entry = self._entries[row]
+
+        if role == Qt.ItemDataRole.DisplayRole:
+            if col == self.COL_TIMESTAMP:
+                return entry.timestamp_display
+            elif col == self.COL_LEVEL:
+                return entry.level_display
+            elif col == self.COL_MESSAGE:
+                return entry.message
+            elif col == self.COL_MODULE:
+                return entry.module or "-"
+
+        elif role == Qt.ItemDataRole.FontRole:
+            font = QFont()
+            if col == self.COL_MESSAGE:
+                font = QFont("Consolas", 9)
+            return font
+
+        elif role == Qt.ItemDataRole.ForegroundRole:
+            if col == self.COL_LEVEL:
+                return QColor(entry.level_color)
+            return QColor("#2c3e50")
+
+        elif role == Qt.ItemDataRole.BackgroundRole:
+            if row % 2 == 0:
+                return QColor("#f8f8f8")
+            return QColor("#ffffff")
+
+        elif role == Qt.ItemDataRole.TextAlignmentRole:
+            if col == self.COL_TIMESTAMP or col == self.COL_LEVEL:
+                return Qt.AlignmentFlag.AlignCenter
+            return Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
+
+        return None
+
+    def headerData(self, section: int, orientation: Qt.Orientation,
+                   role: int = Qt.ItemDataRole.DisplayRole):
+        """Return header data."""
+        if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole:
+            if 0 <= section < self.COLUMN_COUNT:
+                return self.HEADERS[section]
+        return None
+
+    def get_entry_at_row(self, row: int) -> Optional[LogEntry]:
+        """Get the entry at the given row."""
+        if 0 <= row < len(self._entries):
+            return self._entries[row]
+        return None
+
+
+class LogViewer(QWidget):
+    """
+    Log viewer widget (Story 7.15).
+
+    Features:
+    - QTableView displaying log entries
+    - Log level filtering (INFO/WARNING/ERROR/DEBUG)
+    - Log search functionality
+    - Export logs to file
+    - Auto-refresh with new logs
+    """
+
+    # Signals
+    log_exported = pyqtSignal(str)  # file path
+
+    def __init__(self, log_handler: InMemoryLogHandler, parent: Optional[QWidget] = None) -> None:
+        """Initialize the log viewer."""
+        super().__init__(parent)
+
+        self._log_handler = log_handler
+        self._model: Optional[LogTableModel] = None
+        self._min_level: Optional[LogLevel] = None
+        self._search_text: str = ""
+        self._auto_scroll = True
+        self._max_display = 1000
+
+        self._setup_ui()
+        self._connect_signals()
+        self._connect_log_handler()
+
+        # Initial load
+        self.refresh_logs()
+
+    def _setup_ui(self) -> None:
+        """Set up the UI components."""
+        layout = QVBoxLayout(self)
+        layout.setContentsMargins(8, 8, 8, 8)
+        layout.setSpacing(8)
+
+        # Top control panel
+        control_layout = QHBoxLayout()
+        control_layout.setSpacing(8)
+
+        # Level filter
+        filter_group = QGroupBox("级别筛选")
+        filter_layout = QHBoxLayout(filter_group)
+        filter_layout.setContentsMargins(4, 4, 4, 4)
+        self._level_combo = QComboBox()
+        self._level_combo.addItem("全部", None)
+        self._level_combo.addItem("信息及以上", LogLevel.INFO)
+        self._level_combo.addItem("警告及以上", LogLevel.WARNING)
+        self._level_combo.addItem("错误及以上", LogLevel.ERROR)
+        self._level_combo.setCurrentIndex(0)
+        filter_layout.addWidget(self._level_combo)
+        control_layout.addWidget(filter_group)
+
+        # Search box
+        search_group = QGroupBox("搜索")
+        search_layout = QHBoxLayout(search_group)
+        search_layout.setContentsMargins(4, 4, 4, 4)
+        self._search_input = QLineEdit()
+        self._search_input.setPlaceholderText("搜索日志内容...")
+        self._search_input.setClearButtonEnabled(True)
+        search_layout.addWidget(self._search_input)
+        control_layout.addWidget(search_group, 1)
+
+        # Stats label
+        self._stats_label = QLabel("共 0 条日志")
+        self._stats_label.setStyleSheet("font-weight: bold; color: #2c3e50;")
+        control_layout.addWidget(self._stats_label)
+
+        # Auto-scroll checkbox
+        self._auto_scroll_check = QCheckBox("自动滚动")
+        self._auto_scroll_check.setChecked(True)
+        self._auto_scroll_check.setToolTip("新日志到达时自动滚动到底部")
+        control_layout.addWidget(self._auto_scroll_check)
+
+        layout.addLayout(control_layout)
+
+        # Splitter for table and detail view
+        splitter = QSplitter(Qt.Orientation.Vertical)
+
+        # Log table
+        self._table = QTableView()
+        self._table.setAlternatingRowColors(True)
+        self._table.setSelectionBehavior(QTableView.SelectionBehavior.SelectRows)
+        self._table.setSelectionMode(QTableView.SelectionMode.SingleSelection)
+        self._table.setSortingEnabled(False)
+        self._table.horizontalHeader().setStretchLastSection(True)
+        self._table.horizontalHeader().setSectionResizeMode(
+            0, QHeaderView.ResizeMode.ResizeToContents
+        )
+        self._table.horizontalHeader().setSectionResizeMode(
+            1, QHeaderView.ResizeMode.ResizeToContents
+        )
+        self._table.horizontalHeader().setSectionResizeMode(
+            3, QHeaderView.ResizeMode.ResizeToContents
+        )
+        self._table.verticalHeader().setVisible(False)
+        splitter.addWidget(self._table)
+
+        # Detail view
+        detail_group = QGroupBox("日志详情")
+        detail_layout = QVBoxLayout(detail_group)
+        self._detail_text = QTextEdit()
+        self._detail_text.setReadOnly(True)
+        self._detail_text.setMaximumHeight(150)
+        self._detail_text.setFont(QFont("Consolas", 9))
+        detail_layout.addWidget(self._detail_text)
+        splitter.addWidget(detail_group)
+
+        splitter.setStretchFactor(0, 3)
+        splitter.setStretchFactor(1, 1)
+
+        layout.addWidget(splitter)
+
+        # Create model
+        self._model = LogTableModel([], self)
+        self._table.setModel(self._model)
+
+        # Bottom action panel
+        action_layout = QHBoxLayout()
+        action_layout.setSpacing(8)
+
+        self._refresh_btn = QPushButton("刷新")
+        self._refresh_btn.setToolTip("重新加载日志")
+        self._refresh_btn.setMinimumWidth(80)
+
+        self._clear_btn = QPushButton("清空")
+        self._clear_btn.setToolTip("清空所有日志")
+        self._clear_btn.setMinimumWidth(80)
+
+        action_layout.addWidget(self._refresh_btn)
+        action_layout.addWidget(self._clear_btn)
+        action_layout.addStretch()
+
+        # Export buttons
+        self._export_txt_btn = QPushButton("导出 TXT")
+        self._export_txt_btn.setToolTip("导出日志到文本文件")
+        self._export_txt_btn.setMinimumWidth(100)
+
+        self._export_csv_btn = QPushButton("导出 CSV")
+        self._export_csv_btn.setToolTip("导出日志到 CSV 文件")
+        self._export_csv_btn.setMinimumWidth(100)
+
+        action_layout.addWidget(self._export_txt_btn)
+        action_layout.addWidget(self._export_csv_btn)
+
+        layout.addLayout(action_layout)
+
+    def _connect_signals(self) -> None:
+        """Connect internal signals."""
+        self._refresh_btn.clicked.connect(self.refresh_logs)
+        self._clear_btn.clicked.connect(self.clear_logs)
+        self._export_txt_btn.clicked.connect(lambda: self._export_logs("txt"))
+        self._export_csv_btn.clicked.connect(lambda: self._export_logs("csv"))
+
+        self._search_input.textChanged.connect(self._on_search_changed)
+        self._level_combo.currentIndexChanged.connect(self._on_level_changed)
+        self._auto_scroll_check.toggled.connect(self._on_auto_scroll_changed)
+
+        self._table.selectionModel().selectionChanged.connect(self._on_selection_changed)
+
+    def _connect_log_handler(self) -> None:
+        """Connect to log handler for new entries."""
+        self._log_handler.add_listener(self._on_new_log_entry)
+
+    def _on_new_log_entry(self, entry: LogEntry) -> None:
+        """Handle new log entry from handler."""
+        # Refresh on next timer event to avoid too frequent updates
+        if self._auto_scroll:
+            QApplication.processEvents()
+
+    def _on_selection_changed(self) -> None:
+        """Handle table selection change."""
+        selected = self._table.selectionModel().selectedRows()
+        if selected:
+            row = selected[0].row()
+            entry = self._model.get_entry_at_row(row)
+            if entry:
+                self._show_entry_detail(entry)
+        else:
+            self._detail_text.clear()
+
+    def _show_entry_detail(self, entry: LogEntry) -> None:
+        """Show detailed entry information."""
+        detail = f"""级别: {entry.level_display}
+时间: {entry.timestamp.strftime("%Y-%m-%d %H:%M:%S")}
+模块: {entry.module or "-"}
+函数: {entry.function or "-"}
+行号: {entry.line or "-"}
+线程: {entry.thread or "-"}
+
+消息:
+{entry.message}"""
+        self._detail_text.setText(detail)
+
+    def _on_search_changed(self, text: str) -> None:
+        """Handle search text change."""
+        self._search_text = text
+        self._apply_filters()
+
+    def _on_level_changed(self, index: int) -> None:
+        """Handle level filter change."""
+        self._min_level = self._level_combo.currentData()
+        self._apply_filters()
+
+    def _on_auto_scroll_changed(self, checked: bool) -> None:
+        """Handle auto-scroll toggle."""
+        self._auto_scroll = checked
+
+    def _apply_filters(self) -> None:
+        """Apply all filters and refresh display."""
+        entries = self._log_handler.get_entries(
+            min_level=self._min_level,
+            search_text=self._search_text,
+            limit=self._max_display
+        )
+
+        self._model = LogTableModel(entries, self)
+        self._table.setModel(self._model)
+        self._update_stats()
+
+        # Auto-scroll to bottom if enabled
+        if self._auto_scroll and entries:
+            self._table.scrollToBottom()
+
+    def _update_stats(self) -> None:
+        """Update the statistics label."""
+        total = self._log_handler.entry_count
+        displayed = self._model.rowCount()
+
+        if displayed == total:
+            self._stats_label.setText(f"共 {total} 条日志")
+        else:
+            self._stats_label.setText(f"显示 {displayed} / {total} 条日志")
+
+    def refresh_logs(self) -> None:
+        """Refresh all logs from the handler."""
+        self._apply_filters()
+
+    def clear_logs(self) -> None:
+        """Clear all logs."""
+        reply = QMessageBox.question(
+            self,
+            "确认清空",
+            "确定要清空所有日志吗?",
+            QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
+            QMessageBox.StandardButton.No
+        )
+
+        if reply == QMessageBox.StandardButton.Yes:
+            self._log_handler.clear()
+            self._apply_filters()
+            self._detail_text.clear()
+
+    def _export_logs(self, format: str) -> None:
+        """Export logs to a file."""
+        default_name = f"logs_{datetime.now().strftime('%Y%m%d_%H%M%S')}.{format}"
+        default_path = Path(default_name)
+
+        if format == "txt":
+            file_filter = "Text Files (*.txt);;All Files (*)"
+        else:
+            file_filter = "CSV Files (*.csv);;All Files (*)"
+
+        file_path, _ = QFileDialog.getSaveFileName(
+            self,
+            f"导出日志 ({format.upper()})",
+            str(default_path),
+            file_filter
+        )
+
+        if file_path:
+            path = Path(file_path)
+            try:
+                entries = self._log_handler.get_entries(min_level=self._min_level)
+
+                if format == "txt":
+                    self._export_txt(path, entries)
+                else:
+                    self._export_csv(path, entries)
+
+                self.log_exported.emit(str(path))
+                QMessageBox.information(
+                    self,
+                    "导出成功",
+                    f"已导出 {len(entries)} 条日志到:\n{path}"
+                )
+            except Exception as e:
+                QMessageBox.warning(
+                    self,
+                    "导出失败",
+                    f"导出时发生错误:\n{e}"
+                )
+
+    def _export_txt(self, path: Path, entries: List[LogEntry]) -> None:
+        """Export logs to a text file."""
+        with open(path, "w", encoding="utf-8") as f:
+            f.write(f"BMAD Novel Translator - 日志导出\n")
+            f.write(f"导出时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
+            f.write(f"日志条数: {len(entries)}\n")
+            f.write("=" * 80 + "\n\n")
+
+            for entry in entries:
+                f.write(f"[{entry.timestamp}] [{entry.level.value}] {entry.message}\n")
+
+    def _export_csv(self, path: Path, entries: List[LogEntry]) -> None:
+        """Export logs to a CSV file."""
+        import csv
+
+        with open(path, "w", encoding="utf-8", newline="") as f:
+            writer = csv.writer(f)
+            writer.writerow(["时间", "级别", "消息", "模块", "函数", "行号"])
+
+            for entry in entries:
+                writer.writerow([
+                    entry.timestamp.strftime("%Y-%m-%d %H:%M:%S"),
+                    entry.level.value,
+                    entry.message,
+                    entry.module,
+                    entry.function,
+                    entry.line
+                ])
+
+    def set_max_display(self, max_entries: int) -> None:
+        """Set the maximum number of entries to display."""
+        self._max_display = max_entries
+        self.refresh_logs()
+
+    def add_log(self, level: LogLevel, message: str, **kwargs) -> None:
+        """
+        Add a log entry.
+
+        Args:
+            level: The log level
+            message: The log message
+            **kwargs: Additional metadata
+        """
+        self._log_handler.emit(level, message, **kwargs)
+        self.refresh_logs()
+
+
+class LogViewerDialog(QDialog):
+    """
+    Standalone dialog for the log viewer.
+    """
+
+    def __init__(
+        self,
+        log_handler: InMemoryLogHandler,
+        parent: Optional[QWidget] = None,
+    ) -> None:
+        """Initialize the dialog."""
+        super().__init__(parent)
+
+        self._log_handler = log_handler
+        self._viewer: Optional[LogViewer] = None
+
+        self._setup_ui()
+
+    def _setup_ui(self) -> None:
+        """Set up the dialog UI."""
+        self.setWindowTitle("日志查看器")
+        self.setMinimumSize(900, 600)
+
+        layout = QVBoxLayout(self)
+
+        # Create viewer
+        self._viewer = LogViewer(self._log_handler, self)
+        layout.addWidget(self._viewer)
+
+        # Close button
+        button_layout = QHBoxLayout()
+        button_layout.addStretch()
+
+        self._close_btn = QPushButton("关闭")
+        self._close_btn.setMinimumWidth(100)
+        self._close_btn.clicked.connect(self.accept)
+        button_layout.addWidget(self._close_btn)
+
+        layout.addLayout(button_layout)
+
+    def refresh(self) -> None:
+        """Refresh the viewer data."""
+        if self._viewer:
+            self._viewer.refresh_logs()
+
+
+# Singleton log handler
+_global_log_handler: Optional[InMemoryLogHandler] = None
+
+
+def get_log_handler() -> InMemoryLogHandler:
+    """Get the global log handler instance."""
+    global _global_log_handler
+    if _global_log_handler is None:
+        _global_log_handler = InMemoryLogHandler()
+    return _global_log_handler
+
+
+def log_debug(message: str, **kwargs) -> None:
+    """Log a debug message."""
+    get_log_handler().emit(LogLevel.DEBUG, message, **kwargs)
+
+
+def log_info(message: str, **kwargs) -> None:
+    """Log an info message."""
+    get_log_handler().emit(LogLevel.INFO, message, **kwargs)
+
+
+def log_warning(message: str, **kwargs) -> None:
+    """Log a warning message."""
+    get_log_handler().emit(LogLevel.WARNING, message, **kwargs)
+
+
+def log_error(message: str, **kwargs) -> None:
+    """Log an error message."""
+    get_log_handler().emit(LogLevel.ERROR, message, **kwargs)

+ 728 - 0
src/ui/shortcuts.py

@@ -0,0 +1,728 @@
+"""
+Keyboard Shortcuts Manager for UI.
+
+Implements Story 7.13: Keyboard shortcut support with
+common operation shortcuts and configuration panel.
+"""
+
+from typing import Dict, Optional, List, Tuple
+from dataclasses import dataclass
+from enum import Enum
+
+from PyQt6.QtWidgets import (
+    QWidget,
+    QVBoxLayout,
+    QHBoxLayout,
+    QTableView,
+    QPushButton,
+    QLabel,
+    QLineEdit,
+    QDialog,
+    QDialogButtonBox,
+    QFormLayout,
+    QMessageBox,
+    QGroupBox,
+    QHeaderView,
+    QAbstractItemView,
+    QApplication,
+    QComboBox,
+)
+from PyQt6.QtCore import Qt, pyqtSignal, QAbstractTableModel, QModelIndex, QSettings
+from PyQt6.QtGui import QKeySequence, QAction, QShortcut, QFont
+
+
+class StandardAction(Enum):
+    """Standard application actions with default shortcuts."""
+
+    # File operations
+    OPEN_FILE = "open_file"
+    SAVE_FILE = "save_file"
+    EXPORT = "export"
+    EXIT = "exit"
+
+    # Edit operations
+    UNDO = "undo"
+    REDO = "redo"
+    CUT = "cut"
+    COPY = "copy"
+    PASTE = "paste"
+    SELECT_ALL = "select_all"
+    FIND = "find"
+    SETTINGS = "settings"
+
+    # View operations
+    FULL_SCREEN = "full_screen"
+    ZOOM_IN = "zoom_in"
+    ZOOM_OUT = "zoom_out"
+    RESET_ZOOM = "reset_zoom"
+
+    # Translation operations
+    START_TRANSLATION = "start_translation"
+    PAUSE_TRANSLATION = "pause_translation"
+    CANCEL_TRANSLATION = "cancel_translation"
+
+    # Navigation
+    NEXT_FILE = "next_file"
+    PREVIOUS_FILE = "previous_file"
+    NEXT_CHAPTER = "next_chapter"
+    PREVIOUS_CHAPTER = "previous_chapter"
+
+    # Help
+    HELP = "help"
+    ABOUT = "about"
+
+
+# Default shortcuts mapping
+DEFAULT_SHORTCUTS: Dict[StandardAction, str] = {
+    # File operations
+    StandardAction.OPEN_FILE: "Ctrl+O",
+    StandardAction.SAVE_FILE: "Ctrl+S",
+    StandardAction.EXPORT: "Ctrl+E",
+    StandardAction.EXIT: "Ctrl+Q",
+
+    # Edit operations
+    StandardAction.UNDO: "Ctrl+Z",
+    StandardAction.REDO: "Ctrl+Shift+Z",
+    StandardAction.CUT: "Ctrl+X",
+    StandardAction.COPY: "Ctrl+C",
+    StandardAction.PASTE: "Ctrl+V",
+    StandardAction.SELECT_ALL: "Ctrl+A",
+    StandardAction.FIND: "Ctrl+F",
+    StandardAction.SETTINGS: "Ctrl+,",
+
+    # View operations
+    StandardAction.FULL_SCREEN: "F11",
+    StandardAction.ZOOM_IN: "Ctrl++",
+    StandardAction.ZOOM_OUT: "Ctrl+-",
+    StandardAction.RESET_ZOOM: "Ctrl+0",
+
+    # Translation operations
+    StandardAction.START_TRANSLATION: "F5",
+    StandardAction.PAUSE_TRANSLATION: "F6",
+    StandardAction.CANCEL_TRANSLATION: "Esc",
+
+    # Navigation
+    StandardAction.NEXT_FILE: "Ctrl+Tab",
+    StandardAction.PREVIOUS_FILE: "Ctrl+Shift+Tab",
+    StandardAction.NEXT_CHAPTER: "Alt+Down",
+    StandardAction.PREVIOUS_CHAPTER: "Alt+Up",
+
+    # Help
+    StandardAction.HELP: "F1",
+    StandardAction.ABOUT: "Ctrl+F1",
+}
+
+
+# Display names for actions
+ACTION_NAMES: Dict[StandardAction, str] = {
+    # File operations
+    StandardAction.OPEN_FILE: "打开文件",
+    StandardAction.SAVE_FILE: "保存",
+    StandardAction.EXPORT: "导出",
+    StandardAction.EXIT: "退出",
+
+    # Edit operations
+    StandardAction.UNDO: "撤销",
+    StandardAction.REDO: "重做",
+    StandardAction.CUT: "剪切",
+    StandardAction.COPY: "复制",
+    StandardAction.PASTE: "粘贴",
+    StandardAction.SELECT_ALL: "全选",
+    StandardAction.FIND: "查找",
+    StandardAction.SETTINGS: "设置",
+
+    # View operations
+    StandardAction.FULL_SCREEN: "全屏",
+    StandardAction.ZOOM_IN: "放大",
+    StandardAction.ZOOM_OUT: "缩小",
+    StandardAction.RESET_ZOOM: "重置缩放",
+
+    # Translation operations
+    StandardAction.START_TRANSLATION: "开始翻译",
+    StandardAction.PAUSE_TRANSLATION: "暂停翻译",
+    StandardAction.CANCEL_TRANSLATION: "取消翻译",
+
+    # Navigation
+    StandardAction.NEXT_FILE: "下一个文件",
+    StandardAction.PREVIOUS_FILE: "上一个文件",
+    StandardAction.NEXT_CHAPTER: "下一章",
+    StandardAction.PREVIOUS_CHAPTER: "上一章",
+
+    # Help
+    StandardAction.HELP: "帮助",
+    StandardAction.ABOUT: "关于",
+}
+
+
+@dataclass
+class ShortcutEntry:
+    """An entry in the shortcuts configuration."""
+
+    action: StandardAction
+    key_sequence: str
+    default_key_sequence: str
+    name: str
+    category: str
+
+    @property
+    def is_modified(self) -> bool:
+        """Check if shortcut has been modified from default."""
+        return self.key_sequence != self.default_key_sequence
+
+
+class ShortcutsManager:
+    """
+    Manager for keyboard shortcuts.
+
+    Features:
+    - Default shortcut mappings
+    - Persistent storage via QSettings
+    - Shortcut conflict detection
+    - Reset to defaults
+    """
+
+    # Settings keys
+    SETTINGS_PREFIX = "shortcuts/"
+
+    def __init__(self) -> None:
+        """Initialize the shortcuts manager."""
+        self._settings = QSettings("BMAD", "NovelTranslator")
+        self._shortcuts: Dict[StandardAction, QKeySequence] = {}
+        self._actions: Dict[StandardAction, QAction] = {}
+
+        # Load shortcuts from settings
+        self._load_settings()
+
+    def _load_settings(self) -> None:
+        """Load shortcuts from QSettings."""
+        for action, default_str in DEFAULT_SHORTCUTS.items():
+            saved_str = self._settings.value(
+                self.SETTINGS_PREFIX + action.value,
+                default_str
+            )
+            self._shortcuts[action] = QKeySequence(saved_str)
+
+    def _save_settings(self) -> None:
+        """Save shortcuts to QSettings."""
+        for action, key_seq in self._shortcuts.items():
+            self._settings.setValue(
+                self.SETTINGS_PREFIX + action.value,
+                key_seq.toString()
+            )
+
+    def get_shortcut(self, action: StandardAction) -> QKeySequence:
+        """
+        Get the key sequence for an action.
+
+        Args:
+            action: The action to get the shortcut for
+
+        Returns:
+            The key sequence for the action
+        """
+        return self._shortcuts.get(action, QKeySequence())
+
+    def set_shortcut(self, action: StandardAction, key_sequence: str) -> bool:
+        """
+        Set a shortcut for an action.
+
+        Args:
+            action: The action to set the shortcut for
+            key_sequence: The new key sequence string
+
+        Returns:
+            True if successful, False if there's a conflict
+        """
+        # Check for conflicts
+        new_seq = QKeySequence(key_sequence)
+        if not new_seq.isEmpty():
+            for existing_action, existing_seq in self._shortcuts.items():
+                if existing_action != action and existing_seq == new_seq:
+                    return False  # Conflict detected
+
+        self._shortcuts[action] = new_seq
+        self._save_settings()
+
+        # Update associated QAction if exists
+        if action in self._actions:
+            self._actions[action].setShortcut(new_seq)
+
+        return True
+
+    def register_action(self, action: StandardAction, qaction: QAction) -> None:
+        """
+        Register a QAction with a standard action.
+
+        Args:
+            action: The standard action to register
+            qaction: The QAction to associate
+        """
+        self._actions[action] = qaction
+        qaction.setShortcut(self._shortcuts[action])
+
+    def create_action(
+        self,
+        action: StandardAction,
+        parent: Optional[QWidget] = None,
+        callback: Optional[callable] = None,
+    ) -> QAction:
+        """
+        Create a QAction with the appropriate shortcut.
+
+        Args:
+            action: The standard action
+            parent: Parent widget
+            callback: Optional callback function
+
+        Returns:
+            Configured QAction
+        """
+        qaction = QAction(ACTION_NAMES[action], parent)
+        qaction.setShortcut(self._shortcuts[action])
+
+        if callback:
+            qaction.triggered.connect(callback)
+
+        self._actions[action] = qaction
+        return qaction
+
+    def reset_to_defaults(self) -> None:
+        """Reset all shortcuts to default values."""
+        for action, default_str in DEFAULT_SHORTCUTS.items():
+            self._shortcuts[action] = QKeySequence(default_str)
+            if action in self._actions:
+                self._actions[action].setShortcut(QKeySequence(default_str))
+
+        self._save_settings()
+
+    def reset_action(self, action: StandardAction) -> None:
+        """
+        Reset a single action to its default shortcut.
+
+        Args:
+            action: The action to reset
+        """
+        default_seq = QKeySequence(DEFAULT_SHORTCUTS[action])
+        self._shortcuts[action] = default_seq
+
+        if action in self._actions:
+            self._actions[action].setShortcut(default_seq)
+
+        self._save_settings()
+
+    def get_all_entries(self) -> List[ShortcutEntry]:
+        """
+        Get all shortcut entries.
+
+        Returns:
+            List of all shortcut entries
+        """
+        entries = []
+
+        # Group by category
+        categories = {
+            StandardAction.OPEN_FILE: "文件",
+            StandardAction.SAVE_FILE: "文件",
+            StandardAction.EXPORT: "文件",
+            StandardAction.EXIT: "文件",
+            StandardAction.UNDO: "编辑",
+            StandardAction.REDO: "编辑",
+            StandardAction.CUT: "编辑",
+            StandardAction.COPY: "编辑",
+            StandardAction.PASTE: "编辑",
+            StandardAction.SELECT_ALL: "编辑",
+            StandardAction.FIND: "编辑",
+            StandardAction.SETTINGS: "编辑",
+            StandardAction.FULL_SCREEN: "视图",
+            StandardAction.ZOOM_IN: "视图",
+            StandardAction.ZOOM_OUT: "视图",
+            StandardAction.RESET_ZOOM: "视图",
+            StandardAction.START_TRANSLATION: "翻译",
+            StandardAction.PAUSE_TRANSLATION: "翻译",
+            StandardAction.CANCEL_TRANSLATION: "翻译",
+            StandardAction.NEXT_FILE: "导航",
+            StandardAction.PREVIOUS_FILE: "导航",
+            StandardAction.NEXT_CHAPTER: "导航",
+            StandardAction.PREVIOUS_CHAPTER: "导航",
+            StandardAction.HELP: "帮助",
+            StandardAction.ABOUT: "帮助",
+        }
+
+        for action in StandardAction:
+            key_seq = self._shortcuts[action].toString()
+            default_seq = DEFAULT_SHORTCUTS[action]
+            category = categories.get(action, "其他")
+
+            entries.append(ShortcutEntry(
+                action=action,
+                key_sequence=key_seq,
+                default_key_sequence=default_seq,
+                name=ACTION_NAMES[action],
+                category=category
+            ))
+
+        return entries
+
+
+class ShortcutsModel(QAbstractTableModel):
+    """Table model for shortcuts configuration."""
+
+    COL_ACTION = 0
+    COL_SHORTCUT = 1
+    COL_DEFAULT = 2
+    COLUMN_COUNT = 3
+
+    HEADERS = ["操作", "快捷键", "默认"]
+
+    def __init__(self, entries: List[ShortcutEntry], parent=None):
+        """Initialize the model."""
+        super().__init__(parent)
+        self._entries = entries
+
+    def rowCount(self, parent: QModelIndex = QModelIndex()) -> int:
+        """Return the number of rows."""
+        return len(self._entries)
+
+    def columnCount(self, parent: QModelIndex = QModelIndex()) -> int:
+        """Return the number of columns."""
+        return self.COLUMN_COUNT
+
+    def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole):
+        """Return data for the given index."""
+        if not index.isValid():
+            return None
+
+        row = index.row()
+        col = index.column()
+
+        if row < 0 or row >= len(self._entries):
+            return None
+
+        entry = self._entries[row]
+
+        if role == Qt.ItemDataRole.DisplayRole:
+            if col == self.COL_ACTION:
+                return entry.name
+            elif col == self.COL_SHORTCUT:
+                return entry.key_sequence
+            elif col == self.COL_DEFAULT:
+                return entry.default_key_sequence
+
+        elif role == Qt.ItemDataRole.FontRole:
+            if col == self.COL_SHORTCUT and entry.is_modified:
+                font = QFont()
+                font.setBold(True)
+                return font
+
+        elif role == Qt.ItemDataRole.TextAlignmentRole:
+            if col == self.COL_SHORTCUT or col == self.COL_DEFAULT:
+                return Qt.AlignmentFlag.AlignCenter
+            return Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
+
+        return None
+
+    def headerData(self, section: int, orientation: Qt.Orientation,
+                   role: int = Qt.ItemDataRole.DisplayRole):
+        """Return header data."""
+        if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole:
+            if 0 <= section < self.COLUMN_COUNT:
+                return self.HEADERS[section]
+        return None
+
+    def get_entry_at_row(self, row: int) -> Optional[ShortcutEntry]:
+        """Get the entry at the given row."""
+        if 0 <= row < len(self._entries):
+            return self._entries[row]
+        return None
+
+
+class ShortcutCaptureButton(QPushButton):
+    """
+    Button that captures key sequences when clicked.
+
+    When clicked, it waits for the user to press a key combination
+    and displays the resulting shortcut.
+    """
+
+    shortcut_captured = pyqtSignal(str)  # Emitted when a shortcut is captured
+
+    def __init__(self, parent: Optional[QWidget] = None) -> None:
+        """Initialize the button."""
+        super().__init__("点击设置快捷键", parent)
+
+        self._capturing = False
+        self._current_sequence: str = ""
+        self._pressed_keys: List[int] = []
+
+        self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
+        self.clicked.connect(self._start_capture)
+
+    def set_shortcut(self, sequence: str) -> None:
+        """Set the current shortcut display."""
+        self._current_sequence = sequence
+        if sequence:
+            self.setText(QKeySequence(sequence).toString())
+        else:
+            self.setText("点击设置快捷键")
+
+    def _start_capture(self) -> None:
+        """Start capturing keyboard input."""
+        self._capturing = True
+        self._pressed_keys.clear()
+        self.setText("按下快捷键...")
+        self.grabKeyboard()
+        self.setFocus()
+
+    def _stop_capture(self) -> None:
+        """Stop capturing and emit the captured shortcut."""
+        self._capturing = False
+        self.releaseKeyboard()
+
+        if self._pressed_keys:
+            # Create key sequence from pressed keys
+            modifiers = 0
+            key = 0
+
+            for k in self._pressed_keys:
+                if int(k) & (Qt.Key.Key_ShiftModifier | Qt.Key.Key_ControlModifier |
+                             Qt.Key.Key_AltModifier | Qt.Key.Key_MetaModifier):
+                    modifiers |= int(k)
+                else:
+                    key = int(k) & ~int(Qt.Key.Key.KeypadModifier)
+
+            if key:
+                sequence = QKeySequence(modifiers | key).toString()
+                self._current_sequence = sequence
+                self.setText(sequence)
+                self.shortcut_captured.emit(sequence)
+            else:
+                self.setText("点击设置快捷键")
+        else:
+            self.setText("点击设置快捷键")
+
+        self._pressed_keys.clear()
+
+    def keyPressEvent(self, event) -> None:
+        """Handle key press event during capture."""
+        if self._capturing:
+            key = event.key()
+            modifiers = event.modifiers()
+
+            # Ignore modifier-only presses
+            if key in (Qt.Key.Key_Shift, Qt.Key.Key_Control, Qt.Key.Key_Alt, Qt.Key.Key_Meta):
+                return
+
+            # Build the sequence
+            self._pressed_keys.append(modifiers | key)
+            self._stop_capture()
+        else:
+            super().keyPressEvent(event)
+
+    def keyReleaseEvent(self, event) -> None:
+        """Handle key release event."""
+        if self._capturing:
+            event.accept()
+        else:
+            super().keyReleaseEvent(event)
+
+
+class ShortcutsDialog(QDialog):
+    """
+    Dialog for configuring keyboard shortcuts.
+    """
+
+    shortcuts_changed = pyqtSignal()
+
+    def __init__(self, manager: ShortcutsManager, parent: Optional[QWidget] = None) -> None:
+        """Initialize the dialog."""
+        super().__init__(parent)
+
+        self._manager = manager
+        self._model: Optional[ShortcutsModel] = None
+        self._selected_entry: Optional[ShortcutEntry] = None
+
+        self._setup_ui()
+
+    def _setup_ui(self) -> None:
+        """Set up the dialog UI."""
+        self.setWindowTitle("快捷键配置")
+        self.setMinimumSize(600, 500)
+
+        layout = QVBoxLayout(self)
+
+        # Instructions
+        info_label = QLabel(
+            "双击表格中的快捷键列可以自定义快捷键。"
+            "点击"恢复默认"可以重置选中的快捷键。"
+        )
+        info_label.setWordWrap(True)
+        info_label.setStyleSheet("color: #666; padding: 4px;")
+        layout.addWidget(info_label)
+
+        # Filter by category
+        filter_layout = QHBoxLayout()
+        filter_layout.addWidget(QLabel("筛选:"))
+        self._category_combo = QComboBox()
+        self._category_combo.addItem("全部")
+        self._category_combo.currentIndexChanged.connect(self._on_category_changed)
+        filter_layout.addWidget(self._category_combo)
+        filter_layout.addStretch()
+        layout.addLayout(filter_layout)
+
+        # Shortcuts table
+        self._table = QTableView()
+        self._table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
+        self._table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
+        self._table.setAlternatingRowColors(True)
+        self._table.horizontalHeader().setStretchLastSection(True)
+        layout.addWidget(self._table)
+
+        # Create and populate model
+        entries = self._manager.get_all_entries()
+        self._all_entries = entries
+        self._model = ShortcutsModel(entries, self)
+        self._table.setModel(self._model)
+
+        # Populate categories
+        categories = sorted(set(e.category for e in entries))
+        for cat in categories:
+            self._category_combo.addItem(cat)
+
+        # Editor panel
+        editor_group = QGroupBox("编辑快捷键")
+        editor_layout = QFormLayout(editor_group)
+
+        self._action_label = QLabel("-")
+        self._action_label.setFont(QFont("", 10, QFont.Weight.Bold))
+        editor_layout.addRow("操作:", self._action_label)
+
+        self._capture_btn = ShortcutCaptureButton()
+        self._capture_btn.shortcut_captured.connect(self._on_shortcut_captured)
+        self._capture_btn.setEnabled(False)
+        editor_layout.addRow("快捷键:", self._capture_btn)
+
+        self._default_label = QLabel("-")
+        editor_layout.addRow("默认:", self._default_label)
+
+        layout.addWidget(editor_group)
+
+        # Buttons
+        button_layout = QHBoxLayout()
+        button_layout.addStretch()
+
+        self._reset_btn = QPushButton("恢复默认")
+        self._reset_btn.setEnabled(False)
+        self._reset_btn.clicked.connect(self._on_reset_to_default)
+        button_layout.addWidget(self._reset_btn)
+
+        reset_all_btn = QPushButton("全部恢复默认")
+        reset_all_btn.clicked.connect(self._on_reset_all)
+        button_layout.addWidget(reset_all_btn)
+
+        self._ok_btn = QPushButton("确定")
+        self._ok_btn.clicked.connect(self.accept)
+        button_layout.addWidget(self._ok_btn)
+
+        layout.addLayout(button_layout)
+
+        # Connect selection
+        self._table.selectionModel().selectionChanged.connect(self._on_selection_changed)
+
+    def _on_selection_changed(self) -> None:
+        """Handle table selection change."""
+        selected = self._table.selectionModel().selectedRows()
+        if selected:
+            row = selected[0].row()
+            entry = self._model.get_entry_at_row(row)
+            if entry:
+                self._selected_entry = entry
+                self._action_label.setText(entry.name)
+                self._default_label.setText(entry.default_key_sequence)
+                self._capture_btn.set_shortcut(entry.key_sequence)
+                self._capture_btn.setEnabled(True)
+                self._reset_btn.setEnabled(entry.is_modified)
+        else:
+            self._selected_entry = None
+            self._action_label.setText("-")
+            self._default_label.setText("-")
+            self._capture_btn.setText("点击设置快捷键")
+            self._capture_btn.setEnabled(False)
+            self._reset_btn.setEnabled(False)
+
+    def _on_shortcut_captured(self, sequence: str) -> None:
+        """Handle shortcut capture."""
+        if self._selected_entry:
+            success = self._manager.set_shortcut(self._selected_entry.action, sequence)
+            if success:
+                # Update model
+                self._model = ShortcutsModel(self._manager.get_all_entries(), self)
+                self._table.setModel(self._model)
+                self._on_category_changed()  # Reapply filter
+                self._reset_btn.setEnabled(self._selected_entry.is_modified)
+                self.shortcuts_changed.emit()
+            else:
+                QMessageBox.warning(
+                    self,
+                    "快捷键冲突",
+                    f"快捷键「{sequence}」已被其他操作使用,\n"
+                    "请选择其他快捷键。"
+                )
+                self._capture_btn.set_shortcut(self._selected_entry.key_sequence)
+
+    def _on_reset_to_default(self) -> None:
+        """Handle reset to default button."""
+        if self._selected_entry:
+            self._manager.reset_action(self._selected_entry.action)
+            self._model = ShortcutsModel(self._manager.get_all_entries(), self)
+            self._table.setModel(self._model)
+            self._on_category_changed()
+            self._capture_btn.set_shortcut(self._selected_entry.key_sequence)
+            self._reset_btn.setEnabled(False)
+            self.shortcuts_changed.emit()
+
+    def _on_reset_all(self) -> None:
+        """Handle reset all button."""
+        reply = QMessageBox.question(
+            self,
+            "确认重置",
+            "确定要将所有快捷键恢复为默认值吗?",
+            QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
+            QMessageBox.StandardButton.No
+        )
+
+        if reply == QMessageBox.StandardButton.Yes:
+            self._manager.reset_to_defaults()
+            self._model = ShortcutsModel(self._manager.get_all_entries(), self)
+            self._table.setModel(self._model)
+            self._on_category_changed()
+            if self._selected_entry:
+                entry = self._manager.get_all_entries()[
+                    list(StandardAction).index(self._selected_entry.action)
+                ]
+                self._capture_btn.set_shortcut(entry.key_sequence)
+                self._reset_btn.setEnabled(False)
+            self.shortcuts_changed.emit()
+
+    def _on_category_changed(self) -> None:
+        """Handle category filter change."""
+        category = self._category_combo.currentText()
+
+        if category == "全部":
+            filtered = self._all_entries
+        else:
+            filtered = [e for e in self._all_entries if e.category == category]
+
+        self._model = ShortcutsModel(filtered, self)
+        self._table.setModel(self._model)
+
+
+# Singleton instance
+_shortcuts_manager_instance: Optional[ShortcutsManager] = None
+
+
+def get_shortcuts_manager() -> ShortcutsManager:
+    """Get the singleton shortcuts manager instance."""
+    global _shortcuts_manager_instance
+    if _shortcuts_manager_instance is None:
+        _shortcuts_manager_instance = ShortcutsManager()
+    return _shortcuts_manager_instance

+ 841 - 0
src/ui/theme_manager.py

@@ -0,0 +1,841 @@
+"""
+Theme Manager component for UI.
+
+Implements Story 7.11: Theme switching functionality with
+light/dark mode support and dynamic stylesheet switching.
+"""
+
+from enum import Enum
+from pathlib import Path
+from typing import Optional, Dict, Callable
+from dataclasses import dataclass
+
+from PyQt6.QtWidgets import QWidget, QApplication
+from PyQt6.QtCore import QObject, pyqtSignal, QSettings
+from PyQt6.QtGui import QColor, QPalette
+
+
+class ThemeMode(Enum):
+    """Available theme modes."""
+    LIGHT = "light"
+    DARK = "dark"
+    AUTO = "auto"
+
+
+@dataclass
+class ThemeColors:
+    """Color scheme for a theme."""
+
+    # Background colors
+    window_bg: str
+    dialog_bg: str
+    widget_bg: str
+    view_bg: str
+
+    # Text colors
+    window_text: str
+    widget_text: str
+    view_text: str
+
+    # Accent colors
+    highlight: str
+    highlight_text: str
+    link: str
+    link_visited: str
+
+    # Border colors
+    border: str
+    button_border: str
+
+    # Status colors
+    success: str
+    warning: str
+    error: str
+    info: str
+
+
+# Light theme color palette
+LIGHT_COLORS = ThemeColors(
+    # Background
+    window_bg="#ffffff",
+    dialog_bg="#ffffff",
+    widget_bg="#f5f5f5",
+    view_bg="#ffffff",
+
+    # Text
+    window_text="#2c3e50",
+    widget_text="#2c3e50",
+    view_text="#2c3e50",
+
+    # Accent
+    highlight="#3498db",
+    highlight_text="#ffffff",
+    link="#3498db",
+    link_visited="#9b59b6",
+
+    # Border
+    border="#d0d0d0",
+    button_border="#b0b0b0",
+
+    # Status
+    success="#27ae60",
+    warning="#f39c12",
+    error="#e74c3c",
+    info="#3498db",
+)
+
+
+# Dark theme color palette
+DARK_COLORS = ThemeColors(
+    # Background
+    window_bg="#2c2c2c",
+    dialog_bg="#2c2c2c",
+    widget_bg="#353535",
+    view_bg="#2c2c2c",
+
+    # Text
+    window_text="#e0e0e0",
+    widget_text="#e0e0e0",
+    view_text="#e0e0e0",
+
+    # Accent
+    highlight="#3498db",
+    highlight_text="#ffffff",
+    link="#5dade2",
+    link_visited="#af7ac5",
+
+    # Border
+    border="#404040",
+    button_border="#505050",
+
+    # Status
+    success="#52c982",
+    warning="#f5b041",
+    error="#ec7063",
+    info="#5dade2",
+)
+
+
+class ThemeManager(QObject):
+    """
+    Theme manager for switching between light and dark themes.
+
+    Features:
+    - Light/Dark theme switching
+    - Dynamic stylesheet application
+    - Persistent user preferences via QSettings
+    - Custom theme support
+
+    Usage:
+        manager = ThemeManager()
+        manager.set_theme(ThemeMode.DARK)
+        # Apply to application
+        manager.apply_to_application(QApplication.instance())
+    """
+
+    # Signals
+    theme_changed = pyqtSignal(ThemeMode)  # Emitted when theme changes
+
+    # Settings keys
+    SETTINGS_THEME_MODE = "ui/theme_mode"
+    SETTINGS_CUSTOM_STYLESHEET = "ui/custom_stylesheet"
+
+    def __init__(self, parent: Optional[QObject] = None) -> None:
+        """Initialize the theme manager."""
+        super().__init__(parent)
+
+        self._current_mode: ThemeMode = ThemeMode.LIGHT
+        self._custom_stylesheet: str = ""
+        self._settings = QSettings("BMAD", "NovelTranslator")
+
+        # Load saved theme preference
+        self._load_settings()
+
+    def _load_settings(self) -> None:
+        """Load theme settings from QSettings."""
+        saved_mode = self._settings.value(self.SETTINGS_THEME_MODE, ThemeMode.LIGHT.value)
+        try:
+            self._current_mode = ThemeMode(saved_mode)
+        except ValueError:
+            self._current_mode = ThemeMode.LIGHT
+
+        self._custom_stylesheet = self._settings.value(
+            self.SETTINGS_CUSTOM_STYLESHEET, ""
+        ) or ""
+
+    def _save_settings(self) -> None:
+        """Save theme settings to QSettings."""
+        self._settings.setValue(self.SETTINGS_THEME_MODE, self._current_mode.value)
+        self._settings.setValue(self.SETTINGS_CUSTOM_STYLESHEET, self._custom_stylesheet)
+
+    @property
+    def current_mode(self) -> ThemeMode:
+        """Get the current theme mode."""
+        return self._current_mode
+
+    @property
+    def colors(self) -> ThemeColors:
+        """Get the current color palette."""
+        if self._current_mode == ThemeMode.DARK:
+            return DARK_COLORS
+        return LIGHT_COLORS
+
+    def set_theme(self, mode: ThemeMode) -> None:
+        """
+        Set the current theme mode.
+
+        Args:
+            mode: The theme mode to set
+        """
+        if mode != self._current_mode:
+            self._current_mode = mode
+            self._save_settings()
+            self.theme_changed.emit(mode)
+
+    def toggle_theme(self) -> None:
+        """Toggle between light and dark themes."""
+        if self._current_mode == ThemeMode.LIGHT:
+            self.set_theme(ThemeMode.DARK)
+        else:
+            self.set_theme(ThemeMode.LIGHT)
+
+    def generate_stylesheet(self) -> str:
+        """
+        Generate the Qt stylesheet for the current theme.
+
+        Returns:
+            Complete stylesheet string
+        """
+        colors = self.colors
+
+        stylesheet = f"""
+/* Main Application Styles */
+QWidget {{
+    background-color: {colors.widget_bg};
+    color: {colors.widget_text};
+    border: none;
+}}
+
+QMainWindow {{
+    background-color: {colors.window_bg};
+}}
+
+QDialog {{
+    background-color: {colors.dialog_bg};
+}}
+
+/* Buttons */
+QPushButton {{
+    background-color: {colors.widget_bg};
+    color: {colors.widget_text};
+    border: 1px solid {colors.button_border};
+    border-radius: 4px;
+    padding: 6px 12px;
+    min-width: 80px;
+}}
+
+QPushButton:hover {{
+    background-color: {colors.highlight};
+    color: {colors.highlight_text};
+    border: 1px solid {colors.highlight};
+}}
+
+QPushButton:pressed {{
+    background-color: {colors.highlight};
+    color: {colors.highlight_text};
+}}
+
+QPushButton:disabled {{
+    background-color: {colors.widget_bg};
+    color: #888;
+    border: 1px solid {colors.border};
+}}
+
+/* Primary Button */
+QPushButton[class="primary"] {{
+    background-color: {colors.highlight};
+    color: {colors.highlight_text};
+    border: none;
+    font-weight: bold;
+}}
+
+QPushButton[class="primary"]:hover {{
+    background-color: {self._adjust_color(colors.highlight, -20)};
+}}
+
+QPushButton[class="primary"]:disabled {{
+    background-color: #888;
+    color: {colors.widget_text};
+}}
+
+/* Danger Button */
+QPushButton[class="danger"] {{
+    background-color: {colors.error};
+    color: white;
+    border: none;
+}}
+
+QPushButton[class="danger"]:hover {{
+    background-color: {self._adjust_color(colors.error, -20)};
+}}
+
+/* Input Fields */
+QLineEdit, QTextEdit, QPlainTextEdit {{
+    background-color: {colors.view_bg};
+    color: {colors.view_text};
+    border: 1px solid {colors.border};
+    border-radius: 4px;
+    padding: 4px;
+    selection-background-color: {colors.highlight};
+    selection-color: {colors.highlight_text};
+}}
+
+QLineEdit:hover, QTextEdit:hover {{
+    border: 1px solid {colors.highlight};
+}}
+
+QLineEdit:focus, QTextEdit:focus {{
+    border: 2px solid {colors.highlight};
+}}
+
+QLineEdit:disabled {{
+    background-color: {colors.widget_bg};
+    color: #888;
+}}
+
+/* Combo Box */
+QComboBox {{
+    background-color: {colors.widget_bg};
+    color: {colors.widget_text};
+    border: 1px solid {colors.border};
+    border-radius: 4px;
+    padding: 4px 12px 4px 8px;
+    min-width: 100px;
+}}
+
+QComboBox:hover {{
+    border: 1px solid {colors.highlight};
+}}
+
+QComboBox::drop-down {{
+    border: none;
+    width: 20px;
+}}
+
+QComboBox::down-arrow {{
+    image: none;
+    border: 2px solid {colors.widget_text};
+    border-top: none;
+    border-left: none;
+    width: 6px;
+    height: 6px;
+    margin-right: 8px;
+    transform: rotate(45deg);
+}}
+
+QComboBox QAbstractItemView {{
+    background-color: {colors.view_bg};
+    color: {colors.view_text};
+    border: 1px solid {colors.border};
+    selection-background-color: {colors.highlight};
+    selection-color: {colors.highlight_text};
+}}
+
+/* Table View */
+QTableView {{
+    background-color: {colors.view_bg};
+    color: {colors.view_text};
+    border: 1px solid {colors.border};
+    gridline-color: {colors.border};
+    selection-background-color: {colors.highlight};
+    selection-color: {colors.highlight_text};
+}}
+
+QTableView::item {{
+    padding: 4px;
+}}
+
+QTableView::item:alternate {{
+    background-color: {self._adjust_color(colors.view_bg, 5)};
+}}
+
+QTableView::item:selected {{
+    background-color: {colors.highlight};
+    color: {colors.highlight_text};
+}}
+
+QHeaderView::section {{
+    background-color: {colors.widget_bg};
+    color: {colors.widget_text};
+    border: none;
+    border-bottom: 1px solid {colors.border};
+    border-right: 1px solid {colors.border};
+    padding: 6px;
+    font-weight: bold;
+}}
+
+QHeaderView::section:hover {{
+    background-color: {self._adjust_color(colors.widget_bg, -5)};
+}}
+
+/* List View */
+QListView {{
+    background-color: {colors.view_bg};
+    color: {colors.view_text};
+    border: 1px solid {colors.border};
+}}
+
+QListView::item {{
+    padding: 4px;
+}}
+
+QListView::item:selected {{
+    background-color: {colors.highlight};
+    color: {colors.highlight_text};
+}}
+
+/* Tree View */
+QTreeView {{
+    background-color: {colors.view_bg};
+    color: {colors.view_text};
+    border: 1px solid {colors.border};
+}}
+
+QTreeView::item {{
+    padding: 4px;
+}}
+
+QTreeView::item:selected {{
+    background-color: {colors.highlight};
+    color: {colors.highlight_text};
+}}
+
+/* Scroll Bar */
+QScrollBar:vertical {{
+    background-color: {colors.widget_bg};
+    width: 12px;
+    margin: 0;
+}}
+
+QScrollBar::handle:vertical {{
+    background-color: {self._adjust_color(colors.widget_bg, -20)};
+    min-height: 30px;
+    border-radius: 6px;
+    margin: 2px;
+}}
+
+QScrollBar::handle:vertical:hover {{
+    background-color: {self._adjust_color(colors.widget_bg, -30)};
+}}
+
+QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{
+    height: 0;
+}}
+
+QScrollBar:horizontal {{
+    background-color: {colors.widget_bg};
+    height: 12px;
+    margin: 0;
+}}
+
+QScrollBar::handle:horizontal {{
+    background-color: {self._adjust_color(colors.widget_bg, -20)};
+    min-width: 30px;
+    border-radius: 6px;
+    margin: 2px;
+}}
+
+QScrollBar::handle:horizontal:hover {{
+    background-color: {self._adjust_color(colors.widget_bg, -30)};
+}}
+
+QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal {{
+    width: 0;
+}}
+
+/* Slider */
+QSlider::groove:horizontal {{
+    height: 6px;
+    background-color: {colors.border};
+    border-radius: 3px;
+}}
+
+QSlider::handle:horizontal {{
+    width: 16px;
+    height: 16px;
+    background-color: {colors.highlight};
+    border-radius: 8px;
+    margin: -5px 0;
+}}
+
+QSlider::handle:horizontal:hover {{
+    background-color: {self._adjust_color(colors.highlight, -20)};
+}}
+
+/* Progress Bar */
+QProgressBar {{
+    background-color: {colors.widget_bg};
+    border: 1px solid {colors.border};
+    border-radius: 4px;
+    text-align: center;
+}}
+
+QProgressBar::chunk {{
+    background-color: {colors.highlight};
+    border-radius: 3px;
+}}
+
+/* Tab Widget */
+QTabWidget::pane {{
+    border: 1px solid {colors.border};
+    background-color: {colors.widget_bg};
+}}
+
+QTabBar::tab {{
+    background-color: {colors.widget_bg};
+    color: {colors.widget_text};
+    border: 1px solid {colors.border};
+    border-bottom: none;
+    padding: 8px 16px;
+    margin-right: 2px;
+}}
+
+QTabBar::tab:selected {{
+    background-color: {colors.view_bg};
+    border-bottom: 1px solid {colors.view_bg};
+}}
+
+QTabBar::tab:hover {{
+    background-color: {self._adjust_color(colors.widget_bg, -5)};
+}}
+
+/* Group Box */
+QGroupBox {{
+    color: {colors.widget_text};
+    border: 1px solid {colors.border};
+    border-radius: 4px;
+    margin-top: 8px;
+    padding: 8px;
+    font-weight: bold;
+}}
+
+QGroupBox::title {{
+    subcontrol-origin: margin;
+    left: 8px;
+    padding: 0 4px;
+}}
+
+/* Menu Bar */
+QMenuBar {{
+    background-color: {colors.widget_bg};
+    color: {colors.widget_text};
+    border-bottom: 1px solid {colors.border};
+}}
+
+QMenuBar::item {{
+    padding: 6px 12px;
+}}
+
+QMenuBar::item:selected {{
+    background-color: {colors.highlight};
+    color: {colors.highlight_text};
+}}
+
+QMenuBar::item:pressed {{
+    background-color: {colors.highlight};
+    color: {colors.highlight_text};
+}}
+
+/* Menu */
+QMenu {{
+    background-color: {colors.dialog_bg};
+    color: {colors.widget_text};
+    border: 1px solid {colors.border};
+}}
+
+QMenu::item {{
+    padding: 6px 24px;
+}}
+
+QMenu::item:selected {{
+    background-color: {colors.highlight};
+    color: {colors.highlight_text};
+}}
+
+QMenu::separator {{
+    height: 1px;
+    background-color: {colors.border};
+    margin: 4px 8px;
+}}
+
+/* Status Bar */
+QStatusBar {{
+    background-color: {colors.widget_bg};
+    color: {colors.widget_text};
+    border-top: 1px solid {colors.border};
+}}
+
+/* Tool Bar */
+QToolBar {{
+    background-color: {colors.widget_bg};
+    border: none;
+    spacing: 4px;
+}}
+
+QToolBar::separator {{
+    width: 1px;
+    background-color: {colors.border};
+    margin: 4px 8px;
+}}
+
+/* Tool Button */
+QToolButton {{
+    background-color: transparent;
+    border: 1px solid transparent;
+    border-radius: 4px;
+    padding: 4px;
+}}
+
+QToolButton:hover {{
+    background-color: {self._adjust_color(colors.widget_bg, -10)};
+    border: 1px solid {colors.border};
+}}
+
+QToolButton:pressed {{
+    background-color: {self._adjust_color(colors.widget_bg, -20)};
+}}
+
+/* Spin Box */
+QSpinBox, QDoubleSpinBox {{
+    background-color: {colors.view_bg};
+    color: {colors.view_text};
+    border: 1px solid {colors.border};
+    border-radius: 4px;
+    padding: 4px;
+}}
+
+QSpinBox:hover, QDoubleSpinBox:hover {{
+    border: 1px solid {colors.highlight};
+}}
+
+QSpinBox:focus, QDoubleSpinBox:focus {{
+    border: 2px solid {colors.highlight};
+}}
+
+QSpinBox::up-button, QDoubleSpinBox::up-button,
+QSpinBox::down-button, QDoubleSpinBox::down-button {{
+    background-color: {colors.widget_bg};
+    border: none;
+    width: 16px;
+}}
+
+QSpinBox::up-button:hover, QDoubleSpinBox::up-button:hover,
+QSpinBox::down-button:hover, QDoubleSpinBox::down-button:hover {{
+    background-color: {self._adjust_color(colors.widget_bg, -10)};
+}}
+
+/* Check Box */
+QCheckBox {{
+    spacing: 6px;
+}}
+
+QCheckBox::indicator {{
+    width: 18px;
+    height: 18px;
+    border: 1px solid {colors.border};
+    border-radius: 3px;
+    background-color: {colors.view_bg};
+}}
+
+QCheckBox::indicator:hover {{
+    border: 1px solid {colors.highlight};
+}}
+
+QCheckBox::indicator:checked {{
+    background-color: {colors.highlight};
+    border: 1px solid {colors.highlight};
+}}
+
+QCheckBox::indicator:checked {{
+    image: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTgiIGhlaWdodD0iMTgiIHZpZXdCb3g9IjAgMCAxOCAxOCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJNMyA5bDUgNSA3LTciIHN0cm9rZT0id2hpdGUiIHN0cm9rZS13aWR0aD0iMiIgZmlsbD0ibm9uZSIvPjwvc3ZnPg==);
+}}
+
+/* Radio Button */
+QRadioButton {{
+    spacing: 6px;
+}}
+
+QRadioButton::indicator {{
+    width: 18px;
+    height: 18px;
+    border: 1px solid {colors.border};
+    border-radius: 9px;
+    background-color: {colors.view_bg};
+}}
+
+QRadioButton::indicator:hover {{
+    border: 1px solid {colors.highlight};
+}}
+
+QRadioButton::indicator:checked {{
+    background-color: {colors.highlight};
+    border: 4px solid {colors.view_bg};
+}}
+
+/* Tooltip */
+QToolTip {{
+    background-color: {colors.window_bg};
+    color: {colors.window_text};
+    border: 1px solid {colors.border};
+    padding: 4px;
+    border-radius: 4px;
+}}
+
+/* Dock Widget */
+QDockWidget {{
+    background-color: {colors.widget_bg};
+    color: {colors.widget_text};
+    border: 1px solid {colors.border};
+}}
+
+QDockWidget::title {{
+    background-color: {colors.widget_bg};
+    padding: 6px;
+    border-bottom: 1px solid {colors.border};
+}}
+
+/* Splitter */
+QSplitter::handle {{
+    background-color: {colors.border};
+}}
+
+QSplitter::handle:hover {{
+    background-color: {colors.highlight};
+}}
+
+QSplitter::handle:horizontal {{
+    width: 2px;
+}}
+
+QSplitter::handle:vertical {{
+    height: 2px;
+}}
+
+/* Frame */
+QFrame {{
+    background-color: {colors.widget_bg};
+    color: {colors.widget_text};
+}}
+
+QFrame[frameShape="4"], QFrame[frameShape="5"] {{
+    border: 1px solid {colors.border};
+}}
+"""
+        return stylesheet
+
+    def _adjust_color(self, hex_color: str, amount: int) -> str:
+        """
+        Adjust a hex color by a specified amount.
+
+        Args:
+            hex_color: Hex color string (e.g., "#3498db")
+            amount: Amount to adjust (-255 to 255)
+
+        Returns:
+            Adjusted hex color string
+        """
+        hex_color = hex_color.lstrip("#")
+        r, g, b = int(hex_color[0:2], 16), int(hex_color[2:4], 16), int(hex_color[4:6], 16)
+
+        r = max(0, min(255, r + amount))
+        g = max(0, min(255, g + amount))
+        b = max(0, min(255, b + amount))
+
+        return f"#{r:02x}{g:02x}{b:02x}"
+
+    def apply_to_application(self, app: QApplication) -> None:
+        """
+        Apply the current theme to the application.
+
+        Args:
+            app: The QApplication instance
+        """
+        stylesheet = self.generate_stylesheet()
+
+        # Add custom stylesheet if set
+        if self._custom_stylesheet:
+            stylesheet += "\n" + self._custom_stylesheet
+
+        app.setStyleSheet(stylesheet)
+
+    def apply_to_widget(self, widget: QWidget) -> None:
+        """
+        Apply the current theme to a specific widget.
+
+        Args:
+            widget: The widget to apply the theme to
+        """
+        widget.setStyleSheet(self.generate_stylesheet())
+
+    def set_custom_stylesheet(self, stylesheet: str) -> None:
+        """
+        Set a custom stylesheet to append to the theme.
+
+        Args:
+            stylesheet: Custom stylesheet CSS
+        """
+        self._custom_stylesheet = stylesheet
+        self._save_settings()
+
+    def load_stylesheet_from_file(self, path: Path) -> bool:
+        """
+        Load a custom stylesheet from a file.
+
+        Args:
+            path: Path to the CSS/QSS file
+
+        Returns:
+            True if successful, False otherwise
+        """
+        try:
+            path = Path(path)
+            if not path.exists():
+                return False
+
+            with open(path, "r", encoding="utf-8") as f:
+                self._custom_stylesheet = f.read()
+
+            self._save_settings()
+            return True
+        except Exception:
+            return False
+
+    def get_color(self, color_name: str) -> str:
+        """
+        Get a specific color from the current theme.
+
+        Args:
+            color_name: Name of the color (e.g., "window_bg", "highlight")
+
+        Returns:
+            Hex color string
+        """
+        return getattr(self.colors, color_name, "#000000")
+
+
+# Singleton instance
+_theme_manager_instance: Optional[ThemeManager] = None
+
+
+def get_theme_manager() -> ThemeManager:
+    """Get the singleton theme manager instance."""
+    global _theme_manager_instance
+    if _theme_manager_instance is None:
+        _theme_manager_instance = ThemeManager()
+    return _theme_manager_instance
+
+
+def set_theme_manager(manager: ThemeManager) -> None:
+    """Set the global theme manager instance."""
+    global _theme_manager_instance
+    _theme_manager_instance = manager

+ 1 - 0
tests/config/__init__.py

@@ -0,0 +1 @@
+"""Tests for config module."""

+ 95 - 0
tests/config/test_manager.py

@@ -0,0 +1,95 @@
+"""
+Tests for ConfigManager - no GUI required.
+"""
+
+import pytest
+import tempfile
+import json
+from pathlib import Path
+
+from src.config.manager import ConfigManager, ConfigChangeTracker
+from src.config.defaults import (
+    DEFAULT_CONFIG,
+    CONFIG_SCHEMA,
+    get_config_item,
+    get_category_items,
+    validate_config_value,
+    ConfigType,
+)
+
+
+class TestConfigManager:
+    """Test ConfigManager functionality."""
+
+    def test_initialization_with_default_path(self):
+        """Test manager initializes with default config."""
+        manager = ConfigManager()
+
+        # Should have loaded defaults
+        assert manager.get("translation.model_name") == "facebook/m2m100_418M"
+        assert manager.get("translation.device") == "auto"
+        assert manager.get("interface.theme") == "light"
+
+    def test_get_existing_key(self):
+        """Test getting an existing configuration key."""
+        manager = ConfigManager()
+
+        value = manager.get("translation.batch_size")
+        assert value == 8
+
+    def test_set_valid_value(self):
+        """Test setting a valid configuration value."""
+        with tempfile.TemporaryDirectory() as tmpdir:
+            config_path = Path(tmpdir) / "config.json"
+            manager = ConfigManager(config_path)
+
+            success, error = manager.set("translation.batch_size", 16, validate=True)
+            assert success is True
+            assert error is None
+            assert manager.get("translation.batch_size") == 16
+
+    def test_set_invalid_value_below_min(self):
+        """Test setting a value below minimum."""
+        manager = ConfigManager()
+
+        success, error = manager.set("translation.batch_size", 0, validate=True)
+        assert success is False
+        assert "must be >=" in error.lower()
+
+    def test_get_all(self):
+        """Test getting all configuration values."""
+        manager = ConfigManager()
+
+        all_config = manager.get_all()
+
+        assert isinstance(all_config, dict)
+        assert "translation.model_name" in all_config
+        assert "interface.theme" in all_config
+
+
+class TestConfigDefaults:
+    """Test default configuration and schema."""
+
+    def test_default_config_completeness(self):
+        """Test default config has all expected values."""
+        assert "translation.model_name" in DEFAULT_CONFIG
+        assert "translation.device" in DEFAULT_CONFIG
+        assert "interface.theme" in DEFAULT_CONFIG
+        assert "interface.language" in DEFAULT_CONFIG
+
+    def test_schema_has_categories(self):
+        """Test schema has all expected categories."""
+        category_keys = [cat.key for cat in CONFIG_SCHEMA]
+
+        assert "translation" in category_keys
+        assert "interface" in category_keys
+        assert "paths" in category_keys
+        assert "advanced" in category_keys
+
+    def test_get_config_item(self):
+        """Test getting a config item from schema."""
+        item = get_config_item("translation.batch_size")
+
+        assert item is not None
+        assert item.key == "translation.batch_size"
+        assert item.title == "Batch Size"

+ 185 - 203
tests/ui/test_file_selector.py

@@ -1,242 +1,224 @@
 """
-Tests for FileSelector UI component.
+Tests for FileSelector component - no GUI required.
 """
 
 import pytest
-
-# Skip tests if PyQt6 is not installed
-pytest.importorskip("PyQt6")
-
 from pathlib import Path
-from datetime import datetime
-
-from PyQt6.QtWidgets import QApplication
-from PyQt6.QtCore import Qt, QFileSystemWatcher
-from PyQt6.QtTest import QTest
-
-from src.ui.file_selector import FileSelector
-
-
-@pytest.fixture
-def app(qtbot):
-    """Create QApplication fixture."""
-    test_app = QApplication.instance()
-    if test_app is None:
-        test_app = QApplication([])
-    yield test_app
-
-
-@pytest.fixture
-def file_selector(app, qtbot, tmp_path):
-    """Create FileSelector fixture with temp directory."""
-    selector = FileSelector()
-    qtbot.addWidget(selector)
-    yield selector, tmp_path
-    selector.close()
-
-
-class TestFileSelector:
-    """Test FileSelector functionality."""
-
-    def test_initialization(self, file_selector):
-        """Test file selector initializes correctly."""
-        selector, _ = file_selector
-        assert selector.current_path is None
-        assert not selector.is_valid
-        assert selector._path_display.placeholderText() == selector.DEFAULT_PLACEHOLDER
-
-    def test_no_file_selected_initially(self, file_selector):
-        """Test no file is selected initially."""
-        selector, _ = file_selector
-        assert selector.current_path is None
-        assert not selector.is_valid
-        assert selector._name_value.text() == "-"
-        assert selector._size_value.text() == "-"
-        assert selector._lines_value.text() == "-"
-
-    def test_set_valid_file(self, file_selector):
-        """Test setting a valid file."""
-        selector, tmp_path = file_selector
-
-        # Create a test file
-        test_file = tmp_path / "test.txt"
-        test_file.write_text("Line 1\nLine 2\nLine 3\n", encoding='utf-8')
-
-        # Set the file
-        selector.set_file(test_file)
-
-        assert selector.current_path == test_file
-        assert selector.is_valid
-        assert selector._name_value.text() == "test.txt"
-
-    def test_file_info_display(self, file_selector):
-        """Test file information is displayed correctly."""
-        selector, tmp_path = file_selector
-
-        # Create a test file with known content
-        content = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5\n"
-        test_file = tmp_path / "test.txt"
-        test_file.write_text(content, encoding='utf-8')
-
-        selector.set_file(test_file)
-
-        # Check name
-        assert selector._name_value.text() == "test.txt"
-
-        # Check size (should be non-zero)
-        size_text = selector._size_value.text()
-        assert "B" in size_text
-
-        # Check line count
-        lines_text = selector._lines_value.text()
-        assert "5" in lines_text or "5," in lines_text  # 5 lines
-
-    def test_clear_selection(self, file_selector):
-        """Test clearing file selection."""
-        selector, tmp_path = file_selector
-
-        # Create and set a file
-        test_file = tmp_path / "test.txt"
-        test_file.write_text("Content\n", encoding='utf-8')
-        selector.set_file(test_file)
-
-        assert selector.is_valid
-
-        # Clear the selection
-        selector.clear()
-
-        assert selector.current_path is None
-        assert not selector.is_valid
-        assert selector._name_value.text() == "-"
-
-    def test_file_selected_signal(self, file_selector, qtbot):
-        """Test file_selected signal is emitted."""
-        selector, tmp_path = file_selector
-
-        signal_received = []
+import tempfile
 
-        def on_file_selected(path):
-            signal_received.append(path)
+from src.ui.models import FileItem, FileStatus
 
-        selector.file_selected.connect(on_file_selected)
 
-        # Create and set a file
-        test_file = tmp_path / "test.txt"
-        test_file.write_text("Content\n", encoding='utf-8')
-        selector.set_file(test_file)
+class TestFileSelectorConstants:
+    """Test FileSelector constants and configuration."""
 
-        assert len(signal_received) == 1
-        assert signal_received[0] == test_file
+    def test_supported_extensions(self):
+        """Test supported file extensions."""
+        from src.ui.file_selector import FileSelector
 
-    def test_selection_cleared_signal(self, file_selector):
-        """Test selection_cleared signal is emitted."""
-        selector, tmp_path = file_selector
+        expected = [".txt", ".md", ".html", ".htm"]
+        assert FileSelector.SUPPORTED_EXTENSIONS == expected
 
-        signal_received = []
+    def test_file_filter(self):
+        """Test file filter string."""
+        from src.ui.file_selector import FileSelector
 
-        def on_cleared():
-            signal_received.append(True)
+        filter_str = FileSelector.FILE_FILTER
+        assert "*.txt" in filter_str
+        assert "*.md" in filter_str
+        assert "*.html" in filter_str
 
-        selector.selection_cleared.connect(on_cleared)
 
-        # Create and set a file
-        test_file = tmp_path / "test.txt"
-        test_file.write_text("Content\n", encoding='utf-8')
-        selector.set_file(test_file)
+class TestFileScanner:
+    """Test FileScanner thread logic."""
 
-        # Clear the selection
-        selector.clear()
+    def test_scanner_initialization(self):
+        """Test scanner initialization with path and extensions."""
+        from src.ui.file_selector import FileScanner
 
-        assert len(signal_received) == 1
+        with tempfile.TemporaryDirectory() as tmpdir:
+            test_path = Path(tmpdir)
+            extensions = [".txt"]
 
-    def test_size_formatting(self):
-        """Test file size formatting."""
-        selector = FileSelector()
+            scanner = FileScanner(test_path, extensions)
 
-        # Test different size units
-        assert "B" in selector._format_size(512)
-        assert "KB" in selector._format_size(2048)
-        assert "MB" in selector._format_size(1024 * 1024 * 5)
-        assert "GB" in selector._format_size(1024 * 1024 * 1024 * 2)
+            assert scanner._path == test_path
+            assert scanner._extensions == extensions
+            assert scanner._is_running is True
 
-    def test_line_count_property(self, file_selector):
-        """Test line_count property."""
-        selector, tmp_path = file_selector
+    def test_is_supported_file(self):
+        """Test file extension checking."""
+        from src.ui.file_selector import FileScanner
 
-        # No file selected
-        assert selector.line_count is None
+        with tempfile.TemporaryDirectory() as tmpdir:
+            test_path = Path(tmpdir)
+            extensions = [".txt", ".md"]
 
-        # Create a file with 10 lines
-        test_file = tmp_path / "test.txt"
-        test_file.write_text("\n".join([f"Line {i}" for i in range(10)]), encoding='utf-8')
-        selector.set_file(test_file)
+            scanner = FileScanner(test_path, extensions)
 
-        assert selector.line_count == 10
+            # Create test files
+            (test_path / "test.txt").touch()
+            (test_path / "test.md").touch()
+            (test_path / "test.html").touch()
 
-    def test_non_existent_file(self, file_selector):
-        """Test handling of non-existent file."""
-        selector, tmp_path = file_selector
+            assert scanner._is_supported_file(test_path / "test.txt") is True
+            assert scanner._is_supported_file(test_path / "test.md") is True
+            assert scanner._is_supported_file(test_path / "test.html") is False
 
-        # Set a non-existent path
-        non_existent = tmp_path / "does_not_exist.txt"
-        selector.set_file(non_existent)
+    def test_create_file_item(self):
+        """Test FileItem creation from path."""
+        from src.ui.file_selector import FileScanner
 
-        # Should handle gracefully
-        assert selector.current_path == non_existent
-        assert not selector.is_valid
+        with tempfile.TemporaryDirectory() as tmpdir:
+            test_path = Path(tmpdir)
+            extensions = [".txt"]
 
-    def test_empty_file(self, file_selector):
-        """Test handling of empty file."""
-        selector, tmp_path = file_selector
+            # Create a test file with content
+            test_file = test_path / "test.txt"
+            test_file.write_text("Hello, World!")
 
-        # Create an empty file
-        test_file = tmp_path / "empty.txt"
-        test_file.write_text("", encoding='utf-8')
-        selector.set_file(test_file)
+            scanner = FileScanner(test_path, extensions)
+            item = scanner._create_file_item(test_file)
 
-        assert selector.is_valid
-        assert selector.line_count == 0
+            assert isinstance(item, FileItem)
+            assert item.name == "test.txt"
+            assert item.path == test_file
+            assert item.size == len("Hello, World!")
+            assert item.status == FileStatus.PENDING
 
-    def test_chinese_text_file(self, file_selector):
-        """Test handling of Chinese text file."""
-        selector, tmp_path = file_selector
+    def test_stop_scanner(self):
+        """Test stopping the scanner."""
+        from src.ui.file_selector import FileScanner
 
-        # Create a file with Chinese text
-        test_file = tmp_path / "chinese.txt"
-        test_file.write_text("第一章:开始\n第二章:发展\n第三章:结束\n", encoding='utf-8')
-        selector.set_file(test_file)
+        with tempfile.TemporaryDirectory() as tmpdir:
+            test_path = Path(tmpdir)
+            extensions = [".txt"]
 
-        assert selector.is_valid
-        assert selector.line_count == 3
+            scanner = FileScanner(test_path, extensions)
+            scanner.stop()
 
+            assert scanner._is_running is False
 
-class TestFileSelectorUI:
-    """Test FileSelector UI elements."""
 
-    def test_browse_button_exists(self, file_selector):
-        """Test browse button exists."""
-        selector, _ = file_selector
-        assert selector._browse_btn is not None
-        assert selector._browse_btn.text() == "Browse..."
+class TestFileItemExtensions:
+    """Test FileItem extensions for file selector."""
 
-    def test_clear_button_initially_disabled(self, file_selector):
-        """Test clear button is initially disabled."""
-        selector, _ = file_selector
-        assert not selector._clear_btn.isEnabled()
+    def test_file_item_with_real_file(self):
+        """Test FileItem with actual file stats."""
+        with tempfile.TemporaryDirectory() as tmpdir:
+            test_path = Path(tmpdir) / "novel.txt"
+            content = "This is a test novel file.\n" * 10
+            test_path.write_text(content)
 
-    def test_clear_button_enabled_with_file(self, file_selector):
-        """Test clear button is enabled when file is selected."""
-        selector, tmp_path = file_selector
+            item = FileItem(
+                path=test_path,
+                name=test_path.name,
+                size=test_path.stat().st_size,
+                status=FileStatus.READY
+            )
 
-        test_file = tmp_path / "test.txt"
-        test_file.write_text("Content\n", encoding='utf-8')
-        selector.set_file(test_file)
+            assert item.name == "novel.txt"
+            assert item.size == len(content)
+            assert item.status == FileStatus.READY
+            assert item.total_words == 0
+            assert item.translated_words == 0
 
-        assert selector._clear_btn.isEnabled()
+    def test_file_item_progress(self):
+        """Test FileItem progress calculation."""
+        item = FileItem(
+            path=Path("/test/novel.txt"),
+            name="novel.txt",
+            size=10000,
+            total_words=5000,
+            translated_words=2500,
+            status=FileStatus.TRANSLATING
+        )
+
+        assert item.progress == 50.0
 
-    def test_groups_exist(self, file_selector):
-        """Test UI groups are created."""
-        selector, _ = file_selector
-        # Check for groups (they should exist)
-        assert selector.layout() is not None
-        assert selector.layout().count() > 0
+    def test_file_item_size_formatting(self):
+        """Test FileItem size formatting for various sizes."""
+        # Bytes
+        item1 = FileItem(Path("/test/small.txt"), "small.txt", 512)
+        assert "B" in item1.size_formatted
+
+        # Kilobytes
+        item2 = FileItem(Path("/test/medium.txt"), "medium.txt", 2048)
+        assert "KB" in item2.size_formatted
+
+        # Megabytes
+        item3 = FileItem(Path("/test/large.txt"), "large.txt", 1024 * 1024 * 5)
+        assert "MB" in item3.size_formatted
+
+
+class TestFileValidation:
+    """Test file validation logic."""
+
+    def test_validate_existing_files(self):
+        """Test validation of existing files."""
+        with tempfile.TemporaryDirectory() as tmpdir:
+            test_path = Path(tmpdir)
+
+            # Create test files
+            (test_path / "test.txt").touch()
+            (test_path / "test.md").touch()
+            (test_path / "test.html").touch()
+
+            files = [
+                test_path / "test.txt",
+                test_path / "test.md",
+                test_path / "test.html",
+            ]
+
+            # All should be valid
+            for file_path in files:
+                assert file_path.exists()
+                assert file_path.is_file()
+
+    def test_validate_nonexistent_file(self):
+        """Test validation of non-existent file."""
+        from src.ui.file_selector import FileScanner
+
+        with tempfile.TemporaryDirectory() as tmpdir:
+            test_path = Path(tmpdir)
+            extensions = [".txt"]
+
+            scanner = FileScanner(test_path, extensions)
+            nonexistent = test_path / "does_not_exist.txt"
+
+            assert scanner._is_supported_file(nonexistent) is True
+            assert nonexistent.exists() is False
+
+    def test_validate_unsupported_extension(self):
+        """Test validation of unsupported file type."""
+        from src.ui.file_selector import FileScanner
+
+        with tempfile.TemporaryDirectory() as tmpdir:
+            test_path = Path(tmpdir)
+            extensions = [".txt", ".md"]
+
+            scanner = FileScanner(test_path, extensions)
+
+            # Create file with unsupported extension
+            unsupported = test_path / "test.pdf"
+            unsupported.touch()
+
+            assert scanner._is_supported_file(unsupported) is False
+
+    def test_validate_directory_vs_file(self):
+        """Test validation distinguishes files from directories."""
+        with tempfile.TemporaryDirectory() as tmpdir:
+            test_path = Path(tmpdir)
+
+            # Create a directory
+            test_dir = test_path / "subdir"
+            test_dir.mkdir()
+
+            # Create a file
+            test_file = test_path / "test.txt"
+            test_file.touch()
+
+            assert test_dir.is_dir() is True
+            assert test_file.is_file() is True
+            assert test_dir.is_file() is False
+            assert test_file.is_dir() is False

+ 290 - 0
tests/ui/test_glossary_editor.py

@@ -0,0 +1,290 @@
+"""
+Tests for the Glossary Editor UI component (Story 7.19).
+"""
+
+import pytest
+from pathlib import Path
+import tempfile
+import json
+
+from PyQt6.QtWidgets import QApplication
+from PyQt6.QtCore import Qt
+
+from src.glossary.models import Glossary, GlossaryEntry, TermCategory
+from src.ui.glossary_editor import (
+    GlossaryTableModel,
+    GlossaryEditor,
+    EntryEditDialog,
+    CATEGORY_NAMES,
+)
+
+
+@pytest.fixture
+def app(qtbot):
+    """Create QApplication for tests."""
+    return QApplication.instance() or QApplication([])
+
+
+@pytest.fixture
+def sample_glossary():
+    """Create a sample glossary for testing."""
+    glossary = Glossary()
+    glossary.add(GlossaryEntry("林风", "Lin Feng", TermCategory.CHARACTER, "Main protagonist"))
+    glossary.add(GlossaryEntry("火球术", "Fireball", TermCategory.SKILL, "Basic fire spell"))
+    glossary.add(GlossaryEntry("东方大陆", "Eastern Continent", TermCategory.LOCATION, ""))
+    glossary.add(GlossaryEntry("龙剑", "Dragon Sword", TermCategory.ITEM, "Legendary weapon"))
+    return glossary
+
+
+@pytest.fixture
+def temp_json_file():
+    """Create a temporary JSON file for testing."""
+    with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
+        data = [
+            {
+                "source": "测试术语",
+                "target": "Test Term",
+                "category": "other",
+                "context": "Test context"
+            }
+        ]
+        json.dump(data, f, ensure_ascii=False)
+        return Path(f.name)
+
+
+class TestGlossaryTableModel:
+    """Tests for GlossaryTableModel."""
+
+    def test_initialization(self, app, sample_glossary):
+        """Test model initialization."""
+        model = GlossaryTableModel(sample_glossary)
+        assert model.rowCount() == 4
+        assert model.columnCount() == 4
+
+    def test_data_retrieval(self, app, sample_glossary):
+        """Test data retrieval from model."""
+        model = GlossaryTableModel(sample_glossary)
+
+        index = model.index(0, 0)  # First row, source column
+        assert model.data(index) == "林风"
+
+        index = model.index(0, 1)  # First row, target column
+        assert model.data(index) == "Lin Feng"
+
+        index = model.index(0, 2)  # First row, category column
+        assert model.data(index) == "人名"
+
+    def test_category_filter(self, app, sample_glossary):
+        """Test category filtering."""
+        model = GlossaryTableModel(sample_glossary)
+
+        # Filter by character
+        model.set_category_filter(TermCategory.CHARACTER)
+        assert model.rowCount() == 1
+        assert model.filtered_count == 1
+        assert model.entry_count == 4
+
+        # Filter by skill
+        model.set_category_filter(TermCategory.SKILL)
+        assert model.rowCount() == 1
+
+        # Clear filter
+        model.set_category_filter(None)
+        assert model.rowCount() == 4
+
+    def test_search_filter(self, app, sample_glossary):
+        """Test search filtering."""
+        model = GlossaryTableModel(sample_glossary)
+
+        # Search for "Lin"
+        model.set_search_text("Lin")
+        assert model.rowCount() == 2  # "Lin Feng" and "Dragon Sword"
+
+        # Search for "火"
+        model.set_search_text("火")
+        assert model.rowCount() == 2  # "火球术" and "龙剑"
+
+        # Clear search
+        model.set_search_text("")
+        assert model.rowCount() == 4
+
+    def test_get_entry_at_row(self, app, sample_glossary):
+        """Test getting entry at specific row."""
+        model = GlossaryTableModel(sample_glossary)
+
+        entry = model.get_entry_at_row(0)
+        assert entry is not None
+        assert entry.source == "林风"
+
+        entry = model.get_entry_at_row(10)
+        assert entry is None
+
+    def test_refresh(self, app, sample_glossary):
+        """Test model refresh."""
+        model = GlossaryTableModel(sample_glossary)
+        assert model.rowCount() == 4
+
+        # Add new entry
+        sample_glossary.add(GlossaryEntry("新术语", "New Term", TermCategory.OTHER))
+
+        model.refresh()
+        assert model.rowCount() == 5
+
+
+class TestGlossaryEntry:
+    """Tests for GlossaryEntry validation."""
+
+    def test_valid_entry(self):
+        """Test creating a valid entry."""
+        entry = GlossaryEntry("测试", "Test", TermCategory.OTHER)
+        assert entry.source == "测试"
+        assert entry.target == "Test"
+        assert entry.category == TermCategory.OTHER
+
+    def test_empty_source(self):
+        """Test that empty source raises error."""
+        with pytest.raises(ValueError):
+            GlossaryEntry("", "Test", TermCategory.OTHER)
+
+    def test_whitespace_only_source(self):
+        """Test that whitespace-only source raises error."""
+        with pytest.raises(ValueError):
+            GlossaryEntry("   ", "Test", TermCategory.OTHER)
+
+    def test_empty_target(self):
+        """Test that empty target raises error."""
+        with pytest.raises(ValueError):
+            GlossaryEntry("测试", "", TermCategory.OTHER)
+
+    def test_length_property(self):
+        """Test the length property."""
+        entry = GlossaryEntry("测试术语", "Test", TermCategory.OTHER)
+        assert entry.length == 4
+
+
+@pytest.mark.skipif(not QApplication.instance(), reason="Requires QApplication")
+class TestGlossaryEditor:
+    """Tests for GlossaryEditor widget."""
+
+    def test_initialization(self, qtbot, sample_glossary):
+        """Test editor initialization."""
+        editor = GlossaryEditor(sample_glossary)
+        qtbot.addWidget(editor)
+
+        assert editor.glossary == sample_glossary
+        # Editor should display all entries
+        assert editor._model.entry_count == 4
+
+    def test_add_entry(self, qtbot, sample_glossary):
+        """Test adding a new entry."""
+        editor = GlossaryEditor(sample_glossary)
+        qtbot.addWidget(editor)
+
+        initial_count = len(sample_glossary)
+
+        # Add new entry
+        new_entry = GlossaryEntry("魔法", "Magic", TermCategory.SKILL)
+        sample_glossary.add(new_entry)
+        editor.refresh_data()
+
+        assert len(sample_glossary) == initial_count + 1
+
+    def test_delete_entry(self, qtbot, sample_glossary):
+        """Test deleting an entry."""
+        editor = GlossaryEditor(sample_glossary)
+        qtbot.addWidget(editor)
+
+        initial_count = len(sample_glossary)
+
+        # Remove entry
+        sample_glossary.remove("林风")
+        editor.refresh_data()
+
+        assert len(sample_glossary) == initial_count - 1
+
+    def test_set_glossary(self, qtbot, sample_glossary):
+        """Test setting a new glossary."""
+        editor = GlossaryEditor(sample_glossary)
+        qtbot.addWidget(editor)
+
+        new_glossary = Glossary()
+        new_glossary.add(GlossaryEntry("测试", "Test", TermCategory.OTHER))
+
+        editor.set_glossary(new_glossary)
+
+        assert editor.glossary == new_glossary
+        assert editor._model.entry_count == 1
+
+
+class TestGlossaryJson:
+    """Tests for JSON import/export functionality."""
+
+    def test_save_to_file(self, sample_glossary, tmp_path):
+        """Test saving glossary to JSON file."""
+        json_path = tmp_path / "test_glossary.json"
+        sample_glossary.save_to_file(json_path)
+
+        assert json_path.exists()
+
+        # Verify content
+        with open(json_path, "r", encoding="utf-8") as f:
+            data = json.load(f)
+
+        assert len(data) == 4
+        assert data[0]["source"] == "林风"
+        assert data[0]["target"] == "Lin Feng"
+        assert data[0]["category"] == "character"
+
+    def test_load_from_file(self, temp_json_file):
+        """Test loading glossary from JSON file."""
+        glossary = Glossary()
+        glossary.load_from_file(temp_json_file)
+
+        assert len(glossary) == 1
+
+        entry = glossary.get("测试术语")
+        assert entry is not None
+        assert entry.target == "Test Term"
+        assert entry.category == TermCategory.OTHER
+
+    def test_load_nonexistent_file(self):
+        """Test loading from non-existent file."""
+        glossary = Glossary()
+
+        with pytest.raises(FileNotFoundError):
+            glossary.load_from_file(Path("/nonexistent/path.json"))
+
+    def test_atomic_write(self, sample_glossary, tmp_path):
+        """Test that save uses atomic write."""
+        json_path = tmp_path / "test_glossary.json"
+
+        # Save should be atomic
+        sample_glossary.save_to_file(json_path)
+
+        # File should exist and be complete
+        assert json_path.exists()
+
+        with open(json_path, "r", encoding="utf-8") as f:
+            data = json.load(f)
+
+        # Should have all entries
+        assert len(data) == 4
+
+
+class TestCategoryNames:
+    """Tests for category name mappings."""
+
+    def test_all_categories_have_names(self):
+        """Test that all categories have display names."""
+        for category in TermCategory:
+            assert category in CATEGORY_NAMES
+            assert CATEGORY_NAMES[category]
+
+    def test_chinese_names(self):
+        """Test Chinese category names."""
+        assert CATEGORY_NAMES[TermCategory.CHARACTER] == "人名"
+        assert CATEGORY_NAMES[TermCategory.SKILL] == "技能"
+        assert CATEGORY_NAMES[TermCategory.LOCATION] == "地名"
+        assert CATEGORY_NAMES[TermCategory.ITEM] == "物品"
+        assert CATEGORY_NAMES[TermCategory.ORGANIZATION] == "组织"
+        assert CATEGORY_NAMES[TermCategory.OTHER] == "其他"

+ 292 - 0
tests/ui/test_i18n.py

@@ -0,0 +1,292 @@
+"""
+Tests for the Internationalization (i18n) UI component (Story 7.12).
+"""
+
+import pytest
+from pathlib import Path
+
+from PyQt6.QtWidgets import QApplication
+from PyQt6.QtCore import QLocale
+
+from src.ui.i18n import (
+    SupportedLanguage,
+    TRANSLATIONS,
+    I18nManager,
+    get_i18n_manager,
+    t,
+)
+
+
+@pytest.fixture
+def app(qtbot):
+    """Create QApplication for tests."""
+    return QApplication.instance() or QApplication([])
+
+
+@pytest.fixture
+def clean_manager():
+    """Create a fresh I18nManager for testing."""
+    import src.ui.i18n as i18n
+    i18n._i18n_manager_instance = None
+    manager = I18nManager()
+    return manager
+
+
+class TestSupportedLanguage:
+    """Tests for SupportedLanguage enum."""
+
+    def test_language_values(self):<arg_value>(self):
+        """Test SupportedLanguage enum values."""
+        assert SupportedLanguage.CHINESE_SIMPLIFIED.code == "zh_CN"
+        assert SupportedLanguage.ENGLISH.code == "en_US"
+
+    def test_native_names(self):
+        """Test native language names."""
+        assert SupportedLanguage.CHINESE_SIMPLIFIED.native_name == "简体中文"
+        assert SupportedLanguage.ENGLISH.native_name == "English"
+
+    def test_english_names(self):
+        """Test English language names."""
+        assert SupportedLanguage.CHINESE_SIMPLIFIED.english_name == "Chinese (Simplified)"
+        assert SupportedLanguage.ENGLISH.english_name == "English"
+
+    def test_from_locale_chinese(self):
+        """Test getting language from Chinese locale."""
+        locale = QLocale(QLocale.Language.Chinese, QLocale.Country.China)
+        lang = SupportedLanguage.from_locale(locale)
+        assert lang == SupportedLanguage.CHINESE_SIMPLIFIED
+
+    def test_from_locale_english(self):
+        """Test getting language from English locale."""
+        locale = QLocale(QLocale.Language.English, QLocale.Country.UnitedStates)
+        lang = SupportedLanguage.from_locale(locale)
+        assert lang == SupportedLanguage.ENGLISH
+
+
+class TestTranslations:
+    """Tests for translation strings."""
+
+    def test_translations_structure(self):
+        """Test that translations have proper structure."""
+        for key, translations in TRANSLATIONS.items():
+            assert "zh_CN" in translations
+            assert "en_US" in translations
+
+    def test_common_translations_exist(self):
+        """Test that common UI strings have translations."""
+        # Menu items
+        assert "menu.file" in TRANSLATIONS
+        assert "menu.edit" in TRANSLATIONS
+
+        # Buttons
+        assert "button.ok" in TRANSLATIONS
+        assert "button.cancel" in TRANSLATIONS
+
+        # Status
+        assert "status.ready" in TRANSLATIONS
+        assert "status.translating" in TRANSLATIONS
+
+    def test_translation_values(self):
+        """Test specific translation values."""
+        assert TRANSLATIONS["button.ok"]["zh_CN"] == "确定"
+        assert TRANSLATIONS["button.ok"]["en_US"] == "OK"
+
+        assert TRANSLATIONS["button.cancel"]["zh_CN"] == "取消"
+        assert TRANSLATIONS["button.cancel"]["en_US"] == "Cancel"
+
+    def test_formatted_translations(self):
+        """Test translations with format placeholders."""
+        assert "{count}" in TRANSLATIONS["msg.added_files"]["zh_CN"]
+        assert "{count}" in TRANSLATIONS["msg.added_files"]["en_US"]
+
+        assert "{path}" in TRANSLATIONS["msg.file_not_found"]["zh_CN"]
+        assert "{path}" in TRANSLATIONS["msg.file_not_found"]["en_US"]
+
+
+class TestI18nManager:
+    """Tests for I18nManager."""
+
+    def test_initialization(self, clean_manager):
+        """Test manager initialization."""
+        assert isinstance(clean_manager, I18nManager)
+        # Default to Chinese
+        assert clean_manager.current_language == SupportedLanguage.CHINESE_SIMPLIFIED
+
+    def test_current_language(self, clean_manager):
+        """Test current_language property."""
+        assert clean_manager.current_language == SupportedLanguage.CHINESE_SIMPLIFIED
+
+        clean_manager.set_language(SupportedLanguage.ENGLISH)
+        assert clean_manager.current_language == SupportedLanguage.ENGLISH
+
+    def test_current_locale(self, clean_manager):
+        """Test current_locale property."""
+        locale = clean_manager.current_locale
+        assert isinstance(locale, QLocale)
+
+    def test_set_language_chinese(self, clean_manager):
+        """Test setting Chinese language."""
+        clean_manager.set_language(SupportedLanguage.CHINESE_SIMPLIFIED)
+        assert clean_manager.current_language == SupportedLanguage.CHINESE_SIMPLIFIED
+
+    def test_set_language_english(self, clean_manager):
+        """Test setting English language."""
+        clean_manager.set_language(SupportedLanguage.ENGLISH)
+        assert clean_manager.current_language == SupportedLanguage.ENGLISH
+
+    def test_translate_chinese(self, clean_manager):
+        """Test translation to Chinese."""
+        clean_manager.set_language(SupportedLanguage.CHINESE_SIMPLIFIED)
+
+        text = clean_manager.translate("button.ok")
+        assert text == "确定"
+
+        text = clean_manager.translate("button.cancel")
+        assert text == "取消"
+
+    def test_translate_english(self, clean_manager):
+        """Test translation to English."""
+        clean_manager.set_language(SupportedLanguage.ENGLISH)
+
+        text = clean_manager.translate("button.ok")
+        assert text == "OK"
+
+        text = clean_manager.translate("button.cancel")
+        assert text == "Cancel"
+
+    def test_translate_with_format(self, clean_manager):
+        """Test translation with format arguments."""
+        clean_manager.set_language(SupportedLanguage.ENGLISH)
+
+        text = clean_manager.translate("msg.added_files", count=5)
+        assert "5" in text
+        assert "file" in text
+
+    def test_translate_unknown_key(self, clean_manager):
+        """Test translating unknown key returns key itself."""
+        text = clean_manager.translate("unknown.key")
+        assert text == "unknown.key"
+
+    def test_shorthand_t(self, clean_manager):
+        """Test shorthand t() function."""
+        clean_manager.set_language(SupportedLanguage.CHINESE_SIMPLIFIED)
+
+        text = clean_manager.t("button.ok")
+        assert text == "确定"
+
+    def test_language_changed_signal(self, app, clean_manager, qtbot):
+        """Test that language_changed signal is emitted."""
+        signals = []
+
+        def on_language_changed(lang):
+            signals.append(lang)
+
+        clean_manager.language_changed.connect(on_language_changed)
+        clean_manager.set_language(SupportedLanguage.ENGLISH)
+
+        assert len(signals) == 1
+        assert signals[0] == SupportedLanguage.ENGLISH
+
+    def test_retranslate_callback(self, clean_manager):
+        """Test retranslate callbacks are called on language change."""
+        callback_calls = []
+
+        def callback():
+            callback_calls.append(True)
+
+        clean_manager.register_retranslate_callback(callback)
+
+        # Change language
+        clean_manager.set_language(SupportedLanguage.ENGLISH)
+
+        assert len(callback_calls) == 1
+
+        # Change again
+        clean_manager.set_language(SupportedLanguage.CHINESE_SIMPLIFIED)
+
+        assert len(callback_calls) == 2
+
+    def test_get_language_menu(self, clean_manager):
+        """Test getting language selection menu."""
+        menu = clean_manager.get_language_menu()
+        assert menu is not None
+
+        # Should have actions for all languages
+        assert menu.isEmpty() is False
+
+
+class TestModuleLevelFunctions:
+    """Tests for module-level functions."""
+
+    def test_get_i18n_manager(self):
+        """Test getting i18n manager singleton."""
+        import src.ui.i18n as i18n
+        i18n._i18n_manager_instance = None
+
+        manager = get_i18n_manager()
+        assert manager is not None
+        assert isinstance(manager, I18nManager)
+
+    def test_t_function(self):
+        """Test module-level t() function."""
+        import src.ui.i18n as i18n
+        i18n._i18n_manager_instance = None
+        i18n._i18n_manager_instance = I18nManager()
+
+        # Set to Chinese
+        i18n._i18n_manager_instance.set_language(SupportedLanguage.CHINESE_SIMPLIFIED)
+
+        text = t("button.ok")
+        assert text == "确定"
+
+    def test_singleton_persistence(self):
+        """Test that singleton returns same instance."""
+        import src.ui.i18n as i18n
+        i18n._i18n_manager_instance = None
+
+        manager1 = get_i18n_manager()
+        manager2 = get_i18n_manager()
+        assert manager1 is manager2
+
+
+class TestTranslationCoverage:
+    """Tests to ensure translation coverage."""
+
+    def test_all_categories(self):
+        """Test translations for all UI categories."""
+        # Menus
+        assert any(key.startswith("menu.") for key in TRANSLATIONS)
+
+        # Actions
+        assert any(key.startswith("action.") for key in TRANSLATIONS)
+
+        # Buttons
+        assert any(key.startswith("button.") for key in TRANSLATIONS)
+
+        # Labels
+        assert any(key.startswith("label.") for key in TRANSLATIONS)
+
+        # Status
+        assert any(key.startswith("status.") for key in TRANSLATIONS)
+
+        # Dialogs
+        assert any(key.startswith("dialog.") for key in TRANSLATIONS)
+
+        # Messages
+        assert any(key.startswith("msg.") for key in TRANSLATIONS)
+
+    def test_glossary_translations(self):
+        """Test glossary-related translations."""
+        assert "glossary.add_term" in TRANSLATIONS
+        assert "glossary.character" in TRANSLATIONS
+        assert "glossary.skill" in TRANSLATIONS
+
+    def test_log_translations(self):
+        """Test log-related translations."""
+        assert "log.level_info" in TRANSLATIONS
+        assert "log.level_error" in TRANSLATIONS
+
+    def test_theme_translations(self):
+        """Test theme-related translations."""
+        assert "theme.light" in TRANSLATIONS
+        assert "theme.dark" in TRANSLATIONS

+ 362 - 0
tests/ui/test_log_viewer.py

@@ -0,0 +1,362 @@
+"""
+Tests for the Log Viewer UI component (Story 7.15).
+"""
+
+import pytest
+from pathlib import Path
+import tempfile
+from datetime import datetime, timedelta
+
+from PyQt6.QtWidgets import QApplication
+from PyQt6.QtCore import Qt
+
+from src.ui.log_viewer import (
+    LogLevel,
+    LogEntry,
+    InMemoryLogHandler,
+    LogListModel,
+    LogTableModel,
+    LogViewer,
+    get_log_handler,
+    log_info,
+    log_warning,
+    log_error,
+)
+
+
+@pytest.fixture
+def app(qtbot):
+    """Create QApplication for tests."""
+    return QApplication.instance() or QApplication([])
+
+
+@pytest.fixture
+def clean_handler():
+    """Create a fresh log handler for testing."""
+    import src.ui.log_viewer as lv
+    lv._global_log_handler = None
+    handler = InMemoryLogHandler()
+    return handler
+
+
+@pytest.fixture
+def sample_entries():
+    """Create sample log entries."""
+    now = datetime.now()
+    return [
+        LogEntry(
+            level=LogLevel.INFO,
+            timestamp=now - timedelta(minutes=3),
+            message="Application started",
+            module="main"
+        ),
+        LogEntry(
+            level=LogLevel.DEBUG,
+            timestamp=now - timedelta(minutes=2),
+            message="Loading configuration",
+            module="config"
+        ),
+        LogEntry(
+            level=LogLevel.WARNING,
+            timestamp=now - timedelta(minutes=1),
+            message="Configuration file not found, using defaults",
+            module="config"
+        ),
+        LogEntry(
+            level=LogLevel.ERROR,
+            timestamp=now - timedelta(seconds=30),
+            message="Failed to connect to server",
+            module="network"
+        ),
+        LogEntry(
+            level=LogLevel.INFO,
+            timestamp=now,
+            message="Translation started",
+            module="translator"
+        ),
+    ]
+
+
+class TestLogLevel:
+    """Tests for LogLevel enum."""
+
+    def test_level_values(self):
+        """Test LogLevel enum values."""
+        assert LogLevel.DEBUG.value == "DEBUG"
+        assert LogLevel.INFO.value == "INFO"
+        assert LogLevel.WARNING.value == "WARNING"
+        assert LogLevel.ERROR.value == "ERROR"
+        assert LogLevel.CRITICAL.value == "CRITICAL"
+
+    def test_display_names(self):
+        """Test Chinese display names."""
+        assert LogLevel.DEBUG.display_name == "调试"
+        assert LogLevel.INFO.display_name == "信息"
+        assert LogLevel.WARNING.display_name == "警告"
+        assert LogLevel.ERROR.display_name == "错误"
+        assert LogLevel.CRITICAL.display_name == "严重"
+
+    def test_colors(self):
+        """Test level colors."""
+        assert LogLevel.DEBUG.color == "#888888"
+        assert LogLevel.INFO.color == "#3498db"
+        assert LogLevel.WARNING.color == "#f39c12"
+        assert LogLevel.ERROR.color == "#e74c3c"
+        assert LogLevel.CRITICAL.color == "#8e44ad"
+
+
+class TestLogEntry:
+    """Tests for LogEntry dataclass."""
+
+    def test_entry_creation(self):
+        """Test creating a log entry."""
+        entry = LogEntry(
+            level=LogLevel.INFO,
+            timestamp=datetime.now(),
+            message="Test message"
+        )
+
+        assert entry.level == LogLevel.INFO
+        assert entry.message == "Test message"
+
+    def test_str_representation(self):
+        """Test string representation of entry."""
+        now = datetime.now()
+        entry = LogEntry(
+            level=LogLevel.INFO,
+            timestamp=now,
+            message="Test message"
+        )
+
+        str_repr = str(entry)
+        assert "[INFO]" in str_repr
+        assert "Test message" in str_repr
+
+    def test_timestamp_display(self):
+        """Test timestamp display formatting."""
+        now = datetime(2024, 3, 15, 14, 30, 45)
+        entry = LogEntry(
+            level=LogLevel.INFO,
+            timestamp=now,
+            message="Test"
+        )
+
+        assert entry.timestamp_display == "14:30:45"
+        assert entry.date_display == "2024-03-15"
+
+    def test_level_properties(self):
+        """Test level-related properties."""
+        entry = LogEntry(
+            level=LogLevel.ERROR,
+            timestamp=datetime.now(),
+            message="Error message"
+        )
+
+        assert entry.level_display == "错误"
+        assert entry.level_color == "#e74c3c"
+
+
+class TestInMemoryLogHandler:
+    """Tests for InMemoryLogHandler."""
+
+    def test_initialization(self, clean_handler):
+        """Test handler initialization."""
+        assert clean_handler.entry_count == 0
+
+    def test_emit_entry(self, clean_handler):
+        """Test emitting a log entry."""
+        clean_handler.emit(LogLevel.INFO, "Test message")
+
+        assert clean_handler.entry_count == 1
+
+    def test_emit_with_metadata(self, clean_handler):
+        """Test emitting with metadata."""
+        clean_handler.emit(
+            LogLevel.ERROR,
+            "Error occurred",
+            module="test_module",
+            function="test_func",
+            line=42
+        )
+
+        assert clean_handler.entry_count == 1
+
+    def test_get_all_entries(self, clean_handler):
+        """Test getting all entries."""
+        clean_handler.emit(LogLevel.INFO, "Message 1")
+        clean_handler.emit(LogLevel.WARNING, "Message 2")
+
+        entries = clean_handler.get_entries()
+        assert len(entries) == 2
+
+    def test_filter_by_level(self, clean_handler):
+        """Test filtering by log level."""
+        clean_handler.emit(LogLevel.DEBUG, "Debug message")
+        clean_handler.emit(LogLevel.INFO, "Info message")
+        clean_handler.emit(LogLevel.WARNING, "Warning message")
+        clean_handler.emit(LogLevel.ERROR, "Error message")
+
+        # Get only INFO and above
+        entries = clean_handler.get_entries(min_level=LogLevel.INFO)
+        assert len(entries) == 3
+
+        # Get only ERROR and above
+        entries = clean_handler.get_entries(min_level=LogLevel.ERROR)
+        assert len(entries) == 1
+
+    def test_filter_by_search_text(self, clean_handler):
+        """Test filtering by search text."""
+        clean_handler.emit(LogLevel.INFO, "Application started")
+        clean_handler.emit(LogLevel.INFO, "Configuration loaded")
+        clean_handler.emit(LogLevel.ERROR, "Failed to connect")
+
+        entries = clean_handler.get_entries(search_text="failed")
+        assert len(entries) == 1
+
+        entries = clean_handler.get_entries(search_text="app")
+        assert len(entries) == 1
+
+    def test_limit_entries(self, clean_handler):
+        """Test limiting number of entries."""
+        for i in range(10):
+            clean_handler.emit(LogLevel.INFO, f"Message {i}")
+
+        entries = clean_handler.get_entries(limit=5)
+        assert len(entries) == 5
+
+    def test_max_entries_trimming(self):
+        """Test that handler trims old entries when over max."""
+        handler = InMemoryLogHandler(max_entries=5)
+
+        for i in range(10):
+            handler.emit(LogLevel.INFO, f"Message {i}")
+
+        # Should only keep last 5
+        assert handler.entry_count == 5
+
+        entries = handler.get_entries()
+        assert entries[0].message == "Message 5"
+        assert entries[-1].message == "Message 9"
+
+    def test_clear(self, clean_handler):
+        """Test clearing all entries."""
+        clean_handler.emit(LogLevel.INFO, "Message 1")
+        clean_handler.emit(LogLevel.INFO, "Message 2")
+        assert clean_handler.entry_count == 2
+
+        clean_handler.clear()
+        assert clean_handler.entry_count == 0
+
+    def test_listeners(self, clean_handler):
+        """Test entry listeners."""
+        received = []
+
+        def listener(entry):
+            received.append(entry)
+
+        clean_handler.add_listener(listener)
+        clean_handler.emit(LogLevel.INFO, "Test message")
+
+        assert len(received) == 1
+        assert received[0].message == "Test message"
+
+        # Remove listener
+        clean_handler.remove_listener(listener)
+        clean_handler.emit(LogLevel.INFO, "Another message")
+
+        assert len(received) == 1  # Should not have received the second message
+
+
+class TestLogTableModel:
+    """Tests for LogTableModel."""
+
+    def test_initialization(self, app, sample_entries):
+        """Test model initialization."""
+        model = LogTableModel(sample_entries)
+
+        assert model.rowCount() == 5
+        assert model.columnCount() == 4
+
+    def test_data_retrieval(self, app, sample_entries):
+        """Test data retrieval from model."""
+        model = LogTableModel(sample_entries)
+
+        # Check first row
+        index = model.index(0, 1)  # Level column
+        assert model.data(index) == "信息"
+
+        index = model.index(0, 2)  # Message column
+        assert "Application started" in model.data(index)
+
+    def test_header_data(self, app, sample_entries):
+        """Test header data."""
+        model = LogTableModel(sample_entries)
+
+        assert model.headerData(0, Qt.Orientation.Horizontal) == "时间"
+        assert model.headerData(1, Qt.Orientation.Horizontal) == "级别"
+        assert model.headerData(2, Qt.Orientation.Horizontal) == "消息"
+        assert model.headerData(3, Qt.Orientation.Horizontal) == "模块"
+
+    def test_get_entry_at_row(self, app, sample_entries):
+        """Test getting entry at specific row."""
+        model = LogTableModel(sample_entries)
+
+        entry = model.get_entry_at_row(0)
+        assert entry is not None
+        assert entry.message == "Application started"
+
+        entry = model.get_entry_at_row(999)
+        assert entry is None
+
+
+class TestLogFunctions:
+    """Tests for module-level logging functions."""
+
+    def test_log_info(self):
+        """Test log_info function."""
+        import src.ui.log_viewer as lv
+        lv._global_log_handler = None
+
+        log_info("Info message")
+        handler = get_log_handler()
+        assert handler.entry_count == 1
+
+    def test_log_warning(self):
+        """Test log_warning function."""
+        import src.ui.log_viewer as lv
+        lv._global_log_handler = None
+
+        log_warning("Warning message")
+        handler = get_log_handler()
+        assert handler.entry_count == 1
+
+    def test_log_error(self):
+        """Test log_error function."""
+        import src.ui.log_viewer as lv
+        lv._global_log_handler = None
+
+        log_error("Error message")
+        handler = get_log_handler()
+        assert handler.entry_count == 1
+
+
+class TestLogHandlerSingleton:
+    """Tests for the log handler singleton."""
+
+    def test_get_singleton(self):
+        """Test getting singleton instance."""
+        import src.ui.log_viewer as lv
+        lv._global_log_handler = None
+
+        handler = get_log_handler()
+        assert handler is not None
+        assert isinstance(handler, InMemoryLogHandler)
+
+    def test_singleton_persistence(self):
+        """Test that singleton returns same instance."""
+        import src.ui.log_viewer as lv
+        lv._global_log_handler = None
+
+        handler1 = get_log_handler()
+        handler2 = get_log_handler()
+        assert handler1 is handler2

+ 281 - 0
tests/ui/test_shortcuts.py

@@ -0,0 +1,281 @@
+"""
+Tests for the Keyboard Shortcuts UI component (Story 7.13).
+"""
+
+import pytest
+from pathlib import Path
+
+from PyQt6.QtWidgets import QApplication
+from PyQt6.QtGui import QKeySequence
+
+from src.ui.shortcuts import (
+    StandardAction,
+    DEFAULT_SHORTCUTS,
+    ACTION_NAMES,
+    ShortcutEntry,
+    ShortcutsManager,
+    ShortcutsModel,
+    get_shortcuts_manager,
+)
+
+
+@pytest.fixture
+def app(qtbot):
+    """Create QApplication for tests."""
+    return QApplication.instance() or QApplication([])
+
+
+@pytest.fixture
+def clean_manager():
+    """Create a fresh ShortcutsManager for testing."""
+    import src.ui.shortcuts as sc
+    sc._shortcuts_manager_instance = None
+    manager = ShortcutsManager()
+    return manager
+
+
+class TestStandardAction:
+    """Tests for StandardAction enum."""
+
+    def test_action_values(self):
+        """Test StandardAction enum values."""
+        assert StandardAction.OPEN_FILE.value == "open_file"
+        assert StandardAction.SAVE_FILE.value == "save_file"
+        assert StandardAction.START_TRANSLATION.value == "start_translation"
+
+
+class TestDefaultShortcuts:
+    """Tests for default shortcuts."""
+
+    def test_all_actions_have_defaults(self):
+        """Test that all standard actions have default shortcuts."""
+        for action in StandardAction:
+            assert action in DEFAULT_SHORTCUTS
+            assert DEFAULT_SHORTCUTS[action]
+
+    def test_all_actions_have_names(self):
+        """Test that all standard actions have display names."""
+        for action in StandardAction:
+            assert action in ACTION_NAMES
+            assert ACTION_NAMES[action]
+
+    def test_common_shortcuts(self):
+        """Test common shortcut defaults."""
+        assert DEFAULT_SHORTCUTS[StandardAction.OPEN_FILE] == "Ctrl+O"
+        assert DEFAULT_SHORTCUTS[StandardAction.SAVE_FILE] == "Ctrl+S"
+        assert DEFAULT_SHORTCUTS[StandardAction.START_TRANSLATION] == "F5"
+        assert DEFAULT_SHORTCUTS[StandardAction.ABOUT] == "Ctrl+F1"
+
+
+class TestShortcutEntry:
+    """Tests for ShortcutEntry dataclass."""
+
+    def test_entry_creation(self):
+        """Test creating a shortcut entry."""
+        entry = ShortcutEntry(
+            action=StandardAction.OPEN_FILE,
+            key_sequence="Ctrl+O",
+            default_key_sequence="Ctrl+O",
+            name=ACTION_NAMES[StandardAction.OPEN_FILE],
+            category="文件"
+        )
+
+        assert entry.action == StandardAction.OPEN_FILE
+        assert entry.key_sequence == "Ctrl+O"
+        assert entry.default_key_sequence == "Ctrl+O"
+
+    def test_is_modified_false(self):
+        """Test is_modified when not modified."""
+        entry = ShortcutEntry(
+            action=StandardAction.OPEN_FILE,
+            key_sequence="Ctrl+O",
+            default_key_sequence="Ctrl+O",
+            name="Open File",
+            category="File"
+        )
+
+        assert entry.is_modified is False
+
+    def test_is_modified_true(self):
+        """Test is_modified when modified."""
+        entry = ShortcutEntry(
+            action=StandardAction.OPEN_FILE,
+            key_sequence="Ctrl+Shift+O",
+            default_key_sequence="Ctrl+O",
+            name="Open File",
+            category="File"
+        )
+
+        assert entry.is_modified is True
+
+
+class TestShortcutsManager:
+    """Tests for ShortcutsManager."""
+
+    def test_initialization(self, clean_manager):
+        """Test manager initialization."""
+        assert isinstance(clean_manager, ShortcutsManager)
+
+    def test_get_shortcut(self, clean_manager):
+        """Test getting shortcut for an action."""
+        shortcut = clean_manager.get_shortcut(StandardAction.OPEN_FILE)
+        assert isinstance(shortcut, QKeySequence)
+        assert shortcut.toString() == "Ctrl+O"
+
+    def test_get_all_shortcuts(self, clean_manager):
+        """Test all actions have shortcuts."""
+        for action in StandardAction:
+            shortcut = clean_manager.get_shortcut(action)
+            assert isinstance(shortcut, QKeySequence)
+
+    def test_set_shortcut(self, clean_manager):
+        """Test setting a shortcut."""
+        result = clean_manager.set_shortcut(StandardAction.OPEN_FILE, "Ctrl+Shift+O")
+        assert result is True
+
+        shortcut = clean_manager.get_shortcut(StandardAction.OPEN_FILE)
+        assert shortcut.toString() == "Ctrl+Shift+O"
+
+    def test_set_shortcut_conflict(self, clean_manager):
+        """Test that conflicting shortcuts are rejected."""
+        # Try to set OPEN_FILE to the same as SAVE_FILE
+        result = clean_manager.set_shortcut(StandardAction.OPEN_FILE, "Ctrl+S")
+        assert result is False
+
+    def test_reset_action(self, clean_manager):
+        """Test resetting a single action to default."""
+        # First change it
+        clean_manager.set_shortcut(StandardAction.OPEN_FILE, "Ctrl+Shift+O")
+
+        # Reset to default
+        clean_manager.reset_action(StandardAction.OPEN_FILE)
+
+        shortcut = clean_manager.get_shortcut(StandardAction.OPEN_FILE)
+        assert shortcut.toString() == DEFAULT_SHORTCUTS[StandardAction.OPEN_FILE]
+
+    def test_reset_to_defaults(self, clean_manager):
+        """Test resetting all shortcuts to defaults."""
+        # Change some shortcuts
+        clean_manager.set_shortcut(StandardAction.OPEN_FILE, "Ctrl+Shift+O")
+        clean_manager.set_shortcut(StandardAction.SAVE_FILE, "Ctrl+Shift+S")
+
+        # Reset all
+        clean_manager.reset_to_defaults()
+
+        assert clean_manager.get_shortcut(StandardAction.OPEN_FILE).toString() == "Ctrl+O"
+        assert clean_manager.get_shortcut(StandardAction.SAVE_FILE).toString() == "Ctrl+S"
+
+    def test_get_all_entries(self, clean_manager):
+        """Test getting all shortcut entries."""
+        entries = clean_manager.get_all_entries()
+
+        assert len(entries) == len(StandardAction)
+
+        for entry in entries:
+            assert isinstance(entry, ShortcutEntry)
+            assert isinstance(entry.action, StandardAction)
+            assert entry.name
+            assert entry.category
+            assert entry.key_sequence
+            assert entry.default_key_sequence
+
+    def test_create_action(self, app, clean_manager):
+        """Test creating a QAction with shortcut."""
+        callback_called = []
+
+        def callback():
+            callback_called.append(True)
+
+        action = clean_manager.create_action(
+            StandardAction.OPEN_FILE,
+            parent=None,
+            callback=callback
+        )
+
+        assert action is not None
+        assert action.text() == ACTION_NAMES[StandardAction.OPEN_FILE]
+        assert action.shortcut() == clean_manager.get_shortcut(StandardAction.OPEN_FILE)
+
+    def test_register_action(self, app, clean_manager):
+        """Test registering an existing QAction."""
+        from PyQt6.QtWidgets import QAction
+
+        qaction = QAction("Test", None)
+        clean_manager.register_action(StandardAction.OPEN_FILE, qaction)
+
+        assert qaction.shortcut() == clean_manager.get_shortcut(StandardAction.OPEN_FILE)
+
+    def test_register_then_change_shortcut(self, app, clean_manager):
+        """Test that registered QAction updates when shortcut changes."""
+        from PyQt6.QtWidgets import QAction
+
+        qaction = QAction("Test", None)
+        clean_manager.register_action(StandardAction.OPEN_FILE, qaction)
+
+        # Change the shortcut
+        clean_manager.set_shortcut(StandardAction.OPEN_FILE, "Ctrl+Shift+O")
+
+        # QAction should be updated
+        assert qaction.shortcut().toString() == "Ctrl+Shift+O"
+
+
+class TestShortcutsModel:
+    """Tests for ShortcutsModel."""
+
+    def test_model_initialization(self, app, clean_manager):
+        """Test model initialization."""
+        entries = clean_manager.get_all_entries()
+        model = ShortcutsModel(entries)
+
+        assert model.rowCount() == len(entries)
+        assert model.columnCount() == 3
+
+    def test_data_retrieval(self, app, clean_manager):
+        """Test data retrieval from model."""
+        entries = clean_manager.get_all_entries()
+        model = ShortcutsModel(entries)
+
+        index = model.index(0, 0)  # First row, action column
+        data = model.data(index)
+        assert data  # Should have a name
+
+        index = model.index(0, 1)  # First row, shortcut column
+        data = model.data(index)
+        assert isinstance(data, str)
+
+    def test_header_data(self, app, clean_manager):
+        """Test header data."""
+        entries = clean_manager.get_all_entries()
+        model = ShortcutsModel(entries)
+
+        assert model.headerData(0, Qt.Orientation.Horizontal) == "操作"
+        assert model.headerData(1, Qt.Orientation.Horizontal) == "快捷键"
+        assert model.headerData(2, Qt.Orientation.Horizontal) == "默认"
+
+    def test_get_entry_at_row(self, app, clean_manager):
+        """Test getting entry at specific row."""
+        entries = clean_manager.get_all_entries()
+        model = ShortcutsModel(entries)
+
+        entry = model.get_entry_at_row(0)
+        assert entry is not None
+        assert isinstance(entry, ShortcutEntry)
+
+        entry = model.get_entry_at_row(999)
+        assert entry is None
+
+
+class TestShortcutsSingleton:
+    """Tests for the singleton pattern."""
+
+    def test_get_singleton(self):
+        """Test getting singleton instance."""
+        manager = get_shortcuts_manager()
+        assert manager is not None
+        assert isinstance(manager, ShortcutsManager)
+
+    def test_singleton_persistence(self):
+        """Test that singleton returns same instance."""
+        manager1 = get_shortcuts_manager()
+        manager2 = get_shortcuts_manager()
+        assert manager1 is manager2

+ 198 - 0
tests/ui/test_theme_manager.py

@@ -0,0 +1,198 @@
+"""
+Tests for the Theme Manager UI component (Story 7.11).
+"""
+
+import pytest
+from pathlib import Path
+import tempfile
+
+from PyQt6.QtWidgets import QApplication
+
+from src.ui.theme_manager import (
+    ThemeMode,
+    ThemeColors,
+    ThemeManager,
+    LIGHT_COLORS,
+    DARK_COLORS,
+    get_theme_manager,
+)
+
+
+@pytest.fixture
+def app(qtbot):
+    """Create QApplication for tests."""
+    return QApplication.instance() or QApplication([])
+
+
+@pytest.fixture
+def clean_manager():
+    """Create a fresh ThemeManager for testing."""
+    # Reset singleton
+    import src.ui.theme_manager as tm
+    tm._theme_manager_instance = None
+    manager = ThemeManager()
+    return manager
+
+
+class TestThemeMode:
+    """Tests for ThemeMode enum."""
+
+    def test_theme_mode_values(self):
+        """Test ThemeMode enum values."""
+        assert ThemeMode.LIGHT.value == "light"
+        assert ThemeMode.DARK.value == "dark"
+        assert ThemeMode.AUTO.value == "auto"
+
+
+class TestThemeColors:
+    """Tests for ThemeColors dataclass."""
+
+    def test_light_colors_structure(self):
+        """Test light colors have all required fields."""
+        assert hasattr(LIGHT_COLORS, "window_bg")
+        assert hasattr(LIGHT_COLORS, "window_text")
+        assert hasattr(LIGHT_COLORS, "highlight")
+        assert hasattr(LIGHT_COLORS, "success")
+        assert hasattr(LIGHT_COLORS, "warning")
+        assert hasattr(LIGHT_COLORS, "error")
+
+    def test_dark_colors_structure(self):
+        """Test dark colors have all required fields."""
+        assert hasattr(DARK_COLORS, "window_bg")
+        assert hasattr(DARK_COLORS, "window_text")
+        assert hasattr(DARK_COLORS, "highlight")
+        assert hasattr(DARK_COLORS, "success")
+        assert hasattr(DARK_COLORS, "warning")
+        assert hasattr(DARK_COLORS, "error")
+
+    def test_light_and_dark_differ(self):
+        """Test that light and dark themes are different."""
+        assert LIGHT_COLORS.window_bg != DARK_COLORS.window_bg
+        assert LIGHT_COLORS.window_text != DARK_COLORS.window_text
+
+
+class TestThemeManager:
+    """Tests for ThemeManager."""
+
+    def test_initialization(self, clean_manager):
+        """Test manager initialization."""
+        assert clean_manager.current_mode == ThemeMode.LIGHT
+
+    def test_set_theme_light(self, clean_manager):
+        """Test setting light theme."""
+        clean_manager.set_theme(ThemeMode.LIGHT)
+        assert clean_manager.current_mode == ThemeMode.LIGHT
+        assert clean_manager.colors == LIGHT_COLORS
+
+    def test_set_theme_dark(self, clean_manager):
+        """Test setting dark theme."""
+        clean_manager.set_theme(ThemeMode.DARK)
+        assert clean_manager.current_mode == ThemeMode.DARK
+        assert clean_manager.colors == DARK_COLORS
+
+    def test_toggle_theme(self, clean_manager):
+        """Test toggling between themes."""
+        clean_manager.set_theme(ThemeMode.LIGHT)
+        clean_manager.toggle_theme()
+        assert clean_manager.current_mode == ThemeMode.DARK
+
+        clean_manager.toggle_theme()
+        assert clean_manager.current_mode == ThemeMode.LIGHT
+
+    def test_generate_stylesheet(self, clean_manager):
+        """Test stylesheet generation."""
+        stylesheet = clean_manager.generate_stylesheet()
+
+        assert isinstance(stylesheet, str)
+        assert len(stylesheet) > 0
+        assert "QWidget" in stylesheet
+        assert "QPushButton" in stylesheet
+        assert "QLineEdit" in stylesheet
+
+    def test_stylesheet_contains_colors(self, clean_manager):
+        """Test that stylesheet contains current theme colors."""
+        clean_manager.set_theme(ThemeMode.DARK)
+        stylesheet = clean_manager.generate_stylesheet()
+
+        assert DARK_COLORS.window_bg in stylesheet
+        assert DARK_COLORS.highlight in stylesheet
+
+    def test_apply_to_application(self, app, clean_manager):
+        """Test applying theme to application."""
+        clean_manager.set_theme(ThemeMode.DARK)
+        clean_manager.apply_to_application(app)
+
+        # Stylesheet should be set
+        assert app.styleSheet() != ""
+
+    def test_get_color(self, clean_manager):
+        """Test getting individual colors."""
+        clean_manager.set_theme(ThemeMode.LIGHT)
+
+        highlight = clean_manager.get_color("highlight")
+        assert highlight == LIGHT_COLORS.highlight
+
+        window_bg = clean_manager.get_color("window_bg")
+        assert window_bg == LIGHT_COLORS.window_bg
+
+    def test_adjust_color(self, clean_manager):
+        """Test color adjustment."""
+        # Lighten a color
+        result = clean_manager._adjust_color("#3498db", 20)
+        assert result.startswith("#")
+        assert len(result) == 7
+
+        # Darken a color
+        result = clean_manager._adjust_color("#3498db", -20)
+        assert result.startswith("#")
+        assert len(result) == 7
+
+    def test_custom_stylesheet(self, clean_manager, tmp_path):
+        """Test loading custom stylesheet."""
+        css_file = tmp_path / "custom.css"
+        css_file.write_text("QPushButton { background-color: red; }")
+
+        result = clean_manager.load_stylesheet_from_file(css_file)
+        assert result is True
+        assert "red" in clean_manager._custom_stylesheet
+
+    def test_load_nonexistent_stylesheet(self, clean_manager):
+        """Test loading non-existent stylesheet file."""
+        result = clean_manager.load_stylesheet_from_file(Path("/nonexistent/file.css"))
+        assert result is False
+
+    def test_theme_changed_signal(self, app, clean_manager, qtbot):
+        """Test that theme_changed signal is emitted."""
+        signals = []
+
+        def on_theme_changed(mode):
+            signals.append(mode)
+
+        clean_manager.theme_changed.connect(on_theme_changed)
+        clean_manager.set_theme(ThemeMode.DARK)
+
+        assert len(signals) == 1
+        assert signals[0] == ThemeMode.DARK
+
+    def test_set_custom_stylesheet(self, clean_manager):
+        """Test setting custom stylesheet."""
+        custom_css = "QPushButton { background: red; }"
+        clean_manager.set_custom_stylesheet(custom_css)
+
+        assert clean_manager._custom_stylesheet == custom_css
+
+
+class TestThemeManagerSingleton:
+    """Tests for the singleton pattern."""
+
+    def test_get_singleton(self):
+        """Test getting singleton instance."""
+        manager = get_theme_manager()
+        assert manager is not None
+        assert isinstance(manager, ThemeManager)
+
+    def test_singleton_persistence(self):
+        """Test that singleton returns same instance."""
+        manager1 = get_theme_manager()
+        manager2 = get_theme_manager()
+        assert manager1 is manager2