[
  {
    "path": ".clang-format",
    "content": "# SpinningMomo C++ Code Style\n# Based on Google style with minimal overrides\n\nBasedOnStyle: Google\nLanguage: Cpp\n\n# 主要差异：使用100字符行宽而非Google默认的80\nColumnLimit: 100\n\n# 保持include分组结构，只在组内排序（适合模块化项目）\nIncludeBlocks: Preserve\n"
  },
  {
    "path": ".gitattributes",
    "content": "# 默认强制所有文本文件使用 LF (Unix-style) 行尾，避免跨平台问题\n* text=auto eol=lf\n\n# 为必须使用 CRLF (Windows-style) 的文件设置例外\nsrc/resources/app.rc       text eol=crlf\nsrc/resources/app.manifest text eol=crlf\n# *.bat      text eol=crlf\n# *.cmd      text eol=crlf\n\n# 标记二进制文件，防止 Git 对其进行文本处理\n*.png      binary\n*.jpg      binary\n*.jpeg     binary\n*.webp     binary\n*.ico      binary\n*.exe      binary\n*.dll      binary\n*.lib      binary\n*.pdb      binary\n\n# GitHub Linguist 优化：从语言统计中排除第三方库和文档\nthird_party/**      linguist-vendored\ndocs/**             linguist-documentation\ndocumentation/**    linguist-documentation "
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.yml",
    "content": "name: 问题报告\ndescription: 报告使用过程中遇到的问题或错误\ntitle: \"[Bug] \"\nlabels: [\"bug\"]\nbody:\n  - type: textarea\n    id: description\n    attributes:\n      label: 问题描述\n      description: 简单描述你遇到的问题，以及如何复现这个问题？\n      placeholder: 请详细描述问题...\n    validations:\n      required: true\n      \n  - type: textarea\n    id: logs\n    attributes:\n      label: 日志文件\n      description: 请以附件的形式上传 `app.log` 文件（安装版位于 `%LOCALAPPDATA%\\SpinningMomo\\logs` 目录下，便携版位于程序所在的 `data\\logs` 目录下）\n      placeholder: 如有必要，请先在“设置 -> 通用 -> 日志级别”中切换为 DEBUG 后复现问题\n    validations:\n      required: false\n      \n  - type: textarea\n    id: additional\n    attributes:\n      label: 其他信息\n      description: 截图或其他有助于解决问题的信息（可选）\n      placeholder: 添加截图或其他信息...\n    validations:\n      required: false\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.yml",
    "content": "name: 功能建议\ndescription: 提出新功能或改进建议\ntitle: \"[Feature] \"\nlabels: [\"enhancement\"]\nbody:\n  - type: textarea\n    id: feature-description\n    attributes:\n      label: 功能描述\n      description: 描述你想要的功能，以及在什么情况下使用？\n      placeholder: 请详细描述您的功能建议...\n    validations:\n      required: true\n      \n  - type: textarea\n    id: additional\n    attributes:\n      label: 其他信息\n      description: 其他相关信息或想法（可选）\n      placeholder: 添加其他相关信息...\n    validations:\n      required: false"
  },
  {
    "path": ".github/workflows/build-release.yml",
    "content": "# Build and Release workflow\n# Triggers on version tags (v*) or manual dispatch\n\nname: Build Release\n\non:\n  push:\n    tags:\n      - 'v*'\n  workflow_dispatch:\n    inputs:\n      version:\n        description: 'Version override (e.g., 1.0.0). Leave empty to extract from version.json'\n        required: false\n        type: string\n\npermissions:\n  contents: write\n\njobs:\n  build:\n    runs-on: windows-latest\n    env:\n      # Keep xmake/vcpkg caches in workspace for reliable cache restore on GitHub runners.\n      XMAKE_GLOBALDIR: ${{ github.workspace }}\\.xmake-global\n      VCPKG_DEFAULT_BINARY_CACHE: ${{ github.workspace }}\\.vcpkg\\archives\n    \n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n\n      - name: Fetch third-party dependencies\n        shell: pwsh\n        run: .\\scripts\\fetch-third-party.ps1\n\n      - name: Setup Node.js\n        uses: actions/setup-node@v4\n        with:\n          node-version: 24\n          cache: 'npm'\n          cache-dependency-path: |\n            web/package-lock.json\n\n      - name: Setup xmake\n        uses: xmake-io/github-action-setup-xmake@v1\n        with:\n          xmake-version: latest\n\n      - name: Cache xmake/vcpkg dependencies\n        id: cache_xmake_vcpkg\n        uses: actions/cache@v4\n        with:\n          path: |\n            .xmake\n            .xmake-global\n            .vcpkg/archives\n          key: ${{ runner.os }}-${{ runner.arch }}-xmake-vcpkg-${{ hashFiles('xmake.lua', 'xmake-requires.lock') }}\n          restore-keys: |\n            ${{ runner.os }}-${{ runner.arch }}-xmake-vcpkg-\n\n      - name: Ensure cache directories exist\n        shell: pwsh\n        run: |\n          New-Item -ItemType Directory -Force -Path \".xmake\" | Out-Null\n          New-Item -ItemType Directory -Force -Path \".xmake-global\" | Out-Null\n          New-Item -ItemType Directory -Force -Path \".vcpkg/archives\" | Out-Null\n\n      - name: Cache diagnostics (concise)\n        shell: pwsh\n        run: |\n          Write-Host \"xmake/vcpkg cache hit: ${{ steps.cache_xmake_vcpkg.outputs.cache-hit }}\"\n          Write-Host \"binary cache dir: $env:VCPKG_DEFAULT_BINARY_CACHE\"\n          Write-Host \".xmake -> $((Test-Path '.xmake') ? 'exists' : 'missing')\"\n          Write-Host \".xmake-global -> $((Test-Path '.xmake-global') ? 'exists' : 'missing')\"\n          Write-Host \".vcpkg/archives -> $((Test-Path '.vcpkg/archives') ? 'exists' : 'missing')\"\n\n      - name: Setup .NET (for WiX)\n        uses: actions/setup-dotnet@v4\n        with:\n          dotnet-version: '8.0.x'\n\n      - name: Install WiX Toolset v6\n        run: |\n          dotnet tool install --global wix --version 6.0.2\n          wix extension add WixToolset.UI.wixext/6.0.2 --global\n          wix extension add WixToolset.Util.wixext/6.0.2 --global\n          wix extension add WixToolset.BootstrapperApplications.wixext/6.0.2 --global\n\n      - name: Resolve final version\n        id: final_version\n        shell: pwsh\n        run: |\n          $version = \"${{ inputs.version }}\"\n          if ([string]::IsNullOrEmpty($version)) {\n            $versionJson = Get-Content \"version.json\" -Raw | ConvertFrom-Json\n            $rawVersion = $versionJson.version\n            if ([string]::IsNullOrWhiteSpace($rawVersion)) {\n              Write-Error \"Could not extract version from version.json\"\n              exit 1\n            }\n            # Convert 1.0.0.0 to 1.0.0 for MSI (max 3 parts)\n            $versionParts = $rawVersion.Split('.')\n            if ($versionParts.Count -lt 3) {\n              Write-Error \"version.json version must contain at least 3 parts: $rawVersion\"\n              exit 1\n            }\n            $version = \"$($versionParts[0]).$($versionParts[1]).$($versionParts[2])\"\n          }\n          Write-Host \"Final version: $version\"\n          echo \"VERSION=$version\" >> $env:GITHUB_OUTPUT\n\n      - name: Install dependencies\n        run: |\n          npm install\n          cd web && npm ci\n\n      - name: Build all (C++ + Web + Dist)\n        run: npm run build:ci\n\n      - name: Create Portable ZIP\n        run: npm run build:portable\n\n      - name: Build MSI and Bundle Installer\n        shell: pwsh\n        run: .\\scripts\\build-msi.ps1 -Version \"${{ steps.final_version.outputs.VERSION }}\"\n\n      - name: Generate checksums\n        run: npm run build:checksums\n\n      - name: Upload artifacts (for debugging)\n        uses: actions/upload-artifact@v4\n        with:\n          name: SpinningMomo-All\n          path: |\n            dist/*-Setup.exe\n            dist/*-Portable.zip\n            dist/SHA256SUMS.txt\n            build/windows/x64/release/SpinningMomo.pdb\n          if-no-files-found: error\n\n      - name: Create GitHub Release\n        if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')\n        uses: softprops/action-gh-release@v1\n        with:\n          files: |\n            dist/*-Setup.exe\n            dist/*-Portable.zip\n            dist/SHA256SUMS.txt\n          draft: false\n          prerelease: ${{ contains(github.ref, '-') }}\n          generate_release_notes: true\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Upload release files to Cloudflare R2\n        if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')\n        shell: pwsh\n        env:\n          AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}\n          AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}\n          AWS_DEFAULT_REGION: auto\n          AWS_ENDPOINT_URL: https://${{ secrets.CLOUDFLARE_ACCOUNT_ID }}.r2.cloudflarestorage.com\n        run: |\n          $version = \"${{ steps.final_version.outputs.VERSION }}\"\n          $bucket = \"${{ secrets.R2_BUCKET_NAME }}\"\n          $dest = \"s3://$bucket/releases/v$version\"\n          \n          aws s3 cp \"dist/SpinningMomo-$version-x64-Portable.zip\" \"$dest/SpinningMomo-$version-x64-Portable.zip\"\n          aws s3 cp \"dist/SpinningMomo-$version-x64-Setup.exe\" \"$dest/SpinningMomo-$version-x64-Setup.exe\"\n          aws s3 cp \"dist/SHA256SUMS.txt\" \"$dest/SHA256SUMS.txt\"\n          Write-Host \"Uploaded release files to R2: $dest\"\n"
  },
  {
    "path": ".github/workflows/deploy-docs.yml",
    "content": "# 工作流名称\r\nname: Deploy VitePress Documentation\r\n\r\n# 触发条件：在 main 分支的 push 事件，且仅当 docs 目录有变更时\r\n# 同时支持手动触发\r\non:\r\n  push:\r\n    branches: [main]\r\n    paths:\r\n      - 'docs/**'\r\n  # 添加手动触发\r\n  workflow_dispatch:\r\n    inputs:\r\n      reason:\r\n        description: '触发原因（可选）'\r\n        required: false\r\n        type: string\r\n\r\n# 设置 GITHUB_TOKEN 的权限\r\npermissions:\r\n  contents: read\r\n  pages: write\r\n  id-token: write\r\n\r\n# 确保同时只有一个部署任务在运行\r\nconcurrency:\r\n  group: pages\r\n  cancel-in-progress: false\r\n\r\njobs:\r\n  # 构建任务\r\n  build:\r\n    runs-on: ubuntu-latest\r\n    env:\r\n      VITE_BASE_PATH: /SpinningMomo/\r\n    steps:\r\n      - name: Checkout\r\n        uses: actions/checkout@v4\r\n        \r\n      - name: Setup Node\r\n        uses: actions/setup-node@v4\r\n        with:\r\n          node-version: 24\r\n          cache: npm\r\n          cache-dependency-path: docs/package-lock.json\r\n          \r\n      - name: Setup Pages\r\n        uses: actions/configure-pages@v5\r\n        \r\n      - name: Install dependencies\r\n        run: |\r\n          cd docs\r\n          npm ci\r\n        \r\n      - name: Build\r\n        run: |\r\n          cd docs\r\n          npm run build\r\n        \r\n      - name: Upload artifact\r\n        uses: actions/upload-pages-artifact@v3\r\n        with:\r\n          path: docs/.vitepress/dist\r\n\r\n  # 部署任务\r\n  deploy:\r\n    environment:\r\n      name: github-pages\r\n      url: ${{ steps.deployment.outputs.page_url }}\r\n    needs: build\r\n    runs-on: ubuntu-latest\r\n    name: Deploy\r\n    steps:\r\n      - name: Deploy to GitHub Pages\r\n        id: deployment\r\n        uses: actions/deploy-pages@v4"
  },
  {
    "path": ".gitignore",
    "content": "# Build directories\nout/\n[Bb]uild/\n[Dd]ebug/\n[Rr]elease/\ndist/\n\n# Visual Studio files\n.vs/\n*.user\n*.suo\n*.sdf\n*.opensdf\n*.VC.db\n*.VC.opendb\n\n# Compiled files\n*.exe\n*.dll\n*.lib\n*.pdb\n*.ilk\n*.obj\n*.idb\n*.pch\n\n# CMake generated files\nCMakeCache.txt\nCMakeFiles/\ncmake_install.cmake\ncompile_commands.json\n\n# Xmake generated files\n.xmake/\nvsxmake2022/\n\n# WiX generated files\n*.msi\n*.wixpdb\n\n# VitePress docs\ndocs/.vitepress/dist/\ndocs/.vitepress/cache/\ndocs/node_modules/\n\n# OS generated files\n.DS_Store\n.DS_Store?\n._*\n.Spotlight-V100\n.Trashes\nehthumbs.db\nThumbs.db \n\nthird_party/\ntest/\n/playground/\n\n# AI\n.windsurfrules\n.cursor\n.claude\n\n# Node.js dependencies\nnode_modules/\npackage-lock.json\n\n# Web App (Vite + React)\nweb/node_modules/\nweb/dist/\nweb/dist-ssr/\nweb/.env\nweb/.env.local\nweb/.env.development.local\nweb/.env.test.local\nweb/.env.production.local\nweb/*.log\nweb/coverage/\nweb/.cache/\nweb/.parcel-cache/\nweb/.vite/\nweb/stats*\n\nweb_react/\n\n# Generated HarmonyOS subset font artifacts\nweb/src/assets/fonts/harmonyos-sans-sc/*.woff2\nweb/src/assets/fonts/harmonyos-sans-sc/charset-generated.txt\nweb/src/assets/fonts/harmonyos-sans-sc/manifest.json\nweb/src/assets/fonts/harmonyos-sans-sc/LICENSE.txt\n"
  },
  {
    "path": ".husky/pre-commit",
    "content": "npx lint-staged\n"
  },
  {
    "path": ".vscode/c_cpp_properties.json",
    "content": "{\n    \"configurations\": [\n        {\n            \"name\": \"Win64\",\n            \"includePath\": [\n                \"${workspaceFolder}/src/**\"\n            ],\n            \"defines\": [\n                \"_DEBUG\",\n                \"UNICODE\",\n                \"_UNICODE\"\n            ],\n            \"windowsSdkVersion\": \"10.0.22621.0\",\n            \"compilerPath\": \"cl.exe\",\n            \"cStandard\": \"c23\",\n            \"cppStandard\": \"c++23\",\n            \"compileCommands\": \".vscode/compile_commands.json\",\n            \"intelliSenseMode\": \"windows-msvc-x64\",\n            \"configurationProvider\": \"ms-vscode.cmake-tools\"\n        }\n    ],\n    \"version\": 4\n}"
  },
  {
    "path": ".vscode/launch.json",
    "content": "{\n    // 使用 IntelliSense 了解相关属性。 \n    // 悬停以查看现有属性的描述。\n    // 欲了解更多信息，请访问: https://go.microsoft.com/fwlink/?linkid=830387\n    \"version\": \"0.2.0\",\n    \"configurations\": [\n        {\n            \"type\": \"chrome\",\n            \"request\": \"launch\",\n            \"name\": \"Launch Chrome against localhost\",\n            \"url\": \"http://localhost:5173\",\n            \"webRoot\": \"${workspaceFolder}\"\n        }\n    ]\n}"
  },
  {
    "path": ".vscode/settings.json",
    "content": "{\n  \"cmake.configureOnOpen\": true,\n  \"C_Cpp.default.cppStandard\": \"c++23\",\n  \"C_Cpp.clang_format_style\": \"file\",\n  \"C_Cpp.default.compilerArgs\": [\"/std:c++latest\", \"/experimental:module\"],\n  \"C_Cpp.experimentalFeatures\": \"enabled\",\n  \"C_Cpp.intelliSenseEngine\": \"Tag Parser\",\n  \"C_Cpp.enhancedColorization\": \"enabled\",\n  \"C_Cpp.errorSquiggles\": \"disabled\",\n  \"files.associations\": {\n    \"vector\": \"cpp\",\n    \"xstring\": \"cpp\",\n    \"algorithm\": \"cpp\",\n    \"array\": \"cpp\",\n    \"atomic\": \"cpp\",\n    \"bit\": \"cpp\",\n    \"cctype\": \"cpp\",\n    \"charconv\": \"cpp\",\n    \"chrono\": \"cpp\",\n    \"clocale\": \"cpp\",\n    \"cmath\": \"cpp\",\n    \"compare\": \"cpp\",\n    \"concepts\": \"cpp\",\n    \"coroutine\": \"cpp\",\n    \"cstddef\": \"cpp\",\n    \"cstdint\": \"cpp\",\n    \"cstdio\": \"cpp\",\n    \"cstdlib\": \"cpp\",\n    \"cstring\": \"cpp\",\n    \"ctime\": \"cpp\",\n    \"cwchar\": \"cpp\",\n    \"exception\": \"cpp\",\n    \"filesystem\": \"cpp\",\n    \"format\": \"cpp\",\n    \"forward_list\": \"cpp\",\n    \"initializer_list\": \"cpp\",\n    \"iomanip\": \"cpp\",\n    \"ios\": \"cpp\",\n    \"iosfwd\": \"cpp\",\n    \"istream\": \"cpp\",\n    \"iterator\": \"cpp\",\n    \"limits\": \"cpp\",\n    \"list\": \"cpp\",\n    \"locale\": \"cpp\",\n    \"map\": \"cpp\",\n    \"memory\": \"cpp\",\n    \"new\": \"cpp\",\n    \"optional\": \"cpp\",\n    \"ostream\": \"cpp\",\n    \"ratio\": \"cpp\",\n    \"sstream\": \"cpp\",\n    \"stdexcept\": \"cpp\",\n    \"stop_token\": \"cpp\",\n    \"streambuf\": \"cpp\",\n    \"string\": \"cpp\",\n    \"system_error\": \"cpp\",\n    \"thread\": \"cpp\",\n    \"tuple\": \"cpp\",\n    \"type_traits\": \"cpp\",\n    \"typeinfo\": \"cpp\",\n    \"unordered_map\": \"cpp\",\n    \"utility\": \"cpp\",\n    \"xfacet\": \"cpp\",\n    \"xhash\": \"cpp\",\n    \"xiosbase\": \"cpp\",\n    \"xlocale\": \"cpp\",\n    \"xlocbuf\": \"cpp\",\n    \"xlocinfo\": \"cpp\",\n    \"xlocmes\": \"cpp\",\n    \"xlocmon\": \"cpp\",\n    \"xlocnum\": \"cpp\",\n    \"xloctime\": \"cpp\",\n    \"xmemory\": \"cpp\",\n    \"xtr1common\": \"cpp\",\n    \"xtree\": \"cpp\",\n    \"xutility\": \"cpp\",\n    \"deque\": \"cpp\",\n    \"functional\": \"cpp\",\n    \"mutex\": \"cpp\",\n    \"queue\": \"cpp\",\n    \"set\": \"cpp\",\n    \"any\": \"cpp\",\n    \"bitset\": \"cpp\",\n    \"cfenv\": \"cpp\",\n    \"cinttypes\": \"cpp\",\n    \"complex\": \"cpp\",\n    \"condition_variable\": \"cpp\",\n    \"csignal\": \"cpp\",\n    \"cstdarg\": \"cpp\",\n    \"cwctype\": \"cpp\",\n    \"fstream\": \"cpp\",\n    \"future\": \"cpp\",\n    \"iostream\": \"cpp\",\n    \"numeric\": \"cpp\",\n    \"random\": \"cpp\",\n    \"regex\": \"cpp\",\n    \"scoped_allocator\": \"cpp\",\n    \"span\": \"cpp\",\n    \"stack\": \"cpp\",\n    \"typeindex\": \"cpp\",\n    \"unordered_set\": \"cpp\",\n    \"valarray\": \"cpp\",\n    \"variant\": \"cpp\",\n    \"barrier\": \"cpp\",\n    \"codecvt\": \"cpp\",\n    \"csetjmp\": \"cpp\",\n    \"cuchar\": \"cpp\",\n    \"execution\": \"cpp\",\n    \"expected\": \"cpp\",\n    \"latch\": \"cpp\",\n    \"mdspan\": \"cpp\",\n    \"memory_resource\": \"cpp\",\n    \"numbers\": \"cpp\",\n    \"print\": \"cpp\",\n    \"ranges\": \"cpp\",\n    \"semaphore\": \"cpp\",\n    \"shared_mutex\": \"cpp\",\n    \"source_location\": \"cpp\",\n    \"spanstream\": \"cpp\",\n    \"stacktrace\": \"cpp\",\n    \"stdfloat\": \"cpp\",\n    \"strstream\": \"cpp\",\n    \"syncstream\": \"cpp\",\n    \"resumable\": \"cpp\",\n    \"*.ipp\": \"cpp\",\n    \"*.rh\": \"cpp\",\n    \"generator\": \"cpp\"\n  }\n}\n"
  },
  {
    "path": "AGENTS.md",
    "content": "# AGENTS.md\n\nThis file provides guidance to AI when working with code in this repository.\n\n## 第一性原理\n请使用第一性原理思考。你不能总是假设我非常清楚自己想要什么和该怎么得到。请保持审慎，从原始需求和问题出发，如果动机和目标不清晰，停下来和我讨论。\n\n## 方案规范\n当需要你给出修改或重构方案时必须符合以下规范：\n- 不允许给出兼容性或补丁性的方案\n- 不允许过度设计，保持最短路径实现且不能违反第一条要求\n\n## Project Overview\n\nSpinningMomo (旋转吧大喵) is a Windows-only desktop tool for the game \"Infinity Nikki\" (无限暖暖), focused on photography, screenshots, recording, and related workflow tooling around the game window. The current repository is a native Win32 C++ application with an embedded web frontend, plus supporting docs, packaging, and playground tooling. The codebase is bilingual — code comments and UI strings are predominantly in Chinese.\n\n## Build & Development\n\n### Prerequisites\n- Visual Studio 2022+ with Windows SDK 10.0.22621.0+\n- xmake (primary build system)\n- Node.js / npm (for web frontend, docs, and formatting scripts)\n- .NET SDK 8.0+ and WiX v5+ when building installers locally\n\n### Build Commands\n```\n# C++ backend — debug (default)\nxmake config -m debug\nxmake build\n\n# C++ backend — release\nxmake release          # builds release then restores debug config\n\n# Full native + web build via xmake task\nxmake build-all\n\n# Web frontend (in web/ directory)\ncd web && npm run build\n\n# Full build: C++ release + web + assemble dist/\nnpm run build\n\n# Release artifacts\nnpm run build:portable\nnpm run build:installer\nnpm run release\n\n# Generate VS project files (vsxmake, debug+release)\nxmake vs\n```\n\nA husky pre-commit hook runs `lint-staged` which auto-formats staged C++ (`.cpp`, `.ixx`, `.h`, `.hpp`) and web files.\n\n### Web Frontend Dev Server\n```\ncd web && npm run dev\n```\nVite dev server proxies `/rpc` and `/static` to the C++ backend at `localhost:51206`.\n\n### Docs Dev Server\n```\ncd docs && npm run dev\n```\n`docs/` is a separate VitePress documentation site and is not part of the runtime app bundle.\n\n## Architecture\n\n### Two-Process Model\nThe application is a **native Win32 C++ backend** that hosts an embedded **WebView2** frontend. Communication happens over **JSON-RPC 2.0** through two transport layers:\n- **WebView bridge** — used when the Vue app runs inside WebView2 (production)\n- **HTTP + SSE** — used when the Vue app runs in a browser during development (uWebSockets on port 51206). SSE provides server-to-client push notifications.\n\nThe frontend auto-detects its environment (`window.chrome.webview` presence) and selects the appropriate transport.\n\n### C++ Module System\nThe backend uses **C++23 modules** (`.ixx` interface files, `.cpp` implementation files). Module names follow a dotted hierarchy that mirrors the directory structure:\n\n- `Core.*` — framework infrastructure (async runtime, database, events, HTTP client, HTTP server, RPC, WebView, i18n, commands, migration, worker pool, tasks, runtime info, shutdown, state)\n- `Features.*` — business logic modules such as gallery, letterbox, notifications, overlay, preview, recording, replay_buffer, screenshot, settings, update, and window_control\n- `UI.*` — native Win32 UI (floating_window, tray_icon, context_menu, webview_window)\n- `Utils.*` — shared utilities such as logger, file, graphics, image, media, path, string, system, throttle, timer, dialog, crash_dump, and crypto\n- `Vendor.*` — thin wrappers re-exporting Win32 API and third-party types through the module system (e.g. `Vendor.Windows` wraps `<windows.h>`)\n\n### Design Philosophy\nThe C++ backend does **NOT** use OOP class hierarchies. Instead it follows:\n- **POD Structs + Free Functions**: plain data structs with free functions operating on them.\n- **Centralized State**: all state lives in `AppState`, passed by reference.\n- **Feature Independence**: features depend on `Core.*` but must NOT depend on each other.\n\n### Central AppState\n`Core::State::AppState` is the single root state object. It owns all subsystem states as `std::unique_ptr` members. Functions are free functions that accept `AppState&`.\n\n### Key Patterns\n- **Error handling**: `std::expected<T, std::string>` throughout; no exception-based control flow.\n- **Async**: Asio-based coroutine runtime (`Core::Async`). RPC handlers return `asio::awaitable<RpcResult<T>>`.\n- **Events**: Type-erased event bus (`Core::Events`) with sync `send()` and async `post()` (wakes the Win32 message loop via `PostMessageW`).\n- **RPC registration**: `Core::RPC::register_method<Req, Res>()` auto-generates JSON Schema from C++ types via reflect-cpp. Field names are auto-converted between `snake_case` (C++) and `camelCase` (JSON).\n- **Commands**: `Core::Commands` registry binds actions, toggle states, i18n keys, and optional hotkeys. Context menu and tray icon are driven by this registry.\n- **Database**: SQLite via SQLiteCpp with thread-local connections, a `DataMapper` for ORM-like row mapping, and an auto-generated migration system (`scripts/generate-migrations.js`).\n- **Vendor wrappers**: Win32 macros/functions are re-exported as proper C++ functions/constants in `Vendor::Windows` to stay compatible with the module system.\n- **String encoding**: internal processing uses UTF-8 (`std::string`); Win32 API calls use UTF-16 (`std::wstring`). Convert via utilities in `Utils.String`.\n\n### Web Frontend (web/)\nThe main frontend lives in `web/` and uses Vue 3 + TypeScript + Pinia + Tailwind CSS v4 + shadcn-vue/reka-ui. It is built with a Vite-compatible toolchain. Key directories:\n- `web/src/core/rpc/` — JSON-RPC client with WebView and HTTP transports\n- `web/src/core/i18n/` — client-side i18n\n- `web/src/core/env/` — runtime environment detection\n- `web/src/core/tasks/` — frontend task orchestration\n- `web/src/features/` — feature modules (gallery, settings, home, about, map, onboarding, common, playground)\n- `web/src/composables/` — shared composables (`useRpc`, `useI18n`, `useToast`)\n- `web/src/extensions/` — game-specific integrations (infinity_nikki)\n- `web/src/router/` — routes\n- `web/src/types/` — shared TS types\n- `web/src/lib/` — shared UI/helpers\n- `web/src/assets/` — static assets\n\n### Additional Repo Surfaces\n- `docs/` — VitePress documentation site for user and developer docs\n- `playground/` — standalone Node/TypeScript scripts for backend HTTP/RPC debugging and experiments\n- `installer/` — WiX source files for MSI and bundle installer generation\n- `tasks/` — custom xmake tasks such as `build-all`, `release`, and `vs`\n\n### Gallery Module\n`gallery` is one of the core vertical slices of the project. It is not just a page: it spans backend indexing/scanning/watchers/static file serving and frontend browsing/filtering/lightbox/detail workflows.\n\n- **Backend entry points**:\n  - `src/features/gallery/gallery.ixx/.cpp` is the orchestration layer for initialization, cleanup, scanning, thumbnail maintenance, file actions, and watcher registration.\n  - `src/features/gallery/state.ixx` holds gallery runtime state such as thumbnail directory, asset path LRU cache, and per-root folder watcher state.\n  - `src/features/gallery/scanner.*` handles full scans and index updates.\n  - `src/features/gallery/watcher.*` restores folder watchers from DB, starts/stops them, and keeps the index in sync after startup.\n  - `src/features/gallery/static_resolver.*` exposes thumbnails and original files to both HTTP dev mode and embedded WebView mode via `/static/assets/thumbnails/...` and `/static/assets/originals/<assetId>`.\n- **Backend subdomains**:\n  - `asset/` is the largest data domain and owns querying, timeline views, home stats, review state, descriptions, color extraction, thumbnails, and Infinity Nikki metadata access.\n  - `folder/` owns folder tree persistence, display names, and root watch management.\n  - `tag/` owns tag tree CRUD and asset-tag relations.\n  - `ignore/` contains ignore-rule matching and persistence used by scans.\n  - `color/` contains extracted main-color models and filtering support.\n- **RPC shape**:\n  - Gallery RPC is split by concern under `src/core/rpc/endpoints/gallery/`: `gallery.cpp` for scanning/maintenance, `asset.cpp` for asset queries and actions, `folder.cpp` for folder tree/navigation actions, and `tag.cpp` for tag management.\n  - Frontend code should usually enter gallery through RPC methods prefixed with `gallery.*`.\n  - Backend sends `gallery.changed` notifications after scan/index mutations; the frontend listens to this event and refreshes folder tree plus current asset view.\n- **Startup behavior**:\n  - During app initialization, the gallery module is initialized first, then watcher registrations are restored from DB, then Infinity Nikki photo-source registration runs, and finally all registered gallery watchers are started near the end of startup.\n- **Frontend entry points**:\n  - `web/src/features/gallery/api.ts` is the RPC facade and static URL helper layer.\n- `web/src/features/gallery/store/index.ts` is the single source of truth for gallery UI state. Store internals are split into `store/querySlice.ts`, `store/navigationSlice.ts`, `store/interactionSlice.ts`, and shared helpers in `store/persistence.ts`.\n  - `web/src/features/gallery/composables/` coordinates behavior around data loading, selection, layout, sidebar, lightbox, virtualized grids, and asset actions.\n  - `web/src/features/gallery/pages/GalleryPage.vue` hosts the three-pane shell (sidebar, viewer, details); child views live under `web/src/features/gallery/components/` in subfolders: `shell/` (sidebar, viewer, details, toolbar, content), `viewer/` (grid/list/masonry/adaptive), `asset/`, `tags/`, `folders/`, `dialogs/`, `menus/`, `infinity_nikki/`, and `lightbox/`.\n  - `web/src/features/gallery/routes.ts` defines the `/gallery` route; `web/src/router/index.ts` spreads it so there is a single source of truth for path, name (`gallery`), and meta.\n- **Frontend data flow**:\n  - Prefer the existing pattern `component -> composable -> api -> RPC` and let components read state directly from the Pinia store.\n  - `useGalleryData()` loads data and writes into store state.\n  - `useGallerySelection()` and `useGalleryAssetActions()` implement higher-level UI behaviors on top of the store instead of duplicating state in components.\n- When changing filters/sort/view mode, check `store/index.ts` plus related slices under `store/` and `queryFilters.ts`; when changing visible behavior, check the relevant composable before editing large Vue components.\n- **Domain model summary**:\n  - The gallery centers on `Asset`, `FolderTreeNode`, `TagTreeNode`, scan/task progress, and flexible `QueryAssetsFilters`.\n  - The same conceptual model exists on both sides: C++ types in `src/features/gallery/types.ixx`, mirrored by TS types in `web/src/features/gallery/types.ts`.\n  - Infinity Nikki-specific enrichments such as photo params and map points are exposed as part of gallery asset queries rather than as a completely separate frontend feature.\n\n### RPC Endpoint Organization\nEndpoints live under `src/core/rpc/endpoints/<domain>/`, each domain exposes a `register_all(state)` called from `registry.cpp`. Current domains include file, clipboard, dialog, runtime_info, settings, tasks, update, webview, gallery, extensions, registry, and window_control. Game-specific adapters in `src/extensions/` (currently `infinity_nikki`) are exposed via `rpc/endpoints/extensions/`.\n\n### Initialization Order\nInitialization still follows the top-level chain `main.cpp` → `Application::Initialize()` → `Core::Initializer::initialize_application()`. In practice this sets up core infrastructure first (events, async/runtime, worker pool, RPC, HTTP, database, settings, update, commands), then native UI surfaces, then feature services such as recording/replay/gallery, then the Infinity Nikki extension, onboarding gate, hotkeys, and startup update checks.\n\n## Build Output\n- Release: `build\\windows\\x64\\release\\`\n- Debug: `build\\windows\\x64\\debug\\`\n- Distribution: `dist/` (exe + web resources)\n\n## Installer\nInstallers are built via `scripts/build-msi.ps1`. The script builds an MSI package and, by default, a WiX bundle-based setup `.exe`, both under `dist/`.\n\n## Code Generation Scripts\nThese must be re-run when their source files change:\n- `node scripts/generate-migrations.js` — after modifying `src/migrations/*.sql`\n- `node scripts/generate-embedded-locales.js` — after modifying `src/locales/*.json` (zh-CN / en-US)\n- `node scripts/generate-map-injection-cpp.js` — after modifying `web/src/features/map/injection/source/*.js` (regenerates minified JS and C++ map injection module)\n\n## Naming Conventions\n- **C++ module names**: PascalCase with dots — `Features.Gallery`, `Core.RPC.Types`\n- **C++ files/functions**: snake_case — `gallery.ixx`, `initialize()`\n- **No anonymous namespaces**: Do not use `namespace { ... }` in C++; put helpers in the module's named namespace.\n- **Frontend components**: PascalCase — `GalleryPage.vue`\n- **Frontend modules**: camelCase — `galleryApi.ts`\n- **Module import order** in `.ixx`: `std` → `Vendor.*` → `Core.*` → `Features.*` / `UI.*` / `Utils.*`\n\n## Testing\nNo automated test suite. Manual testing only:\n1. Build and run the exe.\n2. Use the `web/src/features/playground/` pages for interactive RPC endpoint testing during development.\n3. Use the root-level `playground/` scripts for backend HTTP/RPC debugging and ad-hoc experiments.\n\n## Adding a New Feature\n1. Create a directory under `src/features/<name>/` with at minimum a `.ixx` module interface and `.cpp` implementation.\n2. Add a state struct in `<name>/state.ixx` and register it in `Core::State::AppState`.\n3. Add RPC endpoint file under `src/core/rpc/endpoints/<name>/`, implement `register_all(state)`, and wire it in `registry.cpp`.\n4. Register commands in `src/core/commands/builtin.cpp` if the feature needs hotkeys/menu entries.\n5. If the feature needs initialization, add it to `Core::Initializer::initialize_application`.\n6. On the web side, add a feature directory under `web/src/features/<name>/` with `api.ts`, `store/index.ts`, `types.ts`, components, and pages.\n"
  },
  {
    "path": "CREDITS.md",
    "content": "# 第三方开源项目鸣谢 (Third-Party Open Source Software)\n\nSpinningMomo（旋转吧大喵）的开发离不开以下优秀的开源项目，向它们的作者表示由衷的感谢。\n\n(SpinningMomo is built upon the following excellent open-source projects. We express our sincere gratitude to their authors.)\n\n## C++ Backend (原生后端)\n\n| Project | License | Url |\n|---------|---------|-----|\n| [xmake](https://github.com/xmake-io/xmake) | Apache-2.0 | https://github.com/xmake-io/xmake |\n| [uWebSockets](https://github.com/uNetworking/uWebSockets) | Apache-2.0 | https://github.com/uNetworking/uWebSockets |\n| [uSockets](https://github.com/uNetworking/uSockets) | Apache-2.0 | https://github.com/uNetworking/uSockets |\n| [reflect-cpp](https://github.com/getml/reflect-cpp) | MIT | https://github.com/getml/reflect-cpp |\n| [spdlog](https://github.com/gabime/spdlog) | MIT | https://github.com/gabime/spdlog |\n| [asio](https://github.com/chriskohlhoff/asio) | BSL-1.0 | https://github.com/chriskohlhoff/asio |\n| [yyjson](https://github.com/ibireme/yyjson) | MIT | https://github.com/ibireme/yyjson |\n| [fmt](https://github.com/fmtlib/fmt) | MIT | https://github.com/fmtlib/fmt |\n| [WebView2](https://developer.microsoft.com/en-us/microsoft-edge/webview2/) | Microsoft | https://developer.microsoft.com/en-us/microsoft-edge/webview2/ |\n| [wil (Windows Implementation Libraries)](https://github.com/Microsoft/wil) | MIT | https://github.com/Microsoft/wil |\n| [xxHash](https://github.com/Cyan4973/xxHash) | BSD-2-Clause | https://github.com/Cyan4973/xxHash |\n| [SQLiteCpp](https://github.com/SRombauts/SQLiteCpp) | MIT | https://github.com/SRombauts/SQLiteCpp |\n| [SQLite3](https://www.sqlite.org/index.html) | Public Domain | https://www.sqlite.org/index.html |\n| [libwebp](https://chromium.googlesource.com/webm/libwebp) | BSD-3-Clause | https://chromium.googlesource.com/webm/libwebp |\n| [zlib](https://zlib.net/) | zlib License | https://zlib.net/ |\n| [libuv](https://libuv.org/) | MIT | https://github.com/libuv/libuv |\n\n\n## Web Frontend (Web 前端)\n\n| Project | License | Url |\n|---------|---------|-----|\n| [Vue.js](https://vuejs.org/) | MIT | https://github.com/vuejs/core |\n| [Vite](https://vitejs.dev/) | MIT | https://github.com/vitejs/vite |\n| [Tailwind CSS](https://tailwindcss.com/) | MIT | https://github.com/tailwindlabs/tailwindcss |\n| [Pinia](https://pinia.vuejs.org/) | MIT | https://github.com/vuejs/pinia |\n| [Vue Router](https://router.vuejs.org/) | MIT | https://github.com/vuejs/router |\n| [VueUse](https://vueuse.org/) | MIT | https://github.com/vueuse/vueuse |\n| [TanStack Virtual](https://tanstack.com/virtual) | MIT | https://github.com/TanStack/virtual |\n| [Lucide](https://lucide.dev/) | ISC | https://github.com/lucide-icons/lucide |\n| [Inter Font](https://rsms.me/inter/) | OFL-1.1 (Open Font License) | https://github.com/rsms/inter |\n| [Reka UI](https://reka-ui.com/) | MIT | https://github.com/unovue/reka-ui |\n| [shadcn-vue](https://www.shadcn-vue.com/) | MIT | https://github.com/radix-vue/shadcn-vue |\n| [vue-sonner](https://github.com/xiaoluoboding/vue-sonner) | MIT | https://github.com/xiaoluoboding/vue-sonner |\n\n--\n\n*Full license texts for the above projects can be found in their respective repositories or distribution packages.*\n"
  },
  {
    "path": "LEGAL.md",
    "content": "# SpinningMomo Legal & Privacy Notice\n\nLatest version date: 2026-03-23\n\n- Chinese: https://spin.infinitymomo.com/zh/about/legal\n- English: https://spin.infinitymomo.com/en/about/legal"
  },
  {
    "path": "LICENSE",
    "content": "                    GNU GENERAL PUBLIC LICENSE\n                       Version 3, 29 June 2007\n\n Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>\n Everyone is permitted to copy and distribute verbatim copies\n of this license document, but changing it is not allowed.\n\n                            Preamble\n\n  The GNU General Public License is a free, copyleft license for\nsoftware and other kinds of works.\n\n  The licenses for most software and other practical works are designed\nto take away your freedom to share and change the works.  By contrast,\nthe GNU General Public License is intended to guarantee your freedom to\nshare and change all versions of a program--to make sure it remains free\nsoftware for all its users.  We, the Free Software Foundation, use the\nGNU General Public License for most of our software; it applies also to\nany other work released this way by its authors.  You can apply it to\nyour programs, too.\n\n  When we speak of free software, we are referring to freedom, not\nprice.  Our General Public Licenses are designed to make sure that you\nhave the freedom to distribute copies of free software (and charge for\nthem if you wish), that you receive source code or can get it if you\nwant it, that you can change the software or use pieces of it in new\nfree programs, and that you know you can do these things.\n\n  To protect your rights, we need to prevent others from denying you\nthese rights or asking you to surrender the rights.  Therefore, you have\ncertain responsibilities if you distribute copies of the software, or if\nyou modify it: responsibilities to respect the freedom of others.\n\n  For example, if you distribute copies of such a program, whether\ngratis or for a fee, you must pass on to the recipients the same\nfreedoms that you received.  You must make sure that they, too, receive\nor can get the source code.  And you must show them these terms so they\nknow their rights.\n\n  Developers that use the GNU GPL protect your rights with two steps:\n(1) assert copyright on the software, and (2) offer you this License\ngiving you legal permission to copy, distribute and/or modify it.\n\n  For the developers' and authors' protection, the GPL clearly explains\nthat there is no warranty for this free software.  For both users' and\nauthors' sake, the GPL requires that modified versions be marked as\nchanged, so that their problems will not be attributed erroneously to\nauthors of previous versions.\n\n  Some devices are designed to deny users access to install or run\nmodified versions of the software inside them, although the manufacturer\ncan do so.  This is fundamentally incompatible with the aim of\nprotecting users' freedom to change the software.  The systematic\npattern of such abuse occurs in the area of products for individuals to\nuse, which is precisely where it is most unacceptable.  Therefore, we\nhave designed this version of the GPL to prohibit the practice for those\nproducts.  If such problems arise substantially in other domains, we\nstand ready to extend this provision to those domains in future versions\nof the GPL, as needed to protect the freedom of users.\n\n  Finally, every program is threatened constantly by software patents.\nStates should not allow patents to restrict development and use of\nsoftware on general-purpose computers, but in those that do, we wish to\navoid the special danger that patents applied to a free program could\nmake it effectively proprietary.  To prevent this, the GPL assures that\npatents cannot be used to render the program non-free.\n\n  The precise terms and conditions for copying, distribution and\nmodification follow.\n\n                       TERMS AND CONDITIONS\n\n  0. Definitions.\n\n  \"This License\" refers to version 3 of the GNU General Public License.\n\n  \"Copyright\" also means copyright-like laws that apply to other kinds of\nworks, such as semiconductor masks.\n\n  \"The Program\" refers to any copyrightable work licensed under this\nLicense.  Each licensee is addressed as \"you\".  \"Licensees\" and\n\"recipients\" may be individuals or organizations.\n\n  To \"modify\" a work means to copy from or adapt all or part of the work\nin a fashion requiring copyright permission, other than the making of an\nexact copy.  The resulting work is called a \"modified version\" of the\nearlier work or a work \"based on\" the earlier work.\n\n  A \"covered work\" means either the unmodified Program or a work based\non the Program.\n\n  To \"propagate\" a work means to do anything with it that, without\npermission, would make you directly or secondarily liable for\ninfringement under applicable copyright law, except executing it on a\ncomputer or modifying a private copy.  Propagation includes copying,\ndistribution (with or without modification), making available to the\npublic, and in some countries other activities as well.\n\n  To \"convey\" a work means any kind of propagation that enables other\nparties to make or receive copies.  Mere interaction with a user through\na computer network, with no transfer of a copy, is not conveying.\n\n  An interactive user interface displays \"Appropriate Legal Notices\"\nto the extent that it includes a convenient and prominently visible\nfeature that (1) displays an appropriate copyright notice, and (2)\ntells the user that there is no warranty for the work (except to the\nextent that warranties are provided), that licensees may convey the\nwork under this License, and how to view a copy of this License.  If\nthe interface presents a list of user commands or options, such as a\nmenu, a prominent item in the list meets this criterion.\n\n  1. Source Code.\n\n  The \"source code\" for a work means the preferred form of the work\nfor making modifications to it.  \"Object code\" means any non-source\nform of a work.\n\n  A \"Standard Interface\" means an interface that either is an official\nstandard defined by a recognized standards body, or, in the case of\ninterfaces specified for a particular programming language, one that\nis widely used among developers working in that language.\n\n  The \"System Libraries\" of an executable work include anything, other\nthan the work as a whole, that (a) is included in the normal form of\npackaging a Major Component, but which is not part of that Major\nComponent, and (b) serves only to enable use of the work with that\nMajor Component, or to implement a Standard Interface for which an\nimplementation is available to the public in source code form.  A\n\"Major Component\", in this context, means a major essential component\n(kernel, window system, and so on) of the specific operating system\n(if any) on which the executable work runs, or a compiler used to\nproduce the work, or an object code interpreter used to run it.\n\n  The \"Corresponding Source\" for a work in object code form means all\nthe source code needed to generate, install, and (for an executable\nwork) run the object code and to modify the work, including scripts to\ncontrol those activities.  However, it does not include the work's\nSystem Libraries, or general-purpose tools or generally available free\nprograms which are used unmodified in performing those activities but\nwhich are not part of the work.  For example, Corresponding Source\nincludes interface definition files associated with source files for\nthe work, and the source code for shared libraries and dynamically\nlinked subprograms that the work is specifically designed to require,\nsuch as by intimate data communication or control flow between those\nsubprograms and other parts of the work.\n\n  The Corresponding Source need not include anything that users\ncan regenerate automatically from other parts of the Corresponding\nSource.\n\n  The Corresponding Source for a work in source code form is that\nsame work.\n\n  2. Basic Permissions.\n\n  All rights granted under this License are granted for the term of\ncopyright on the Program, and are irrevocable provided the stated\nconditions are met.  This License explicitly affirms your unlimited\npermission to run the unmodified Program.  The output from running a\ncovered work is covered by this License only if the output, given its\ncontent, constitutes a covered work.  This License acknowledges your\nrights of fair use or other equivalent, as provided by copyright law.\n\n  You may make, run and propagate covered works that you do not\nconvey, without conditions so long as your license otherwise remains\nin force.  You may convey covered works to others for the sole purpose\nof having them make modifications exclusively for you, or provide you\nwith facilities for running those works, provided that you comply with\nthe terms of this License in conveying all material for which you do\nnot control copyright.  Those thus making or running the covered works\nfor you must do so exclusively on your behalf, under your direction\nand control, on terms that prohibit them from making any copies of\nyour copyrighted material outside their relationship with you.\n\n  Conveying under any other circumstances is permitted solely under\nthe conditions stated below.  Sublicensing is not allowed; section 10\nmakes it unnecessary.\n\n  3. Protecting Users' Legal Rights From Anti-Circumvention Law.\n\n  No covered work shall be deemed part of an effective technological\nmeasure under any applicable law fulfilling obligations under article\n11 of the WIPO copyright treaty adopted on 20 December 1996, or\nsimilar laws prohibiting or restricting circumvention of such\nmeasures.\n\n  When you convey a covered work, you waive any legal power to forbid\ncircumvention of technological measures to the extent such circumvention\nis effected by exercising rights under this License with respect to\nthe covered work, and you disclaim any intention to limit operation or\nmodification of the work as a means of enforcing, against the work's\nusers, your or third parties' legal rights to forbid circumvention of\ntechnological measures.\n\n  4. Conveying Verbatim Copies.\n\n  You may convey verbatim copies of the Program's source code as you\nreceive it, in any medium, provided that you conspicuously and\nappropriately publish on each copy an appropriate copyright notice;\nkeep intact all notices stating that this License and any\nnon-permissive terms added in accord with section 7 apply to the code;\nkeep intact all notices of the absence of any warranty; and give all\nrecipients a copy of this License along with the Program.\n\n  You may charge any price or no price for each copy that you convey,\nand you may offer support or warranty protection for a fee.\n\n  5. Conveying Modified Source Versions.\n\n  You may convey a work based on the Program, or the modifications to\nproduce it from the Program, in the form of source code under the\nterms of section 4, provided that you also meet all of these conditions:\n\n    a) The work must carry prominent notices stating that you modified\n    it, and giving a relevant date.\n\n    b) The work must carry prominent notices stating that it is\n    released under this License and any conditions added under section\n    7.  This requirement modifies the requirement in section 4 to\n    \"keep intact all notices\".\n\n    c) You must license the entire work, as a whole, under this\n    License to anyone who comes into possession of a copy.  This\n    License will therefore apply, along with any applicable section 7\n    additional terms, to the whole of the work, and all its parts,\n    regardless of how they are packaged.  This License gives no\n    permission to license the work in any other way, but it does not\n    invalidate such permission if you have separately received it.\n\n    d) If the work has interactive user interfaces, each must display\n    Appropriate Legal Notices; however, if the Program has interactive\n    interfaces that do not display Appropriate Legal Notices, your\n    work need not make them do so.\n\n  A compilation of a covered work with other separate and independent\nworks, which are not by their nature extensions of the covered work,\nand which are not combined with it such as to form a larger program,\nin or on a volume of a storage or distribution medium, is called an\n\"aggregate\" if the compilation and its resulting copyright are not\nused to limit the access or legal rights of the compilation's users\nbeyond what the individual works permit.  Inclusion of a covered work\nin an aggregate does not cause this License to apply to the other\nparts of the aggregate.\n\n  6. Conveying Non-Source Forms.\n\n  You may convey a covered work in object code form under the terms\nof sections 4 and 5, provided that you also convey the\nmachine-readable Corresponding Source under the terms of this License,\nin one of these ways:\n\n    a) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by the\n    Corresponding Source fixed on a durable physical medium\n    customarily used for software interchange.\n\n    b) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by a\n    written offer, valid for at least three years and valid for as\n    long as you offer spare parts or customer support for that product\n    model, to give anyone who possesses the object code either (1) a\n    copy of the Corresponding Source for all the software in the\n    product that is covered by this License, on a durable physical\n    medium customarily used for software interchange, for a price no\n    more than your reasonable cost of physically performing this\n    conveying of source, or (2) access to copy the\n    Corresponding Source from a network server at no charge.\n\n    c) Convey individual copies of the object code with a copy of the\n    written offer to provide the Corresponding Source.  This\n    alternative is allowed only occasionally and noncommercially, and\n    only if you received the object code with such an offer, in accord\n    with subsection 6b.\n\n    d) Convey the object code by offering access from a designated\n    place (gratis or for a charge), and offer equivalent access to the\n    Corresponding Source in the same way through the same place at no\n    further charge.  You need not require recipients to copy the\n    Corresponding Source along with the object code.  If the place to\n    copy the object code is a network server, the Corresponding Source\n    may be on a different server (operated by you or a third party)\n    that supports equivalent copying facilities, provided you maintain\n    clear directions next to the object code saying where to find the\n    Corresponding Source.  Regardless of what server hosts the\n    Corresponding Source, you remain obligated to ensure that it is\n    available for as long as needed to satisfy these requirements.\n\n    e) Convey the object code using peer-to-peer transmission, provided\n    you inform other peers where the object code and Corresponding\n    Source of the work are being offered to the general public at no\n    charge under subsection 6d.\n\n  A separable portion of the object code, whose source code is excluded\nfrom the Corresponding Source as a System Library, need not be\nincluded in conveying the object code work.\n\n  A \"User Product\" is either (1) a \"consumer product\", which means any\ntangible personal property which is normally used for personal, family,\nor household purposes, or (2) anything designed or sold for incorporation\ninto a dwelling.  In determining whether a product is a consumer product,\ndoubtful cases shall be resolved in favor of coverage.  For a particular\nproduct received by a particular user, \"normally used\" refers to a\ntypical or common use of that class of product, regardless of the status\nof the particular user or of the way in which the particular user\nactually uses, or expects or is expected to use, the product.  A product\nis a consumer product regardless of whether the product has substantial\ncommercial, industrial or non-consumer uses, unless such uses represent\nthe only significant mode of use of the product.\n\n  \"Installation Information\" for a User Product means any methods,\nprocedures, authorization keys, or other information required to install\nand execute modified versions of a covered work in that User Product from\na modified version of its Corresponding Source.  The information must\nsuffice to ensure that the continued functioning of the modified object\ncode is in no case prevented or interfered with solely because\nmodification has been made.\n\n  If you convey an object code work under this section in, or with, or\nspecifically for use in, a User Product, and the conveying occurs as\npart of a transaction in which the right of possession and use of the\nUser Product is transferred to the recipient in perpetuity or for a\nfixed term (regardless of how the transaction is characterized), the\nCorresponding Source conveyed under this section must be accompanied\nby the Installation Information.  But this requirement does not apply\nif neither you nor any third party retains the ability to install\nmodified object code on the User Product (for example, the work has\nbeen installed in ROM).\n\n  The requirement to provide Installation Information does not include a\nrequirement to continue to provide support service, warranty, or updates\nfor a work that has been modified or installed by the recipient, or for\nthe User Product in which it has been modified or installed.  Access to a\nnetwork may be denied when the modification itself materially and\nadversely affects the operation of the network or violates the rules and\nprotocols for communication across the network.\n\n  Corresponding Source conveyed, and Installation Information provided,\nin accord with this section must be in a format that is publicly\ndocumented (and with an implementation available to the public in\nsource code form), and must require no special password or key for\nunpacking, reading or copying.\n\n  7. Additional Terms.\n\n  \"Additional permissions\" are terms that supplement the terms of this\nLicense by making exceptions from one or more of its conditions.\nAdditional permissions that are applicable to the entire Program shall\nbe treated as though they were included in this License, to the extent\nthat they are valid under applicable law.  If additional permissions\napply only to part of the Program, that part may be used separately\nunder those permissions, but the entire Program remains governed by\nthis License without regard to the additional permissions.\n\n  When you convey a copy of a covered work, you may at your option\nremove any additional permissions from that copy, or from any part of\nit.  (Additional permissions may be written to require their own\nremoval in certain cases when you modify the work.)  You may place\nadditional permissions on material, added by you to a covered work,\nfor which you have or can give appropriate copyright permission.\n\n  Notwithstanding any other provision of this License, for material you\nadd to a covered work, you may (if authorized by the copyright holders of\nthat material) supplement the terms of this License with terms:\n\n    a) Disclaiming warranty or limiting liability differently from the\n    terms of sections 15 and 16 of this License; or\n\n    b) Requiring preservation of specified reasonable legal notices or\n    author attributions in that material or in the Appropriate Legal\n    Notices displayed by works containing it; or\n\n    c) Prohibiting misrepresentation of the origin of that material, or\n    requiring that modified versions of such material be marked in\n    reasonable ways as different from the original version; or\n\n    d) Limiting the use for publicity purposes of names of licensors or\n    authors of the material; or\n\n    e) Declining to grant rights under trademark law for use of some\n    trade names, trademarks, or service marks; or\n\n    f) Requiring indemnification of licensors and authors of that\n    material by anyone who conveys the material (or modified versions of\n    it) with contractual assumptions of liability to the recipient, for\n    any liability that these contractual assumptions directly impose on\n    those licensors and authors.\n\n  All other non-permissive additional terms are considered \"further\nrestrictions\" within the meaning of section 10.  If the Program as you\nreceived it, or any part of it, contains a notice stating that it is\ngoverned by this License along with a term that is a further\nrestriction, you may remove that term.  If a license document contains\na further restriction but permits relicensing or conveying under this\nLicense, you may add to a covered work material governed by the terms\nof that license document, provided that the further restriction does\nnot survive such relicensing or conveying.\n\n  If you add terms to a covered work in accord with this section, you\nmust place, in the relevant source files, a statement of the\nadditional terms that apply to those files, or a notice indicating\nwhere to find the applicable terms.\n\n  Additional terms, permissive or non-permissive, may be stated in the\nform of a separately written license, or stated as exceptions;\nthe above requirements apply either way.\n\n  8. Termination.\n\n  You may not propagate or modify a covered work except as expressly\nprovided under this License.  Any attempt otherwise to propagate or\nmodify it is void, and will automatically terminate your rights under\nthis License (including any patent licenses granted under the third\nparagraph of section 11).\n\n  However, if you cease all violation of this License, then your\nlicense from a particular copyright holder is reinstated (a)\nprovisionally, unless and until the copyright holder explicitly and\nfinally terminates your license, and (b) permanently, if the copyright\nholder fails to notify you of the violation by some reasonable means\nprior to 60 days after the cessation.\n\n  Moreover, your license from a particular copyright holder is\nreinstated permanently if the copyright holder notifies you of the\nviolation by some reasonable means, this is the first time you have\nreceived notice of violation of this License (for any work) from that\ncopyright holder, and you cure the violation prior to 30 days after\nyour receipt of the notice.\n\n  Termination of your rights under this section does not terminate the\nlicenses of parties who have received copies or rights from you under\nthis License.  If your rights have been terminated and not permanently\nreinstated, you do not qualify to receive new licenses for the same\nmaterial under section 10.\n\n  9. Acceptance Not Required for Having Copies.\n\n  You are not required to accept this License in order to receive or\nrun a copy of the Program.  Ancillary propagation of a covered work\noccurring solely as a consequence of using peer-to-peer transmission\nto receive a copy likewise does not require acceptance.  However,\nnothing other than this License grants you permission to propagate or\nmodify any covered work.  These actions infringe copyright if you do\nnot accept this License.  Therefore, by modifying or propagating a\ncovered work, you indicate your acceptance of this License to do so.\n\n  10. Automatic Licensing of Downstream Recipients.\n\n  Each time you convey a covered work, the recipient automatically\nreceives a license from the original licensors, to run, modify and\npropagate that work, subject to this License.  You are not responsible\nfor enforcing compliance by third parties with this License.\n\n  An \"entity transaction\" is a transaction transferring control of an\norganization, or substantially all assets of one, or subdividing an\norganization, or merging organizations.  If propagation of a covered\nwork results from an entity transaction, each party to that\ntransaction who receives a copy of the work also receives whatever\nlicenses to the work the party's predecessor in interest had or could\ngive under the previous paragraph, plus a right to possession of the\nCorresponding Source of the work from the predecessor in interest, if\nthe predecessor has it or can get it with reasonable efforts.\n\n  You may not impose any further restrictions on the exercise of the\nrights granted or affirmed under this License.  For example, you may\nnot impose a license fee, royalty, or other charge for exercise of\nrights granted under this License, and you may not initiate litigation\n(including a cross-claim or counterclaim in a lawsuit) alleging that\nany patent claim is infringed by making, using, selling, offering for\nsale, or importing the Program or any portion of it.\n\n  11. Patents.\n\n  A \"contributor\" is a copyright holder who authorizes use under this\nLicense of the Program or a work on which the Program is based.  The\nwork thus licensed is called the contributor's \"contributor version\".\n\n  A contributor's \"essential patent claims\" are all patent claims\nowned or controlled by the contributor, whether already acquired or\nhereafter acquired, that would be infringed by some manner, permitted\nby this License, of making, using, or selling its contributor version,\nbut do not include claims that would be infringed only as a\nconsequence of further modification of the contributor version.  For\npurposes of this definition, \"control\" includes the right to grant\npatent sublicenses in a manner consistent with the requirements of\nthis License.\n\n  Each contributor grants you a non-exclusive, worldwide, royalty-free\npatent license under the contributor's essential patent claims, to\nmake, use, sell, offer for sale, import and otherwise run, modify and\npropagate the contents of its contributor version.\n\n  In the following three paragraphs, a \"patent license\" is any express\nagreement or commitment, however denominated, not to enforce a patent\n(such as an express permission to practice a patent or covenant not to\nsue for patent infringement).  To \"grant\" such a patent license to a\nparty means to make such an agreement or commitment not to enforce a\npatent against the party.\n\n  If you convey a covered work, knowingly relying on a patent license,\nand the Corresponding Source of the work is not available for anyone\nto copy, free of charge and under the terms of this License, through a\npublicly available network server or other readily accessible means,\nthen you must either (1) cause the Corresponding Source to be so\navailable, or (2) arrange to deprive yourself of the benefit of the\npatent license for this particular work, or (3) arrange, in a manner\nconsistent with the requirements of this License, to extend the patent\nlicense to downstream recipients.  \"Knowingly relying\" means you have\nactual knowledge that, but for the patent license, your conveying the\ncovered work in a country, or your recipient's use of the covered work\nin a country, would infringe one or more identifiable patents in that\ncountry that you have reason to believe are valid.\n\n  If, pursuant to or in connection with a single transaction or\narrangement, you convey, or propagate by procuring conveyance of, a\ncovered work, and grant a patent license to some of the parties\nreceiving the covered work authorizing them to use, propagate, modify\nor convey a specific copy of the covered work, then the patent license\nyou grant is automatically extended to all recipients of the covered\nwork and works based on it.\n\n  A patent license is \"discriminatory\" if it does not include within\nthe scope of its coverage, prohibits the exercise of, or is\nconditioned on the non-exercise of one or more of the rights that are\nspecifically granted under this License.  You may not convey a covered\nwork if you are a party to an arrangement with a third party that is\nin the business of distributing software, under which you make payment\nto the third party based on the extent of your activity of conveying\nthe work, and under which the third party grants, to any of the\nparties who would receive the covered work from you, a discriminatory\npatent license (a) in connection with copies of the covered work\nconveyed by you (or copies made from those copies), or (b) primarily\nfor and in connection with specific products or compilations that\ncontain the covered work, unless you entered into that arrangement,\nor that patent license was granted, prior to 28 March 2007.\n\n  Nothing in this License shall be construed as excluding or limiting\nany implied license or other defenses to infringement that may\notherwise be available to you under applicable patent law.\n\n  12. No Surrender of Others' Freedom.\n\n  If conditions are imposed on you (whether by court order, agreement or\notherwise) that contradict the conditions of this License, they do not\nexcuse you from the conditions of this License.  If you cannot convey a\ncovered work so as to satisfy simultaneously your obligations under this\nLicense and any other pertinent obligations, then as a consequence you may\nnot convey it at all.  For example, if you agree to terms that obligate you\nto collect a royalty for further conveying from those to whom you convey\nthe Program, the only way you could satisfy both those terms and this\nLicense would be to refrain entirely from conveying the Program.\n\n  13. Use with the GNU Affero General Public License.\n\n  Notwithstanding any other provision of this License, you have\npermission to link or combine any covered work with a work licensed\nunder version 3 of the GNU Affero General Public License into a single\ncombined work, and to convey the resulting work.  The terms of this\nLicense will continue to apply to the part which is the covered work,\nbut the special requirements of the GNU Affero General Public License,\nsection 13, concerning interaction through a network will apply to the\ncombination as such.\n\n  14. Revised Versions of this License.\n\n  The Free Software Foundation may publish revised and/or new versions of\nthe GNU General Public License from time to time.  Such new versions will\nbe similar in spirit to the present version, but may differ in detail to\naddress new problems or concerns.\n\n  Each version is given a distinguishing version number.  If the\nProgram specifies that a certain numbered version of the GNU General\nPublic License \"or any later version\" applies to it, you have the\noption of following the terms and conditions either of that numbered\nversion or of any later version published by the Free Software\nFoundation.  If the Program does not specify a version number of the\nGNU General Public License, you may choose any version ever published\nby the Free Software Foundation.\n\n  If the Program specifies that a proxy can decide which future\nversions of the GNU General Public License can be used, that proxy's\npublic statement of acceptance of a version permanently authorizes you\nto choose that version for the Program.\n\n  Later license versions may give you additional or different\npermissions.  However, no additional obligations are imposed on any\nauthor or copyright holder as a result of your choosing to follow a\nlater version.\n\n  15. Disclaimer of Warranty.\n\n  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY\nAPPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT\nHOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM \"AS IS\" WITHOUT WARRANTY\nOF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,\nTHE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR\nPURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM\nIS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF\nALL NECESSARY SERVICING, REPAIR OR CORRECTION.\n\n  16. Limitation of Liability.\n\n  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING\nWILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS\nTHE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY\nGENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE\nUSE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF\nDATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD\nPARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),\nEVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF\nSUCH DAMAGES.\n\n  17. Interpretation of Sections 15 and 16.\n\n  If the disclaimer of warranty and limitation of liability provided\nabove cannot be given local legal effect according to their terms,\nreviewing courts shall apply local law that most closely approximates\nan absolute waiver of all civil liability in connection with the\nProgram, unless a warranty or assumption of liability accompanies a\ncopy of the Program in return for a fee.\n\n                     END OF TERMS AND CONDITIONS\n\n            How to Apply These Terms to Your New Programs\n\n  If you develop a new program, and you want it to be of the greatest\npossible use to the public, the best way to achieve this is to make it\nfree software which everyone can redistribute and change under these terms.\n\n  To do so, attach the following notices to the program.  It is safest\nto attach them to the start of each source file to most effectively\nstate the exclusion of warranty; and each file should have at least\nthe \"copyright\" line and a pointer to where the full notice is found.\n\n    <one line to give the program's name and a brief idea of what it does.>\n    Copyright (C) <year>  <name of author>\n\n    This program is free software: you can redistribute it and/or modify\n    it under the terms of the GNU General Public License as published by\n    the Free Software Foundation, either version 3 of the License, or\n    (at your option) any later version.\n\n    This program is distributed in the hope that it will be useful,\n    but WITHOUT ANY WARRANTY; without even the implied warranty of\n    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n    GNU General Public License for more details.\n\n    You should have received a copy of the GNU General Public License\n    along with this program.  If not, see <https://www.gnu.org/licenses/>.\n\nAlso add information on how to contact you by electronic and paper mail.\n\n  If the program does terminal interaction, make it output a short\nnotice like this when it starts in an interactive mode:\n\n    <program>  Copyright (C) <year>  <name of author>\n    This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.\n    This is free software, and you are welcome to redistribute it\n    under certain conditions; type `show c' for details.\n\nThe hypothetical commands `show w' and `show c' should show the appropriate\nparts of the General Public License.  Of course, your program's commands\nmight be different; for a GUI interface, you would use an \"about box\".\n\n  You should also get your employer (if you work as a programmer) or school,\nif any, to sign a \"copyright disclaimer\" for the program, if necessary.\nFor more information on this, and how to apply and follow the GNU GPL, see\n<https://www.gnu.org/licenses/>.\n\n  The GNU General Public License does not permit incorporating your program\ninto proprietary programs.  If your program is a subroutine library, you\nmay consider it more useful to permit linking proprietary applications with\nthe library.  If this is what you want to do, use the GNU Lesser General\nPublic License instead of this License.  But first, please read\n<https://www.gnu.org/licenses/why-not-lgpl.html>.\n"
  },
  {
    "path": "README.md",
    "content": "<div align=\"center\">\r\n  <h1>\r\n    <img src=\"./docs/public/logo.png\" width=\"200\" alt=\"SpinningMomo Logo\">\r\n    <br/>\r\n    🎮 旋转吧大喵\r\n    <br/><br/>\r\n    <sup>《无限暖暖》游戏摄影与录像工具</sup>\r\n    <br/>\r\n\r\n  </h1>\r\n\r\n  <p>\r\n    <img alt=\"Platform\" src=\"https://img.shields.io/badge/platform-Windows-blue?style=flat-square\" />\r\n    <img alt=\"Release\" src=\"https://img.shields.io/github/v/release/ChanIok/SpinningMomo?style=flat-square&color=brightgreen\" />\r\n    <img alt=\"License\" src=\"https://img.shields.io/badge/license-GPLv3-blue?style=flat-square\" />\r\n  </p>\r\n\r\n  <p>\r\n    <b>\r\n      <a href=\"https://spin.infinitymomo.com\">📖 使用文档</a> •\r\n      <a href=\"https://spin.infinitymomo.com/dev/build-guide\">🛠️ 构建指南</a> •\r\n      <a href=\"https://spin.infinitymomo.com/en/\">🌐 English</a>\r\n    </b>\r\n  </p>\r\n\r\n  <img src=\"./docs/public/README.webp\" alt=\"Screenshot\" >\r\n</div>\r\n\r\n## 🎯 项目简介\r\n\r\n旋转吧大喵（SpinningMomo）\r\n\r\n▸ 一键切换游戏窗口比例/尺寸，完美适配竖构图拍摄、相册浏览等场景\r\n\r\n▸ 突破原生限制，支持生成 8K-12K 超高清游戏截图和录制\r\n\r\n▸ 专为《无限暖暖》优化，同时兼容多数窗口化运行的其他游戏\r\n\r\n> 🚧 v2.0 正在翻工中，部分功能尚未就绪。\r\n> 如需稳定版，请下载 [v0.7.7 旧版本](https://github.com/ChanIok/SpinningMomo/releases/tag/v0.7.7)，使用说明见 [v0.7.7 文档](https://chaniok.github.io/SpinningMomo/v0/)。\r\n\r\n### 📥 下载地址\r\n\r\n- **GitHub Release**：[点击下载最新版本](https://github.com/ChanIok/SpinningMomo/releases/latest)\r\n- **百度网盘**：[点击下载](https://pan.baidu.com/s/1UL9EJa2ogSZ4DcnGa2XcRQ?pwd=momo)（提取码：momo）\r\n\r\n### 📖 使用指南\r\n\r\n 查看 [使用文档](https://chaniok.github.io/SpinningMomo) 了解更多详细信息。\r\n\r\n### 🛠️ 构建指南\r\n\r\n查看 [构建指南](https://chaniok.github.io/SpinningMomo/dev/build-guide) 了解环境要求和构建步骤。\r\n\r\n## 🗺️ 开发状态\r\n\r\n✅ **已完成**：录制功能、图库功能（基础）\r\n\r\n🔨 **进行中**：地图功能、UI优化、HDR支持\r\n\r\n## 🙏 致谢\r\n\r\n照片数据解析服务由 [NUAN5.PRO](https://NUAN5.PRO) 强力驱动。\r\n\r\n## 📄 声明\r\n\r\n本项目采用 [GPL 3.0 协议](LICENSE) 开源。项目图标来自游戏《无限暖暖》，版权归属游戏开发商。使用前请阅读 [法律与隐私说明](https://spin.infinitymomo.com/zh/about/legal)。\r\n"
  },
  {
    "path": "cliff.toml",
    "content": "# git-cliff configuration\n# https://git-cliff.org/docs/configuration\n\n[changelog]\nheader = \"\"\nbody = \"\"\"\n{% for group, commits in commits | group_by(attribute=\"group\") %}\n### {{ group | upper_first }}\n{% for commit in commits %}\n- {% if commit.scope %}**{{ commit.scope }}**: {% endif %}{{ commit.message | upper_first }}\\\n{% endfor %}\n{% endfor %}\n\"\"\"\nfooter = \"\"\ntrim = true\n\n[git]\nconventional_commits = true\nfilter_unconventional = true\nsplit_commits = false\ncommit_parsers = [\n  { message = \"^feat\", group = \"新功能 | Features\" },\n  { message = \"^fix\", group = \"修复 | Fixes\" },\n  { message = \"^perf\", group = \"优化 | Performance\" },\n  { message = \"^refactor\", group = \"重构 | Refactor\" },\n  { message = \"^docs\", group = \"文档 | Documentation\" },\n  { message = \"^test\", group = \"测试 | Tests\" },\n  { message = \"^ci\", group = \"CI\" },\n  { message = \"^chore|^build|^style\", group = \"其他 | Chores\" },\n]\nfilter_commits = false\ntag_pattern = \"v[0-9].*\"\nsort_commits = \"oldest\"\n"
  },
  {
    "path": "docs/.vitepress/config.ts",
    "content": "import { defineConfig } from \"vitepress\";\nimport type { HeadConfig } from \"vitepress\";\nimport {\n  SITE_ORIGIN,\n  getBilingualPathnames,\n  isLegacyDocPath,\n  mdRelativeToPathname,\n  pageLocale,\n  toAbsoluteUrl,\n} from \"./seo\";\n\nconst baseEnv = process.env.VITE_BASE_PATH || \"/\";\nconst base = baseEnv.endsWith(\"/\") ? baseEnv : `${baseEnv}/`;\nconst is_canonical_build = base === \"/\";\nconst withBasePath = (p: string) => `${base}${p.replace(/^\\//, \"\")}`;\nconst SITE_NAME = \"旋转吧大喵\";\nconst SITE_NAME_EN = \"SpinningMomo\";\nconst SITE_DESCRIPTION_ZH = \"《无限暖暖》游戏摄影与录像工具\";\nconst SITE_DESCRIPTION_EN = \"Infinity Nikki photography and recording tool\";\n\nexport default defineConfig({\n  title: SITE_NAME,\n  description: SITE_DESCRIPTION_ZH,\n\n  // Cloudflare Pages 等托管支持无 .html 的干净 URL\n  cleanUrls: true,\n\n  // 允许通过环境变量自定义基础路径，默认为根路径\n  base,\n\n  head: [\n    [\"link\", { rel: \"icon\", href: withBasePath(\"/logo.png\") }],\n    [\"link\", { rel: \"apple-touch-icon\", href: withBasePath(\"/logo.png\") }],\n    [\"meta\", { property: \"og:site_name\", content: SITE_NAME }],\n    [\"meta\", { name: \"application-name\", content: SITE_NAME }],\n    [\"meta\", { name: \"apple-mobile-web-app-title\", content: SITE_NAME }],\n  ],\n\n  // 忽略死链接检查\n  ignoreDeadLinks: true,\n\n  sitemap: is_canonical_build\n    ? {\n        hostname: SITE_ORIGIN,\n        transformItems(items) {\n          // VitePress 在此使用相对路径（如 v0/zh/...），不含前导 /v0\n          return items.filter((item) => item.url !== \"v0\" && !item.url.startsWith(\"v0/\"));\n        },\n      }\n    : undefined,\n\n  async transformHead(ctx): Promise<HeadConfig[]> {\n    const { pageData, title, description } = ctx;\n    const relativePath = pageData.relativePath;\n    if (!relativePath || pageData.isNotFound) {\n      return [];\n    }\n\n    const pathname = mdRelativeToPathname(relativePath);\n    const canonical = toAbsoluteUrl(SITE_ORIGIN, \"/\", pathname);\n\n    const head: HeadConfig[] = [\n      [\"link\", { rel: \"canonical\", href: canonical }],\n    ];\n\n    if (!is_canonical_build) {\n      head.push([\"meta\", { name: \"robots\", content: \"noindex, nofollow\" }]);\n    } else if (isLegacyDocPath(relativePath)) {\n      head.push([\"meta\", { name: \"robots\", content: \"noindex, follow\" }]);\n    }\n\n    const bilingual = getBilingualPathnames(relativePath);\n    if (bilingual) {\n      const zhUrl = toAbsoluteUrl(SITE_ORIGIN, \"/\", bilingual.zhPathname);\n      const enUrl = toAbsoluteUrl(SITE_ORIGIN, \"/\", bilingual.enPathname);\n      head.push([\"link\", { rel: \"alternate\", hreflang: \"zh-CN\", href: zhUrl }]);\n      head.push([\"link\", { rel: \"alternate\", hreflang: \"en-US\", href: enUrl }]);\n      head.push([\"link\", { rel: \"alternate\", hreflang: \"x-default\", href: enUrl }]);\n    }\n\n    head.push([\"meta\", { property: \"og:title\", content: title }]);\n    head.push([\"meta\", { property: \"og:site_name\", content: SITE_NAME }]);\n    head.push([\"meta\", { property: \"og:description\", content: description }]);\n    head.push([\"meta\", { property: \"og:url\", content: canonical }]);\n    head.push([\"meta\", { property: \"og:type\", content: \"website\" }]);\n\n    const loc = pageLocale(relativePath);\n    if (loc) {\n      head.push([\n        \"meta\",\n        { property: \"og:locale\", content: loc.replace(\"-\", \"_\") },\n      ]);\n      head.push([\n        \"meta\",\n        {\n          property: \"og:locale:alternate\",\n          content: loc === \"zh-CN\" ? \"en_US\" : \"zh_CN\",\n        },\n      ]);\n    }\n\n    head.push([\"meta\", { name: \"twitter:card\", content: \"summary\" }]);\n    head.push([\"meta\", { name: \"twitter:title\", content: title }]);\n    head.push([\"meta\", { name: \"twitter:description\", content: description }]);\n\n    if (relativePath === \"index.md\" || relativePath === \"en/index.md\") {\n      const websiteJsonLd = {\n        \"@context\": \"https://schema.org\",\n        \"@type\": \"WebSite\",\n        name: SITE_NAME,\n        alternateName: SITE_NAME_EN,\n        url: SITE_ORIGIN,\n      };\n      head.push([\n        \"script\",\n        { type: \"application/ld+json\" },\n        JSON.stringify(websiteJsonLd),\n      ]);\n    }\n\n    return head;\n  },\n\n  locales: {\n    root: {\n      label: \"简体中文\",\n      lang: \"zh-CN\",\n      title: SITE_NAME,\n      description: SITE_DESCRIPTION_ZH,\n    },\n    en: {\n      label: \"English\",\n      lang: \"en-US\",\n      link: \"/en/\",\n      title: SITE_NAME_EN,\n      description: SITE_DESCRIPTION_EN,\n      themeConfig: {\n        siteTitle: SITE_NAME_EN,\n        nav: [\n          { text: \"Guide\", link: \"/en/guide/getting-started\" },\n          { text: \"Legal\", link: \"/en/about/legal\" },\n          {\n            text: \"Version\",\n            items: [\n              { text: \"v2.0 (Current)\", link: \"/en/\" },\n              { text: \"v0.7.7 (Legacy)\", link: \"/v0/en/\" },\n            ],\n          },\n        ],\n        sidebar: {\n          \"/en/\": [\n            {\n              text: \"Guide\",\n              items: [{ text: \"Getting Started\", link: \"/en/guide/getting-started\" }],\n            },\n            {\n              text: \"Features\",\n              items: [\n                { text: \"Window & Resolution\", link: \"/en/features/window\" },\n                { text: \"Screenshots\", link: \"/en/features/screenshot\" },\n                { text: \"Video Recording\", link: \"/en/features/recording\" },\n              ],\n            },\n            {\n              text: \"Developer\",\n              items: [{ text: \"Architecture\", link: \"/en/developer/architecture\" }],\n            },\n            {\n              text: \"About\",\n              items: [\n                { text: \"Legal & Privacy\", link: \"/en/about/legal\" },\n                { text: \"Open Source Credits\", link: \"/en/about/credits\" },\n              ],\n            },\n          ],\n        },\n      },\n    },\n  },\n\n  themeConfig: {\n    logo: withBasePath(\"/logo.png\"),\n    siteTitle: SITE_NAME,\n\n    // 社交链接\n    socialLinks: [\n      { icon: \"github\", link: \"https://github.com/ChanIok/SpinningMomo\" },\n    ],\n\n    // 导航栏\n    nav: [\n      { text: \"指南\", link: \"/zh/guide/getting-started\" },\n      { text: \"开发者\", link: \"/zh/developer/architecture\" },\n      {\n        text: \"版本\",\n        items: [\n          { text: \"v2.0 (当前)\", link: \"/\" },\n          { text: \"v0.7.7 (旧版)\", link: \"/v0/index.md\" }\n        ]\n      },\n      {\n        text: \"下载\",\n        link: \"https://github.com/ChanIok/SpinningMomo/releases\",\n      },\n    ],\n\n    sidebar: {\n      \"/zh/\": [\n        {\n          text: \"🚀 快速上手\",\n          items: [\n            { text: \"安装与运行\", link: \"/zh/guide/getting-started\" },\n          ],\n        },\n        {\n          text: \"⚡ 功能\",\n          items: [\n            { text: \"比例与分辨率调整\", link: \"/zh/features/window\" },\n            { text: \"超清截图\", link: \"/zh/features/screenshot\" },\n            { text: \"视频录制\", link: \"/zh/features/recording\" },\n          ],\n        },\n        {\n          text: \"🛠️ 开发者指南\",\n          items: [\n            { text: \"架构与构建\", link: \"/zh/developer/architecture\" },\n          ],\n        },\n        { \n          text: \"📄 关于\", \n          items: [\n            { text: \"法律与隐私\", link: \"/zh/about/legal\" },\n            { text: \"开源鸣谢\", link: \"/zh/about/credits\" },\n          ] \n        },\n      ],\n      // 保留旧版本的配置\n      \"/v0/zh/\": [\n        {\n          text: \"指南 (v0.7.7)\",\n          items: [\n            { text: \"项目介绍\", link: \"/v0/zh/guide/introduction\" },\n            { text: \"快速开始\", link: \"/v0/zh/guide/getting-started\" },\n            { text: \"基本功能\", link: \"/v0/zh/guide/features\" },\n          ],\n        },\n        {\n          text: \"进阶使用\",\n          items: [\n            { text: \"自定义设置\", link: \"/v0/zh/advanced/custom-settings\" },\n            { text: \"常见问题\", link: \"/v0/zh/advanced/troubleshooting\" },\n          ],\n        },\n      ],\n      \"/v0/en/\": [\n        {\n          text: \"Guide (v0.7.7)\",\n          items: [{ text: \"Overview\", link: \"/v0/en/\" }],\n        },\n        {\n          text: \"Legal\",\n          items: [\n            { text: \"Legal & Privacy Notice\", link: \"/v0/en/legal/notice\" },\n            { text: \"Third-Party Licenses\", link: \"/v0/en/credits\" }\n          ],\n        },\n      ]\n    },\n  },\n});\n"
  },
  {
    "path": "docs/.vitepress/seo.ts",
    "content": "/** 正式站点的绝对源（canonical / hreflang / sitemap），不含尾斜杠 */\nexport const SITE_ORIGIN = \"https://spin.infinitymomo.com\";\n\nexport function mdRelativeToPathname(relativePath: string): string {\n  const p = relativePath.replace(/\\\\/g, \"/\");\n  if (p === \"index.md\") return \"/\";\n  if (p.endsWith(\"/index.md\")) {\n    const dir = p.slice(0, -\"/index.md\".length);\n    return `/${dir}/`;\n  }\n  if (p.endsWith(\".md\")) {\n    return `/${p.slice(0, -3)}`;\n  }\n  return `/${p}`;\n}\n\nexport function toAbsoluteUrl(siteOrigin: string, base: string, pathname: string): string {\n  const origin = siteOrigin.replace(/\\/$/, \"\");\n  const normalizedBase =\n    base === \"/\" || base === \"\"\n      ? \"\"\n      : base.endsWith(\"/\")\n        ? base.slice(0, -1)\n        : base;\n  const path = pathname.startsWith(\"/\") ? pathname : `/${pathname}`;\n  return `${origin}${normalizedBase}${path}`;\n}\n\n/** v0 文档：不参与 hreflang，且应 noindex */\nexport function isLegacyDocPath(relativePath: string): boolean {\n  return relativePath.replace(/\\\\/g, \"/\").startsWith(\"v0/\");\n}\n\n/**\n * 返回当前 v2 页面对应的中英 canonical 路径（含前导 /，已考虑 index.md）。\n * 若无对页（非 v2 双语结构），返回 null。\n */\nexport function getBilingualPathnames(relativePath: string): {\n  zhPathname: string;\n  enPathname: string;\n} | null {\n  const p = relativePath.replace(/\\\\/g, \"/\");\n  if (isLegacyDocPath(p)) return null;\n\n  if (p === \"index.md\") {\n    return { zhPathname: \"/\", enPathname: \"/en/\" };\n  }\n  if (p === \"en/index.md\") {\n    return { zhPathname: \"/\", enPathname: \"/en/\" };\n  }\n  if (p.startsWith(\"zh/\")) {\n    const rest = p.slice(\"zh/\".length);\n    return {\n      zhPathname: mdRelativeToPathname(p),\n      enPathname: mdRelativeToPathname(`en/${rest}`),\n    };\n  }\n  if (p.startsWith(\"en/\")) {\n    const rest = p.slice(\"en/\".length);\n    return {\n      zhPathname: mdRelativeToPathname(`zh/${rest}`),\n      enPathname: mdRelativeToPathname(p),\n    };\n  }\n  return null;\n}\n\nexport function pageLocale(relativePath: string): \"zh-CN\" | \"en-US\" | null {\n  const p = relativePath.replace(/\\\\/g, \"/\");\n  if (isLegacyDocPath(p)) return null;\n  if (p === \"index.md\") return \"zh-CN\";\n  if (p.startsWith(\"en/\")) return \"en-US\";\n  if (p.startsWith(\"zh/\")) return \"zh-CN\";\n  return null;\n}\n"
  },
  {
    "path": "docs/.vitepress/theme/custom.css",
    "content": ":root {\n  --vp-c-brand: #ff9f4f;\n  --vp-c-brand-light: #ffb06a;\n  --vp-c-brand-lighter: #ffc088;\n  --vp-c-brand-dark: #f59440;\n  --vp-c-brand-darker: #e68a3c;\n\n  --vp-c-text-1: #333;\n  --vp-c-text-2: #444;\n  --vp-c-text-3: #666;\n\n  --vp-c-bg: #fff;\n  --vp-c-bg-soft: #f8f9fa;\n  --vp-c-bg-mute: #f1f1f1;\n\n  --vp-c-border: #eee;\n  --vp-c-divider: #eee;\n\n  --vp-button-brand-border: var(--vp-c-brand);\n  --vp-button-brand-text: #fff;\n  --vp-button-brand-bg: var(--vp-c-brand);\n  \n  --vp-button-brand-hover-border: var(--vp-c-brand-light);\n  --vp-button-brand-hover-text: #fff;\n  --vp-button-brand-hover-bg: var(--vp-c-brand-light);\n  \n  --vp-button-brand-active-border: var(--vp-c-brand-dark);\n  --vp-button-brand-active-text: #fff;\n  --vp-button-brand-active-bg: var(--vp-c-brand-dark);\n\n  --vp-custom-block-tip-bg: #fff3e6;\n  --vp-custom-block-tip-border: var(--vp-c-brand);\n\n  --vp-code-block-bg: #f8f9fa;\n\n  --vp-home-hero-name-color: var(--vp-c-brand);\n  \n  --vp-nav-bg-color: rgba(255, 255, 255, 0.95);\n  --vp-c-brand-active: var(--vp-c-brand);\n}\n\n:root.dark {\n  --vp-c-text-1: #f0f0f0;\n  --vp-c-text-2: #e0e0e0;\n  --vp-c-text-3: #aaaaaa;\n\n  --vp-c-bg: #1a1a1a;\n  --vp-c-bg-soft: #242424;\n  --vp-c-bg-mute: #2f2f2f;\n\n  --vp-c-border: #333333;\n  --vp-c-divider: #333333;\n\n  --vp-button-brand-border: var(--vp-c-brand);\n  --vp-button-brand-text: #ffffff;\n  --vp-button-brand-bg: var(--vp-c-brand);\n\n  --vp-custom-block-tip-bg: rgba(255, 159, 79, 0.1);\n  --vp-custom-block-tip-border: var(--vp-c-brand);\n\n  --vp-code-block-bg: #242424;\n\n  --vp-nav-bg-color: rgba(26, 26, 26, 0.95);\n} "
  },
  {
    "path": "docs/.vitepress/theme/index.ts",
    "content": "import { h } from 'vue'\nimport DefaultTheme from 'vitepress/theme'\nimport './custom.css'\n\nexport default {\n  extends: DefaultTheme,\n  Layout: () => {\n    return h(DefaultTheme.Layout, null, {\n      // 如果需要自定义布局，可以在这里添加\n    })\n  },\n  enhanceApp({ app }) {\n    // 注册组件等\n  }\n} "
  },
  {
    "path": "docs/en/about/credits.md",
    "content": "# Open Source Credits\n\nSpinningMomo is built upon the following excellent open-source projects. We extend our sincere gratitude to their authors and contributors.\n\n### C++ Backend\n\n| Project | License | Link |\n|---------|---------|------|\n| [xmake](https://github.com/xmake-io/xmake) | Apache-2.0 | https://github.com/xmake-io/xmake |\n| [uWebSockets](https://github.com/uNetworking/uWebSockets) | Apache-2.0 | https://github.com/uNetworking/uWebSockets |\n| [uSockets](https://github.com/uNetworking/uSockets) | Apache-2.0 | https://github.com/uNetworking/uSockets |\n| [reflect-cpp](https://github.com/getml/reflect-cpp) | MIT | https://github.com/getml/reflect-cpp |\n| [spdlog](https://github.com/gabime/spdlog) | MIT | https://github.com/gabime/spdlog |\n| [asio](https://github.com/chriskohlhoff/asio) | BSL-1.0 | https://github.com/chriskohlhoff/asio |\n| [yyjson](https://github.com/ibireme/yyjson) | MIT | https://github.com/ibireme/yyjson |\n| [fmt](https://github.com/fmtlib/fmt) | MIT | https://github.com/fmtlib/fmt |\n| [WebView2](https://developer.microsoft.com/en-us/microsoft-edge/webview2/) | Microsoft | https://developer.microsoft.com/en-us/microsoft-edge/webview2/ |\n| [wil (Windows Implementation Libraries)](https://github.com/Microsoft/wil) | MIT | https://github.com/Microsoft/wil |\n| [xxHash](https://github.com/Cyan4973/xxHash) | BSD-2-Clause | https://github.com/Cyan4973/xxHash |\n| [SQLiteCpp](https://github.com/SRombauts/SQLiteCpp) | MIT | https://github.com/SRombauts/SQLiteCpp |\n| [SQLite3](https://www.sqlite.org/index.html) | Public Domain | https://www.sqlite.org/index.html |\n| [libwebp](https://chromium.googlesource.com/webm/libwebp) | BSD-3-Clause | https://chromium.googlesource.com/webm/libwebp |\n| [zlib](https://zlib.net/) | zlib License | https://zlib.net/ |\n| [libuv](https://libuv.org/) | MIT | https://github.com/libuv/libuv |\n\n### Web Frontend\n\n| Project | License | Link |\n|---------|---------|------|\n| [Vue.js](https://vuejs.org/) | MIT | https://github.com/vuejs/core |\n| [Vite](https://vitejs.dev/) | MIT | https://github.com/vitejs/vite |\n| [Tailwind CSS](https://tailwindcss.com/) | MIT | https://github.com/tailwindlabs/tailwindcss |\n| [Pinia](https://pinia.vuejs.org/) | MIT | https://github.com/vuejs/pinia |\n| [Vue Router](https://router.vuejs.org/) | MIT | https://github.com/vuejs/router |\n| [VueUse](https://vueuse.org/) | MIT | https://github.com/vueuse/vueuse |\n| [TanStack Virtual](https://tanstack.com/virtual) | MIT | https://github.com/TanStack/virtual |\n| [Lucide](https://lucide.dev/) | ISC | https://github.com/lucide-icons/lucide |\n| [Inter Font](https://rsms.me/inter/) | OFL-1.1 | https://github.com/rsms/inter |\n| [Reka UI](https://reka-ui.com/) | MIT | https://github.com/unovue/reka-ui |\n| [shadcn-vue](https://www.shadcn-vue.com/) | MIT | https://github.com/radix-vue/shadcn-vue |\n| [vue-sonner](https://github.com/xiaoluoboding/vue-sonner) | MIT | https://github.com/xiaoluoboding/vue-sonner |\n\n*The full license texts for all projects listed above can be found in their respective repositories or distribution packages.*\n"
  },
  {
    "path": "docs/en/about/legal.md",
    "content": "# Legal & Privacy\n\nLast updated: 2026-03-23  \nEffective date: 2026-03-23\n\nBy downloading, installing, or using this software, you acknowledge that you have read and accepted this notice.\n\n### 1. Project Nature\n\n- This software is an open-source third-party desktop tool. The source code is made available under the GPL 3.0 license.\n- This software has no affiliation, agency, or endorsement relationship with *Infinity Nikki* or its developers and publishers.\n\n### 2. Data Handling (Primarily Local)\n\nThis software processes data primarily on your local device, which may include:\n- **Configuration data**: e.g., target window title, game directory, output directory, feature toggles, and UI preferences (such as `settings.json`).\n- **Runtime data**: Log and crash files (e.g., the `logs/` directory).\n- **Feature data**: Local indexes and metadata (e.g., `database.db`, used for gallery and similar features).\n\nBy default, this project does not include an account system, does not bundle advertising SDKs, and does not send feature-processing data to project-maintainer-provided network interfaces unless you enable a specific online feature.\n\n### 3. Network Activity\n\n- When you explicitly trigger \"Check for Updates / Download Update\", the software will access the update source.\n- If you enable \"Automatically check for updates\", the software will access the update source at startup.\n- \"Infinity Nikki photo metadata extraction\" is an optional online feature. You can skip it, and not enabling it does not affect other core features.\n- The software only contacts the related service when you enable this feature or manually start an extraction. Requests may include the UID, embedded photo parameters, and basic request information required for parsing; the full image file itself is not uploaded.\n- After you enable this feature, it may automatically run in the background when new related photos are detected.\n- When accessing the update source or the related service above, your request may be logged by the respective service provider (e.g., IP address, timestamp, User-Agent).\n\n### 4. Data Sharing & User Feedback\n\n- No local data is actively uploaded to developer servers by default, except for optional online features that you choose to enable.\n- If you voluntarily submit an Issue, log file, crash report, or screenshot on a public platform, you agree to make that content public on that platform.\n\n### 5. Risks & Disclaimer\n\n- This software is provided \"as is\" without warranty of any kind, and without guarantee of error-free, uninterrupted, or fully compatible operation in all environments.\n- You assume all risks associated with its use, including but not limited to performance degradation, compatibility issues, data loss, crashes, or other unexpected behavior.\n- To the extent permitted by applicable law, the project maintainers shall not be liable for any indirect, incidental, or consequential damages arising from the use of or inability to use this software.\n\n### 6. Permitted Use\n\nYou may only use this software for lawful and legitimate purposes. Use for illegal, harmful, or malicious activities is strictly prohibited.\n\n### 7. Changes & Support\n\n- This notice may be updated as the project evolves. Updated versions will be published in the repository or on the documentation site.\n- Continued use of the software constitutes your acceptance of the updated notice.\n- This project does not offer one-on-one customer support or guaranteed response times. If the repository has Issues enabled, you may submit feedback there.\n"
  },
  {
    "path": "docs/en/developer/architecture.md",
    "content": "# Architecture\n\n> 🚧 Work in Progress (WIP)\n"
  },
  {
    "path": "docs/en/features/recording.md",
    "content": "# Video Recording\n\n> 🚧 Work in Progress (WIP)\n"
  },
  {
    "path": "docs/en/features/screenshot.md",
    "content": "# High-Res Screenshots\n\n> 🚧 Work in Progress (WIP)\n"
  },
  {
    "path": "docs/en/features/window.md",
    "content": "# Window & Resolution\n\n> 🚧 Work in Progress (WIP)\n"
  },
  {
    "path": "docs/en/guide/getting-started.md",
    "content": "# Getting Started\n\n::: info Versions and documentation\nThe current release line is still moving quickly; some features may be unstable. If you prefer a **more predictable experience**, use [v0.7.7](https://github.com/ChanIok/SpinningMomo/releases/tag/v0.7.7) and its documentation: [v0.7.7 docs](/v0/en/) .\n:::\n\n> 🚧 Work in Progress (WIP)\n"
  },
  {
    "path": "docs/en/index.md",
    "content": "---\nlayout: home\ntitle: SpinningMomo\ndescription: Infinity Nikki photography and recording tool\nhero:\n  name: SpinningMomo\n  text: 旋转吧大喵\n  tagline: Infinity Nikki Game Photography and Recording Tool\n  image:\n    src: /logo.png\n    alt: SpinningMomo\n  actions:\n    - theme: brand\n      text: Getting Started\n      link: /en/guide/getting-started\n    - theme: alt\n      text: GitHub\n      link: https://github.com/ChanIok/SpinningMomo\nfeatures:\n  - icon: 📐\n    title: Custom Window Ratios\n    details: Supports adjusting window size to any ratio, with one-click vertical display.\n  - icon: 📸\n    title: Ultra High-Res Screenshots\n    details: Bypasses the game's native limitations, supporting 8K to 12K resolution screenshots.\n  - icon: 🎬\n    title: Adaptive Recording\n    details: Built-in recording seamlessly adapts to custom window ratios without tedious settings.\n---"
  },
  {
    "path": "docs/index.md",
    "content": "---\nlayout: home\ntitle: 旋转吧大喵\ndescription: 《无限暖暖》游戏摄影与录像工具\nhero:\n  name: 旋转吧大喵\n  text: SpinningMomo\n  tagline: 《无限暖暖》游戏摄影与录像工具\n  image:\n    src: /logo.png\n    alt: SpinningMomo\n  actions:\n    - theme: brand\n      text: 快速开始\n      link: /zh/guide/getting-started\n    - theme: alt\n      text: GitHub\n      link: https://github.com/ChanIok/SpinningMomo\nfeatures:\n  - icon: 📐\n    title: 自定义窗口比例\n    details: 支持任意比例的窗口尺寸调整，一键切换竖屏显示。\n  - icon: 📸\n    title: 超高清像素截图\n    details: 绕过游戏原生限制，支持生成 8K 至 12K 分辨率的游戏截图。\n  - icon: 🎬\n    title: 适配录像功能\n    details: 内置录制功能，无缝适配各种自定义窗口比例，无需繁琐设置。\n---\n"
  },
  {
    "path": "docs/package.json",
    "content": "{\n  \"name\": \"docs\",\n  \"version\": \"1.0.0\",\n  \"description\": \"SpinningMomo Docs\",\n  \"type\": \"module\",\n  \"scripts\": {\n    \"dev\": \"vitepress dev\",\n    \"build\": \"vitepress build\",\n    \"preview\": \"vitepress preview\"\n  },\n  \"keywords\": [],\n  \"author\": \"\",\n  \"license\": \"GPL-3.0\",\n  \"devDependencies\": {\n    \"@types/node\": \"^24.10.2\",\n    \"vitepress\": \"^1.5.0\",\n    \"vue\": \"^3.5.13\"\n  }\n}\n"
  },
  {
    "path": "docs/public/robots.txt",
    "content": "User-agent: *\nAllow: /\n\nSitemap: https://spin.infinitymomo.com/sitemap.xml\n"
  },
  {
    "path": "docs/public/version.txt",
    "content": "2.0.8\n"
  },
  {
    "path": "docs/v0/en/index.md",
    "content": "---\nlayout: doc\n---\n\n<div class=\"center\">\n  <div class=\"logo-container\">\n    <img src=\"/logo.png\" width=\"200\" alt=\"SpinningMomo Logo\" />\n  </div>\n\n  <h1 class=\"title\">🎮 SpinningMomo</h1>\n\n  A window adjustment tool to enhance photography experience in Infinity Nikki\n\n  <div class=\"badges\">\n    <img alt=\"Platform\" src=\"https://img.shields.io/badge/platform-Windows-blue?style=flat-square\" />\n    <img alt=\"Release\" src=\"https://img.shields.io/github/v/release/ChanIok/SpinningMomo?style=flat-square&color=brightgreen\" />\n    <img alt=\"License\" src=\"https://img.shields.io/badge/license-GPLv3-blue?style=flat-square\" />\n  </div>\n  \n  <div class=\"download-btn\">\n    <a href=\"https://github.com/ChanIok/SpinningMomo/releases/latest\" class=\"download-link\">⬇️ Download Latest Version</a>\n  </div>\n\n  <div class=\"nav-links\">\n    <a href=\"#features\">✨ Features</a> •\n    <a href=\"#user-guide\">🚀 User Guide</a>\n  </div>\n\n  <div class=\"screenshot-container\">\n    <img src=\"/README.webp\" alt=\"Screenshot\" />\n  </div>\n</div>\n\n## 🎯 Introduction\n\n▸ Easily switch game window aspect ratio and size, perfectly adapting to scenarios like vertical composition photography and album browsing.\n\n▸ Break through native limitations, supporting the generation of 8K-12K ultra-high-resolution game screenshots.\n\n▸ Optimized for Infinity Nikki, while also compatible with most other games running in windowed mode.\n\n## Features\n\n<div class=\"feature-grid\">\n  <div class=\"feature-item\">\n    <h3>🎮 Portrait Mode</h3>\n    <p>Perfect support for vertical UI, snapshot hourglass, and album</p>\n  </div>\n  <div class=\"feature-item\">\n    <h3>📸 Ultra-High Resolution</h3>\n    <p>Support photo output beyond game and device resolution limits</p>\n  </div>\n  <div class=\"feature-item\">\n    <h3>📐 Flexible Adjustment</h3>\n    <p>Multiple presets, custom ratios and resolutions</p>\n  </div>\n  <div class=\"feature-item\">\n    <h3>⌨️ Hotkey Support</h3>\n    <p>Customizable hotkey (default: Ctrl+Alt+R)</p>\n  </div>\n  <div class=\"feature-item\">\n    <h3>⚙️ Floating Window</h3>\n    <p>Optional floating menu for convenient window adjustment</p>\n  </div>\n  <div class=\"feature-item\">\n    <h3>🚀 Lightweight</h3>\n    <p>Minimal resource usage, performance priority</p>\n  </div>\n</div>\n\n## User Guide\n\n### 1️⃣ Getting Started\n\nWhen running for the first time, you may encounter these security prompts:\n- **SmartScreen Alert**: Click **More info** → **Run anyway** (open-source software without commercial code signing)\n- **UAC Prompt**: Click **Yes** to allow administrator privileges (required for window adjustments)\n\nAfter startup:\n- Program icon <img src=\"/logo.png\" style=\"display: inline; height: 1em; vertical-align: text-bottom;\" /> will appear in system tray\n- Floating window is shown by default for direct window adjustment\n\n### 2️⃣ Hotkeys\n\n| Function | Hotkey | Description |\n|:--|:--|:--|\n| Show/Hide Floating Window | `Ctrl + Alt + R` | Default hotkey, can be modified in tray menu |\n\n### 3️⃣ Photography Modes\n\n#### 🌟 Window Resolution Mode (Recommended)\n\nGame Settings:\n- Display Mode: **Fullscreen Window** (Recommended) or Window Mode\n- Photo Quality: **Window Resolution**\n\nSteps:\n1. Use ratio options to adjust composition\n2. Select desired resolution preset (4K~12K)\n3. Screen will exceed display bounds, press space to capture\n4. Click reset window after shooting\n\nAdvantages:\n- ✨ Support ultra-high resolution (up to 12K+)\n- ✨ Freely adjustable ratio and resolution\n\n#### 📷 Standard Mode\n\nGame Settings:\n- Display Mode: **Window Mode** or Fullscreen Window (ratio limited)\n- Photo Quality: **4K**\n\nNotes:\n- ✅ Convenient operation, suitable for daily shooting and preview\n- ✅ Always runs smoothly, no extra performance overhead\n- ❗ Can only adjust ratio, resolution based on game's 4K setting\n- ❗ In fullscreen window mode, output limited by monitor's native ratio\n\n### 4️⃣ Optional Features\n\n<div align=\"center\">\n  <table>\n    <tr>\n      <th align=\"center\">🔍 Preview Window</th>\n      <th align=\"center\">📺 Overlay Window</th>\n    </tr>\n    <tr>\n      <td>\n        <b>Function Description</b><br/>\n        ▫️ Similar to Photoshop's navigator feature<br/>\n        ▫️ Provides real-time preview when window exceeds screen\n      </td>\n      <td>\n        <b>Function Description</b><br/>\n        ▫️ Captures target window and renders it to a fullscreen overlay<br/>\n        ▫️ Consumes slightly more CPU resources than Preview Window\n      </td>\n    </tr>\n    <tr>\n      <td>\n        <b>Use Cases</b><br/>\n        ✨ Viewing details when shooting at high resolution<br/>\n        ✨ Helps positioning when window exceeds screen\n      </td>\n      <td>\n        <b>Use Cases</b><br/>\n        ✨ Provides seamless zooming experience<br/>\n        ✨ Maintains good interaction even at ultra-high resolutions\n      </td>\n    </tr>\n    <tr>\n      <td colspan=\"2\" align=\"center\">\n        <b>💡 Performance Note</b><br/>\n        Thanks to efficient capture methods, these features cause almost no noticeable performance drop.<br/>\n        However, if your high resolution setting is already causing significant slowdown, consider disabling these features.\n      </td>\n    </tr>\n  </table>\n</div>\n\n### Resolution Explanation\n- Resolution calculation process:\n  1. First determine total pixel count based on selected resolution preset (e.g., 4K, 8K)\n  2. Calculate final width and height based on selected ratio\n     - Example: When selecting 8K (about 33.2M pixels) and 9:16 ratio\n     - Results in 4320x7680 output resolution (4320x7680=33.2M pixels)\n     - Ensures total pixel count matches preset value\n\n### Tray Features\n\nRight-click or left-click the tray icon to:\n\n- 🎯 **Select Window**: Choose the target window from the submenu\n- 📐 **Window Ratio**: Select from preset ratios or custom ratios \n- 📏 **Resolution**: Select from preset resolutions or custom resolutions\n- 📍 **Capture**: Save lossless screenshots to the ScreenShot folder in program directory (mainly for debugging or games without screenshot support)\n- 📂 **Screenshots**: Open the game screenshot directory\n- 🔽 **Hide Taskbar**: Hide the taskbar to prevent overlap\n- ⬇️ **Lower Taskbar When Resizing**: Lower taskbar when resizing window\n- ⬛ **Black Border Mode**: Adds a full-screen black background to windows that do not match the screen ratio, enhancing immersion and resolving taskbar flickering issues under overlay layers.\n- ⌨️ **Modify Hotkey**: Set a new shortcut combination\n- 🔍 **Preview**: Similar to Photoshop's navigator for real-time preview when window exceeds screen\n  - Support dragging window top area to move position\n  - Mouse wheel to zoom window size\n- 🖼️ **Overlay**: Render the target window on a fullscreen overlay for seamless zooming experience\n- 📱 **Floating Window Mode**: Toggle floating menu visibility (enabled by default, use hotkey to open menu when disabled)\n- 🌐 **Language**: Switch language\n- ⚙️ **Open Config**: Customize ratios and resolutions\n- ❌ **Exit**: Close the program\n\n### Custom Settings\n\n1. Right-click tray icon, select \"Open Config File\"\n2. In the config file, you can customize the following:\n   - **Custom ratios:** Add or modify in the `AspectRatioItems` entry under the `[Menu]` section, using comma-separated format, for example: `32:9,21:9,16:9,3:2,1:1,2:3,9:16,16:10`\n   - **Custom resolutions:** Add or modify in the `ResolutionItems` entry under the `[Menu]` section, using comma-separated format, for example: `Default,4K,6K,8K,12K,5120x2880`\n3. Resolution format guide:\n   - Supports common identifiers: `480P`, `720P`, `1080P`, `2K`, `4K`, `6K`, `8K`, etc.\n   - Custom format: `width x height`, for example `5120x2880`\n4. Save and restart software to apply changes\n\n### Notes\n\n- System Requirements: Windows 10 or above\n- Higher resolutions may affect game performance, please adjust according to your device capabilities\n- It's recommended to test quality comparison before shooting to choose the most suitable settings\n\n### Security Statement\n\nThis program only sends requests through Windows standard window management APIs, with all adjustments executed by the Windows system itself, working similarly to:\n- Window auto-adjustment when changing system resolution\n- Window rearrangement when rotating screen\n- Window movement in multi-display setups\n\n## License\n\nThis project is open source under the [GPL 3.0 License](https://github.com/ChanIok/SpinningMomo/blob/main/LICENSE). The project icon is from the game \"Infinity Nikki\" and copyright belongs to the game developer. Please read the [Legal & Privacy Notice](https://spin.infinitymomo.com/en/about/legal) before use.\n\n<style>\n/* 添加命名空间，限制样式只在文档内容区域生效 */\n.vp-doc {\n  .center {\n    text-align: center;\n    max-width: 100%;\n    margin: 0 auto;\n  }\n  .logo-container {\n    display: flex;\n    justify-content: center;\n    margin: 2rem auto;\n  }\n  .logo-container img {\n    display: block;\n    margin: 0 auto;\n  }\n  h1.title {  /* 修改选择器，使其更具体 */\n    font-size: 2.5rem;\n    margin: 1rem 0;\n  }\n  .description {\n    font-size: 1.2rem;\n    color: var(--vp-c-text-2);\n    margin: 1rem 0;\n  }\n  .badges {\n    display: flex;\n    justify-content: center;\n    gap: 0.5rem;\n    margin: 1rem 0;\n  }\n  .badges img {\n    display: inline-block;\n  }\n  .nav-links {\n    margin: 1.5rem 0;\n  }\n  .nav-links a {\n    text-decoration: none;\n    font-weight: 500;\n  }\n  .screenshot-container {\n    max-width: 100%;\n    margin: 2rem auto;\n  }\n  .screenshot-container img {\n    max-width: 100%;\n    height: auto;\n    border-radius: 8px;\n    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);\n  }\n  .feature-grid {\n    display: grid;\n    grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));\n    gap: 1rem;\n    margin: 2rem 0;\n  }\n  .feature-item {\n    padding: 1rem;\n    border: 1px solid var(--vp-c-divider);\n    border-radius: 8px;\n  }\n  .feature-item h3 {\n    margin-top: 0;\n  }\n}\n</style>\n"
  },
  {
    "path": "docs/v0/index.md",
    "content": "---\nlayout: home\nhero:\n  name: SpinningMomo\n  text: 旋转吧大喵\n  tagline: 一个为《无限暖暖》提升摄影体验的窗口调整工具\n  image:\n    src: /logo.png\n    alt: SpinningMomo\n  actions:\n    - theme: brand\n      text: 快速开始\n      link: /v0/zh/guide/getting-started\n    - theme: alt\n      text: 在 GitHub 上查看\n      link: https://github.com/ChanIok/SpinningMomo\n\nfeatures:\n  - icon: 🎮\n    title: 竖拍支持\n    details: 完美支持游戏竖拍UI，留影沙漏和大喵相册\n  - icon: 📸\n    title: 超高分辨率\n    details: 支持突破游戏和设备分辨率的照片输出\n  - icon: 📐\n    title: 灵活调整\n    details: 多种预设，自定义比例和分辨率\n  - icon: ⌨️\n    title: 快捷键支持\n    details: 可自定义热键，便捷操作\n--- "
  },
  {
    "path": "docs/v0/zh/advanced/custom-settings.md",
    "content": "# 自定义设置\n\n## ⚙️ 配置文件说明\n\n### 📂 文件位置\n\n配置文件 `config.ini` 位于程序目录下，首次运行程序时会自动创建。\n\n::: tip 快速访问\n可以通过托盘菜单的\"打开配置文件\"快速打开。\n:::\n\n## 🔧 配置项说明\n\n### 🎯 窗口设置\n\n```ini\n[Window]\n# 目标窗口标题，程序会优先调整此标题的窗口\nTitle=\n```\n\n### ⌨️ 快捷键设置\n\n```ini\n[Hotkey]\n# 修饰键组合值：\n# 1 = Alt\n# 2 = Ctrl\n# 3 = Ctrl + Alt\n# 4 = Shift\n# 5 = Alt + Shift\n# 6 = Ctrl + Shift\n# 7 = Ctrl + Alt + Shift\nModifiers=3\n\n# 主键的虚拟键码\n# 82 = R键\n# 65-90 = A-Z\n# 48-57 = 0-9\n# 112-123 = F1-F12\nKey=82\n```\n\n::: tip 默认快捷键\n默认快捷键是 `Ctrl + Alt + R`，对应 `Modifiers=3` 和 `Key=82`。\n建议通过托盘菜单的子菜单选择快捷键，也可以自行查询 KeyCode 对照表进行设置。\n:::\n\n### 📐 自定义比例\n\n格式说明：\n- 用冒号(`:`)连接宽高比\n- 用逗号(`,`)分隔多个比例\n- 比例值可以是整数或小数\n- 支持默认预设或自定义比例\n\n示例：\n```ini\n# 使用默认预设并添加新的比例\nAspectRatioItems=32:9,21:9,16:9,3:2,1:1,2:3,9:16,16:10\n\n# 完全自定义比例列表\nAspectRatioItems=16:9,16:10,1.618:1,1:1\n```\n\n### 📏 自定义分辨率\n\n格式说明：\n- 支持常见标识符：`Default`, `4K`, `6K`, `8K`, `12K`\n- 自定义格式：用字母`x`连接宽高，例如`5120x2880`\n- 用逗号(`,`)分隔多个分辨率\n- 分辨率必须是整数\n\n示例：\n```ini\n# 使用默认预设并添加自定义分辨率\nResolutionItems=Default,4K,6K,8K,12K,5120x2880\n\n# 添加常见分辨率标识符\nResolutionItems=Default,480P,720P,1080P,2K,4K,8K\n```\n\n### 📋 自定义浮窗菜单项\n\n格式说明：\n- 用逗号(`,`)分隔多个菜单项\n- 可用项包括：\n  - `CaptureWindow`: 截图\n  - `OpenScreenshot`: 打开相册\n  - `PreviewWindow`: 预览窗\n  - `OverlayWindow`: 叠加层\n  - `LetterboxWindow`: 黑边模式\n  - `Reset`: 重置窗口\n  - `Close`: 关闭菜单\n  - `Exit`: 退出程序\n\n示例：\n```ini\n# 简化菜单（只保留常用选项）\nMenuItems=PreviewWindow,OverlayWindow,Reset,Close\n\n# 完整菜单\nMenuItems=CaptureWindow,OpenScreenshot,PreviewWindow,OverlayWindow,LetterboxWindow,Reset,Close,Exit\n```\n\n### 📸 相册目录设置\n\n```ini\n[Screenshot]\n# 游戏相册目录路径，用于快速打开游戏截图文件夹\n# 可以修改为其他游戏的相册目录或程序的截图目录\nGameAlbumPath=\n```\n\n示例：\n```ini\n# 自定义目录\nGameAlbumPath=D:\\Games\\Screenshots\n```\n\n### 🌐 语言设置\n\n```ini\n[Language]\n# 支持的语言：\n# zh-CN = 简体中文\n# en-US = English\nCurrent=zh-CN\n```\n\n### 🎯 浮窗设置\n\n```ini\n[Menu]\n# 是否使用浮窗模式\n# 0 = 使用快捷菜单\n# 1 = 使用浮窗\nFloating=1\n```\n\n### 🔽 任务栏设置\n\n```ini\n[Taskbar]\n# 是否自动隐藏任务栏\n# 0 = 不隐藏\n# 1 = 自动隐藏\nAutoHide=0\n\n# 调整窗口时是否将任务栏置底\n# 0 = 不置底\n# 1 = 自动置底\nLowerOnResize=1\n```\n\n### ⬛ 黑边模式设置\n\n```ini\n[Letterbox]\n# 是否启用黑边模式\n# 0 = 禁用\n# 1 = 启用\nEnabled=0\n```\n\n### 🔊 日志级别设置\n\n```ini\n[Logger]\n# 日志记录级别\n# DEBUG = 详细调试信息，用于开发者调试\n# INFO = 一般信息（默认）\n# ERROR = 仅记录错误信息\nLevel=INFO\n```\n\n::: warning 注意事项\n- 修改配置文件后需要重启程序才能生效\n- 可手动将旧的配置文件复制到新版本中，以保留自定义设置\n:::"
  },
  {
    "path": "docs/v0/zh/advanced/troubleshooting.md",
    "content": "# 常见问题\n\n::: tip 提示\n在阅读本页面之前，请先阅读[功能说明](/v0/zh/guide/features)了解各功能的注意事项。\n:::\n\n## ❓ 工作原理\n\n### 程序的工作原理是什么？\n\n::: info 原理解析\n本程序利用了游戏引擎的渲染机制和 Windows 系统的窗口管理特性：\n\n#### 1️⃣ 渲染机制\n- 在大多数使用现代游戏引擎（如UE4/5、Unity等）开发的游戏中，窗口模式或无边框窗口模式下的渲染分辨率通常由窗口尺寸决定\n  ```ts\n  // UE4/5引擎示例\n  r.ScreenPercentage // 控制实际渲染分辨率与窗口分辨率的比例\n  ```\n- 当进行窗口尺寸调整时，游戏引擎会根据新的窗口尺寸重新计算渲染分辨率（这是很多游戏引擎的默认行为）\n- 即使窗口尺寸超出了显示器的物理大小，游戏引擎仍然会按照实际的窗口大小进行完整的渲染过程\n- 这使得在拍照画质设置为\"窗口分辨率\"时，可以输出超高分辨率的截图\n\n#### 2️⃣ 窗口管理\n- 程序通过 Windows 系统标准的窗口管理API调整窗口尺寸和样式\n  ```cpp\n  // Windows API 核心调用\n  SetWindowPos()    // 调整窗口大小和位置\n  WS_POPUP         // 需要时切换为无边框样式\n  ```\n- 当窗口需要超出屏幕范围时，会自动切换为无边框样式(Alt+Enter可恢复)\n- 这些操作等同于系统标准行为：\n  - 📱 系统分辨率变更时的窗口自适应\n  - 🔄 屏幕旋转时的窗口重排\n  - 🖥️ 多显示器下的窗口移动\n\n#### 3️⃣ 安全性\n程序不会：\n::: danger 禁止行为\n- 修改游戏内存\n- 注入游戏进程\n- 修改游戏文件\n:::\n\n## ❌ 常见错误与解决方案\n\n### 管理员运行后无反应\n\n::: warning 问题描述\n运行程序后没有任何反应，也没有浮窗弹出。如：[Issue](https://github.com/ChanIok/SpinningMomo/issues/5)\n:::\n\n**解决方案**：\n1. 查看 Windows 安全中心的保护历史记录，检查是否有相关拦截信息，有则尝试关闭保护\n2. 换个渠道重新下载程序并运行\n\n### 热键注册失败\n\n::: warning 错误提示\n热键注册失败。程序仍可使用，但快捷键将不可用。\n:::\n\n**解决方案**：右键系统托盘的程序图标，打开菜单，点击\"修改热键\"，键盘输入其他未被占用的热键组合\n\n### 高分辨率拍照后画质未提升\n\n::: warning 问题描述\n在无限暖暖中选择高分辨率预设后拍照，但最终截图画质没有明显提升。\n:::\n\n**解决方案**：进入游戏设置，确认\"拍照-照片画质\"选项已设置为\"窗口分辨率\"而非\"4K\"或其他。\n\n### 自定义比例拍照后比例未改变\n\n::: warning 问题描述\n在无限暖暖中选择自定义比例后拍照，但最终拍照的比例并不正确\n:::\n\n**解决方案**：\n1. 请参考 [快速开始](https://chaniok.github.io/SpinningMomo/zh/guide/getting-started.html#_3%EF%B8%8F%E2%83%A3-%E6%8B%8D%E7%85%A7%E6%A8%A1%E5%BC%8F) 的 拍照模式 进行正确的设置\n2. 如果\"拍照-照片画质\"选项设置为\"4K\"，务必确保\"显示模式\"选项设置为\"窗口模式\"。（照片画质为窗口分辨率时不会出现该情况）\n\n### 预览窗或叠加层功能引发崩溃\n\n::: info 注意\n尽管作者在多种环境下进行了测试，但由于硬件和系统配置差异，个别情况下仍可能出现问题。\n:::\n\n**解决方案**：如果遇到无法解决的崩溃，请通过 [GitHub Issues](https://github.com/ChanIok/SpinningMomo/issues) 提供详细的系统信息和崩溃前操作步骤，以便开发者定位和解决问题\n\n## 🎮 无限暖暖使用建议\n\n### 动态场景拍照问题\n\n::: warning 问题描述\n- 在无限暖暖中，使用键盘空格键拍照可能导致截图细节模糊、锯齿或纹理不清晰\n- 某些动态场景（如花焰群岛的旋转木马）下拍照尤其容易出现细节模糊、锯齿或纹理不清晰的情况\n:::\n\n**解决方案**：\n- 为获得高质量截图，建议使用预览窗或叠加层功能，**通过鼠标点击游戏内拍照按钮进行拍摄**\n  - 是的，叠纸的技术力就是这么烂，空格键拍照有 BUG\n\n### 画面错位问题\n\n::: warning 问题描述\n在全屏窗口模式下，当从小于屏幕的尺寸（如屏幕1080P，窗口720P）切换到超出屏幕尺寸时，可能导致游戏画面错位。\n:::\n\n**解决方案**：在游戏设置中切换到 **窗口模式**，或直接按 Alt+Enter 切换到窗口模式后再调整尺寸\n\n### 录制超大窗口\n\n::: tip 建议\n- 使用 [OBS](https://obsproject.com/) 可以完整捕获超出屏幕范围的游戏窗口\n- 在\"来源\"中添加\"游戏捕获\"或\"窗口捕获\"，选择无限暖暖窗口\n- 每次使用 SpinningMomo 调整游戏窗口分辨率或比例后，需要在 OBS 中右键点击\"调整输出大小（源大小）\"以匹配新的窗口尺寸\n:::\n\n## 💬 获取帮助\n\n如果您遇到的问题在此页面没有找到解决方案，您可以：\n\n::: tip 寻求帮助的方式\n- 在 [GitHub Issues](https://github.com/ChanIok/SpinningMomo/issues) 提交问题\n:::\n\n::: warning 提交问题时请注意\n提供以下信息有助于我们更好地帮助您：\n- 系统版本\n- 问题的详细描述\n- 复现步骤\n- 相关的错误信息\n:::\n"
  },
  {
    "path": "docs/v0/zh/guide/features.md",
    "content": "# 功能说明\n\n## 🎯 窗口调整\n\n### 选择目标窗口\n\n::: info 支持的游戏\n程序默认选择《无限暖暖》作为目标窗口。同时兼容多数窗口化运行的其他游戏，如：\n- 《最终幻想14》\n- 《鸣潮》\n- 《崩坏：星穹铁道》（不完全适配自定义比例）\n- 《燕云十六声》（建议使用程序的截图功能）\n\n如需调整其他窗口，可通过托盘菜单选择，**务必将游戏设置为窗口化运行**。\n:::\n\n### 📐 窗口比例\n\n提供多种预设比例，满足不同构图需求：\n\n| 比例 | 适用场景 |\n|:--|:--|\n| 32:9 | 超宽屏拍摄，全景构图 |\n| 21:9 | 带鱼屏拍摄，电影构图 |\n| 16:9 | 标准宽屏，横向构图 |\n| 3:2 | 经典相机比例，专业摄影 |\n| 1:1 | 方形构图，社交平台 |\n| 3:4 | 小红书推荐比例，竖屏内容 |\n| 2:3 | 经典相机比例，人像拍摄 |\n| 9:16 | 手机端竖屏，短视频格式 |\n\n::: tip 自定义比例\n想要更多比例选择？可以在配置文件中添加自定义比例，详见[自定义设置](/zh/advanced/custom-settings)。\n:::\n\n### 📏 分辨率预设\n\n支持多种超高清分辨率输出：\n\n| 预设 | 分辨率 | 像素数 |\n|:--|:--|:--|\n| 1080P | 1920×1080 | 约 207 万像素 |\n| 2K | 2560×1440 | 约 369 万像素 |\n| 4K | 3840×2160 | 约 830 万像素 |\n| 6K | 5760×3240 | 约 1870 万像素 |\n| 8K | 7680×4320 | 约 3320 万像素 |\n| 12K | 11520×6480 | 约 7460 万像素 |\n\n::: tip 分辨率计算过程\n1. 首先根据选择的分辨率预设（如 4K、8K）确定总像素数\n2. 然后根据选择的比例计算最终的宽高\n\n例如：选择 8K（约 3320 万像素）和 9:16 比例时\n- 计算得到 4320×7680 的输出分辨率\n- 4320×7680 ≈ 3320 万像素\n- 保证总像素数与预设值相近\n:::\n\n::: warning 性能注意事项\n- 分辨率越高，系统资源占用越大，主要消耗显存、内存和虚拟内存\n- 参考数据：RTX 3060 12G + 32G内存的设备，使用12K分辨率时会出现极其严重卡顿\n- 如遇游戏崩溃，可以：\n  1. 通过任务管理器观察资源使用情况\n  2. 尝试增加虚拟内存大小\n  3. 关闭后台占用内存的程序\n:::\n\n## 💻 界面控制\n\n### 📱 浮动窗口\n\n便捷的窗口调整工具：\n- ✨ 默认开启，随时待命\n- 🎯 快速调整窗口比例和分辨率\n- ⌨️ 支持快捷键切换（默认 `Ctrl + Alt + R`）\n- 🎨 简洁美观的界面设计\n\n::: tip 快捷菜单模式\n如果你更喜欢简洁的界面：\n- 🚀 可以关闭浮窗模式，改用热键呼出快捷菜单\n- 🔍 预览窗口可以独立使用，右键点击即可呼出快捷菜单\n- ⚡ 建议设置单键热键（如 ``` ` ```键），使用更加顺手\n:::\n\n### 🔍 预览窗\n\n类似 Photoshop 的导航器，帮助你在超大分辨率下精确定位：\n- 🖼️ 实时预览溢出屏幕的画面\n- 🖱️ 支持拖拽窗口顶部区域移动位置\n- 🖲️ 滚轮缩放窗口大小，方便调整预览范围\n- 📋 右键点击呼出快捷菜单，支持快速调整窗口比例和分辨率\n\n### 🖼️ 叠加层\n\n类似 Magpie 的反向缩小版，Magpie 是将小窗口放大到全屏，而本功能则是将大窗口缩小显示：\n- 📺 将目标窗口捕获并渲染到全屏叠加层上\n- 🎯 提供无感知放大的操作体验\n- ⚡ 在超大分辨率下依然保持良好交互\n\n::: warning 使用建议\n由于需要不断设置窗口位置以实现鼠标的点击定位，会额外消耗一定的CPU资源。\n如果你选择的高分辨率已经让电脑卡顿严重，建议暂时不要开启此功能。\n:::\n\n### 🔽 任务栏控制\n\n| 模式 | 功能 | 适用场景 |\n|:--|:--|:--|\n| 隐藏任务栏 | 完全隐藏任务栏 | 需要完整画面时 |\n| 调整时置底 | 仅在调整窗口时置底 | 默认开启，使用推荐 |\n\n### ⬛ 黑边模式\n\n为非屏幕原生比例的窗口提供沉浸式体验：\n- 🌃 创建全屏黑色背景，自动置于游戏窗口底部\n- 🔄 解决使用叠加层时任务栏闪烁问题\n\n### 🔲 切换窗口边框\n\n切换游戏窗口边框显示，可以移除窗口标题栏和边框\n\n## 📸 截图功能\n\n### 保存截图\n\n::: info 截图说明\n- 📁 自动保存至程序目录下的 ScreenShot 文件夹中\n- 🎨 主要用于调试目的，无损保存，保持原始画质\n- ⚡ 适用于游戏内置截图功能无法保存高分辨率图片的情况\n- ℹ️ 正常游戏中不需要使用此功能\n:::\n\n::: tip 《无限暖暖》玩家注意\n推荐使用游戏内置的**大喵相机**，在动态场景中拍照时游戏会暂停渲染，可以避免拍照时的锯齿和模糊。\n:::\n\n### 📂 相册管理\n\n便捷的相册访问功能：\n- 🚀 一键打开游戏相册目录，无需手动查找路径\n- 📱 快速查看和整理截图文件\n- 📁 支持[自定义相册路径](/zh/advanced/custom-settings#相册目录设置)\n\n::: warning 使用条件\n首次使用《无限暖暖》相册功能时，需要保持游戏处于运行状态\n:::\n\n## ⚙️ 其他设置\n\n### ⌨️ 快捷键设置\n\n灵活的快捷键配置：\n- 🎯 支持自定义组合键\n- 🔢 支持单个按键（如 `Home`、`End`、`小键盘0-9`）\n- ⚠️ 默认为 `Ctrl + Alt + R`（可能与 NVIDIA 浮窗冲突）\n\n### 🌐 语言切换\n\n支持多语言界面：\n- 🇨🇳 简体中文\n- 🇺🇸 English\n\n### 🛠️ 配置文件\n\n通过编辑配置文件可以实现高级自定义：\n- 📐 添加自定义比例\n- 📏 设置自定义分辨率\n- ⚙️ 调整其他高级选项\n\n::: tip 想了解更多？\n查看[自定义设置](/v0/zh/advanced/custom-settings)了解详细配置方法。\n::: "
  },
  {
    "path": "docs/v0/zh/guide/getting-started.md",
    "content": "# 快速开始\n\n## 📥 下载安装\n\n### 获取程序\n\n::: tip 下载地址\n| 下载源 | 链接 | 说明 |\n|:--|:--|:--|\n| **GitHub** | [点击下载](https://github.com/ChanIok/SpinningMomo/releases/latest) | 推荐，国内访问可能受限 |\n| **蓝奏云** | [点击下载](https://wwf.lanzoul.com/b0sxagp0d) | 密码：`momo` |\n| **百度网盘** | [点击下载](https://pan.baidu.com/s/1UL9EJa2ogSZ4DcnGa2XcRQ?pwd=momo) | 提取码：`momo` |\n:::\n\n### 系统要求\n\n::: warning 运行环境\n- **操作系统**：Windows 10 1803 (Build 17134) 或更高版本\n- **显卡/驱动**：支持 DirectX 11 的显卡和最新驱动\n- **Windows 功能**：\n  - 图形捕获功能 Windows 10 1803+\n  - 高级功能需要 Windows 10 2104+（Build 20348 或更高）\n\n部分高级功能（如无边框捕获、隐藏鼠标捕获）在较新版本的 Windows 10/11 上提供更好体验。  \n:::\n\n## 🚀 使用说明\n\n### 1️⃣ 启动程序\n\n#### 首次运行（系统安全提示）\n\n::: warning Windows安全提示\n首次运行时，可能会遇到以下安全提示：\n\n- **SmartScreen 提示**：点击**更多信息**→**仍要运行**（开源软件无商业代码签名）\n- **UAC 提示**：点击**是**允许管理员权限（程序需要此权限调整窗口）\n:::\n\n启动后：\n- 系统托盘会显示程序图标 <img src=\"/logo.png\" style=\"display: inline; height: 1em; vertical-align: text-bottom;\" />\n- 默认显示浮动窗口，可直接调整窗口\n\n::: warning 无浮窗弹出？\n如果运行程序后没有浮窗弹出，请参考 [故障排除指南](https://chaniok.github.io/SpinningMomo/zh/advanced/troubleshooting.html#%E7%AE%A1%E7%90%86%E5%91%98%E8%BF%90%E8%A1%8C%E5%90%8E%E6%97%A0%E5%8F%8D%E5%BA%94)\n:::\n\n### 2️⃣ 快捷键\n\n| 功能 | 快捷键 | 说明 |\n|:--|:--|:--|\n| 显示/隐藏浮窗 | `Ctrl + Alt + R` | 默认快捷键，可在托盘菜单中修改 |\n\n### 3️⃣ 拍照模式\n\n#### 🌟 窗口分辨率模式（推荐）\n\n游戏设置：\n- 显示模式：**全屏窗口模式** 或 窗口模式\n- 拍照-照片画质：**窗口分辨率**\n\n使用步骤：\n1. 使用程序的比例选项调整构图\n2. 选择需要的分辨率预设（4K~12K）\n3. 画面会溢出屏幕，此时按空格拍照\n4. 拍摄完成后点击重置窗口\n\n优势特点：\n- ✨ 支持超高分辨率（最高12K+）\n- ✨ 可自由调整比例和分辨率\n\n#### 📷 标准模式\n\n游戏设置：\n- 显示模式：**窗口模式** 或 全屏窗口模式（比例受限）\n- 拍照-照片画质：**4K**\n\n特点说明：\n- ✅ 操作便捷，适合日常拍摄和预览\n- ✅ 始终保持流畅运行，无需额外性能开销\n- ❗ 只能调整比例，分辨率基于游戏设置的4k\n- ❗ 全屏窗口模式下输出受限于显示器原始比例\n\n### 4️⃣ 可选功能\n\n#### 🔍 预览窗\n\n功能说明：\n- 类似 Photoshop 的导航器功能\n- 在窗口溢出屏幕时提供实时预览\n\n使用场景：\n- ✨ 高分辨率拍摄时查看放大后的细节\n- ✨ 窗口溢出屏幕时辅助定位\n\n#### 🖼️ 叠加层\n\n功能说明：\n- 类似 Magpie 的反向缩小版\n- 将目标窗口捕获并渲染到全屏叠加层上\n- ⚠️ 比预览窗额外消耗一些CPU资源\n\n使用场景：\n- ✨ 提供无感知放大的操作体验\n- ✨ 在超大分辨率下依然保持良好交互\n\n::: warning 💡 性能说明\n得益于高效的捕获方式，这两种功能几乎不会造成明显的性能下降。\n但如果你选择的高分辨率已经让电脑卡成PPT了，建议暂时不要开启这些功能。\n:::\n\n## ⏩ 下一步\n\n👉 查看[功能说明](/v0/zh/guide/features)了解更多基础功能的详细说明。\n\n::: tip 喜欢这个工具？\n欢迎到 [GitHub](https://github.com/ChanIok/SpinningMomo) 点个 Star ⭐ 支持一下~\n:::\n"
  },
  {
    "path": "docs/v0/zh/guide/introduction.md",
    "content": "# 项目介绍\n\n::: tip 简介\n旋转吧大喵（SpinningMomo）是一个专为《无限暖暖》游戏开发的窗口调整工具，旨在提升游戏的摄影体验。\n:::\n\n## ✨ 主要特性\n\n### 🎮 竖拍支持\n- 完美支持游戏竖拍UI\n- 适配留影沙漏和大喵相册\n- 无缝切换横竖构图\n\n### 📸 超高分辨率\n- 支持突破游戏原有分辨率限制\n- 可输出高达12K+的超清照片\n- 完美保持画质不失真\n\n### 📐 灵活调整\n- 提供多种预设比例和分辨率\n- 支持自定义窗口设置\n- 快速切换不同拍摄模式\n\n### ⌨️ 便捷操作\n- 可自定义快捷键组合\n- 提供浮动菜单快速调整\n- 支持预览窗实时导航\n- 支持叠加层无感知放大\n\n## 💻 技术特点\n\n::: info 技术说明\n- 使用原生 Win32 API 开发，性能优先\n- 极低的系统资源占用\n- 纯手工打造的浮窗界面\n- DirectX 11 实现的预览窗和叠加层\n:::\n\n## 📝 开源协议\n\n::: tip 版权说明\n本项目采用 GPL 3.0 协议开源。项目图标来自游戏《无限暖暖》，版权归游戏开发商所有。\n::: \n\n## 🔐 法律与隐私\n\n- [法律与隐私说明](/zh/legal/notice)\n"
  },
  {
    "path": "docs/zh/about/credits.md",
    "content": "# 开源鸣谢\n\nSpinningMomo（旋转吧大喵）的开发离不开以下优秀的开源项目，向它们的作者表示由衷的感谢。\n\n### C++ Backend（原生后端）\n\n| 项目 | 协议 | 链接 |\n|------|------|------|\n| [xmake](https://github.com/xmake-io/xmake) | Apache-2.0 | https://github.com/xmake-io/xmake |\n| [uWebSockets](https://github.com/uNetworking/uWebSockets) | Apache-2.0 | https://github.com/uNetworking/uWebSockets |\n| [uSockets](https://github.com/uNetworking/uSockets) | Apache-2.0 | https://github.com/uNetworking/uSockets |\n| [reflect-cpp](https://github.com/getml/reflect-cpp) | MIT | https://github.com/getml/reflect-cpp |\n| [spdlog](https://github.com/gabime/spdlog) | MIT | https://github.com/gabime/spdlog |\n| [asio](https://github.com/chriskohlhoff/asio) | BSL-1.0 | https://github.com/chriskohlhoff/asio |\n| [yyjson](https://github.com/ibireme/yyjson) | MIT | https://github.com/ibireme/yyjson |\n| [fmt](https://github.com/fmtlib/fmt) | MIT | https://github.com/fmtlib/fmt |\n| [WebView2](https://developer.microsoft.com/en-us/microsoft-edge/webview2/) | Microsoft | https://developer.microsoft.com/en-us/microsoft-edge/webview2/ |\n| [wil (Windows Implementation Libraries)](https://github.com/Microsoft/wil) | MIT | https://github.com/Microsoft/wil |\n| [xxHash](https://github.com/Cyan4973/xxHash) | BSD-2-Clause | https://github.com/Cyan4973/xxHash |\n| [SQLiteCpp](https://github.com/SRombauts/SQLiteCpp) | MIT | https://github.com/SRombauts/SQLiteCpp |\n| [SQLite3](https://www.sqlite.org/index.html) | Public Domain | https://www.sqlite.org/index.html |\n| [libwebp](https://chromium.googlesource.com/webm/libwebp) | BSD-3-Clause | https://chromium.googlesource.com/webm/libwebp |\n| [zlib](https://zlib.net/) | zlib License | https://zlib.net/ |\n| [libuv](https://libuv.org/) | MIT | https://github.com/libuv/libuv |\n\n### Web Frontend（Web 前端）\n\n| 项目 | 协议 | 链接 |\n|------|------|------|\n| [Vue.js](https://vuejs.org/) | MIT | https://github.com/vuejs/core |\n| [Vite](https://vitejs.dev/) | MIT | https://github.com/vitejs/vite |\n| [Tailwind CSS](https://tailwindcss.com/) | MIT | https://github.com/tailwindlabs/tailwindcss |\n| [Pinia](https://pinia.vuejs.org/) | MIT | https://github.com/vuejs/pinia |\n| [Vue Router](https://router.vuejs.org/) | MIT | https://github.com/vuejs/router |\n| [VueUse](https://vueuse.org/) | MIT | https://github.com/vueuse/vueuse |\n| [TanStack Virtual](https://tanstack.com/virtual) | MIT | https://github.com/TanStack/virtual |\n| [Lucide](https://lucide.dev/) | ISC | https://github.com/lucide-icons/lucide |\n| [Inter Font](https://rsms.me/inter/) | OFL-1.1 | https://github.com/rsms/inter |\n| [Reka UI](https://reka-ui.com/) | MIT | https://github.com/unovue/reka-ui |\n| [shadcn-vue](https://www.shadcn-vue.com/) | MIT | https://github.com/radix-vue/shadcn-vue |\n| [vue-sonner](https://github.com/xiaoluoboding/vue-sonner) | MIT | https://github.com/xiaoluoboding/vue-sonner |\n\n*以上项目的完整许可证文本可以在其各自的存储库或分发包中找到。*\n"
  },
  {
    "path": "docs/zh/about/legal.md",
    "content": "# 法律与隐私\n\n更新日期：2026-03-23  \n生效日期：2026-03-23\n\n你下载、安装或使用本软件，即表示你已阅读并接受本说明。\n\n### 1. 项目性质\n\n- 本软件是开源第三方桌面工具，代码部分按 GPL 3.0 协议提供。\n- 本软件与《无限暖暖》及其开发/发行方无隶属、代理或担保关系。\n\n### 2. 数据处理范围（本地为主）\n\n本软件主要在本地设备处理数据，可能包括：\n- 配置数据：例如目标窗口标题、游戏目录、输出目录、功能开关与界面偏好（如 `settings.json`）。\n- 运行数据：日志与崩溃文件（如 `logs/` 目录）。\n- 功能数据：本地索引与元数据（如 `database.db`，用于图库等功能）。\n\n默认情况下，本项目不提供账号系统，不内置广告 SDK，也不会在未启用特定联网功能时向项目维护者提供的网络接口发送用于功能处理的数据。\n\n### 3. 联网行为\n\n- 当你主动执行\"检查更新/下载更新\"等操作时，软件会访问更新源。\n- 若你启用了\"自动检查更新\"，软件会在启动时自动访问更新源。\n- “无限暖暖照片元数据解析”是可选联网功能，可跳过，不影响其他基础功能。\n- 仅在你启用该功能或手动发起解析时，软件才会访问相应服务，并发送解析所需的 UID、照片内嵌参数及基础请求信息；不会上传整张图片文件。\n- 启用后，该功能可能在检测到新的相关照片时自动后台运行。\n- 访问更新源或上述服务时，请求可能被对应服务提供方记录基础访问日志（例如 IP、时间、User-Agent）。\n\n### 4. 数据共享与用户反馈\n\n- 默认不向开发者服务器主动上传本地数据，但你主动启用的可选联网功能除外。\n- 若你主动在公开平台提交 Issue、日志、崩溃文件或截图，则视为你同意在该平台公开这些内容。\n\n### 5. 风险与免责\n\n- 本软件按\"现状\"提供，不承诺在所有环境下无错误、无中断或完全兼容。\n- 你应自行评估和承担使用风险，包括但不限于性能波动、兼容性问题、数据损失、崩溃或其他异常后果。\n- 在适用法律允许范围内，项目维护者不对因使用或无法使用本软件造成的间接损失、附带损失或特殊损失承担责任。\n\n### 6. 使用边界\n\n你仅可在合法、合规范围内使用本软件，不得用于违法、破坏性或恶意用途。\n\n### 7. 变更与支持\n\n- 本说明可能随项目演进更新，更新后版本在仓库或文档站发布。\n- 继续使用软件即视为你接受更新后的说明。\n- 本项目不提供一对一客服或响应时效承诺；若仓库开放 Issues，你可自行通过 Issues 反馈。\n"
  },
  {
    "path": "docs/zh/developer/architecture.md",
    "content": "# 架构与构建\n\n## 架构与代码规范说明\n\n本项目核心采用 C++23 Modules 与 Vue 3 混合双端架构。\n关于详细的设计哲学、C++ 组件系统划分以及所有的模块依赖关系，已在此仓库根目录维护了最新的 **[`AGENTS.md`](https://github.com/ChanIok/SpinningMomo/blob/main/AGENTS.md)**。\n\n## 环境要求\n\n| 工具 | 要求 | 说明 |\n|------|------|------|\n| **Visual Studio 2022+** | 含「使用 C++ 的桌面开发」工作负载 | 需在工作负载中额外勾选「**C++ 模块（针对标准库的 MSVC v143）**」|\n| **Windows SDK** | 10.0.22621.0+（Windows 11 SDK） | |\n| **xmake** | 最新版 | C++ 构建系统，管理 vcpkg 依赖 |\n| **Node.js** | v20+ | Web 前端构建及 npm 脚本 |\n\n### 安装 xmake\n\n```powershell\n# PowerShell（推荐）\niwr -useb https://xmake.io/psget.txt | iex\n\n# 或前往官网下载安装包\n# https://xmake.io/#/getting_started?id=installation\n```\n\n> xmake 会通过 `xmake-requires.lock` 自动调用 vcpkg 下载和编译 C++ 依赖，**无需手动安装 vcpkg**。\n\n---\n\n## 依赖准备\n\n### 1. 获取第三方依赖\n\n```powershell\n.\\scripts\\fetch-third-party.ps1\n```\n\n### 2. 安装 npm 依赖\n\n```bash\n# 根目录（构建脚本依赖）\nnpm install\n\n# Web 前端依赖\ncd web && npm ci\n```\n\n---\n\n## 构建\n\n### 完整构建（推荐）\n\n```bash\n# 一键完成：C++ Release + Web 前端 + 打包 dist/\nnpm run build:ci\n```\n\n产物位于 `dist/` 目录。\n\n### 分步构建\n\n```bash\n# C++ 后端 - Debug（日常开发）\nxmake config -m debug\nxmake build\n\n# C++ 后端 - Release\nxmake release    # 构建 release 后自动恢复 debug 配置\n\n# Web 前端\ncd web && npm run build\n\n# 打包 dist/（汇总 exe + web 资源）\nnpm run build:prepare\n```\n\n### 构建输出路径\n\n| 构建类型 | 路径 |\n|----------|------|\n| Debug | `build\\windows\\x64\\debug\\` |\n| Release | `build\\windows\\x64\\release\\` |\n| 打包产物 | `dist\\` |\n\n---\n\n## 打包发布产物\n\n### 便携版（ZIP）\n\n```bash\nnpm run build:portable\n```\n\n### MSI 安装包\n\n需要额外安装 WiX Toolset v6：\n\n```bash\ndotnet tool install --global wix --version 6.0.2\nwix extension add WixToolset.UI.wixext/6.0.2 --global\nwix extension add WixToolset.BootstrapperApplications.wixext/6.0.2 --global\n```\n\n然后运行：\n\n```powershell\n.\\scripts\\build-msi.ps1 -Version \"x.y.z\"\n```\n\n---\n\n## Web 前端开发\n\n启动开发服务器（需 C++ 后端同时运行）：\n\n```bash\ncd web && npm run dev\n```\n\nVite 开发服务器会将 `/rpc` 和 `/static` 代理到 C++ 后端（`localhost:51206`）。\n\n---\n\n## 代码生成脚本\n\n修改以下源文件后需重新运行对应脚本：\n\n| 修改内容 | 需运行的脚本 |\n|----------|-------------|\n| `src/migrations/*.sql` | `node scripts/generate-migrations.js` |\n| `src/locales/*.json` | `node scripts/generate-embedded-locales.js` |\n"
  },
  {
    "path": "docs/zh/features/recording.md",
    "content": "# 视频录制\n\n## 使用方法\n\n1. 将游戏窗口调整至需要的比例和分辨率\n2. 点击浮窗中的\"开始录制\"\n3. 录制完成后点击\"停止录制\"\n\n录制文件默认保存至系统\"视频\"文件夹下的 `SpinningMomo` 目录（可在设置中自定义路径），格式为 MP4，分辨率与当前窗口尺寸一致。\n\n## 与外部录制工具的关系\n\n内置录制定位为**轻量适配方案**，适合快速使用或不想切换工具的场景。如果有更高的录制质量、推流、多音轨等需求，仍建议使用 OBS 等专业工具并手动配置捕获区域。\n\n::: warning 性能提示\n录制超高分辨率（如 8K）对 CPU 和磁盘写入速度要求较高，请确保硬件性能足够。\n:::\n"
  },
  {
    "path": "docs/zh/features/screenshot.md",
    "content": "# 超清截图\n\n## 游戏内置截图（推荐）\n\n对于《无限暖暖》，**推荐优先使用游戏内置的\"大喵相机\"拍照**。游戏相机在拍摄时会暂停渲染，可避免运动模糊和时间性锯齿，同时也会保存游戏照片的元数据。\n\n点击浮窗中的\"游戏相册\"可直接跳转到游戏相册目录，无需手动查找。\n\n## 程序内置截图\n\n程序自带截图功能，直接捕获目标窗口的当前画面，文件保存至系统\"视频\"文件夹下的 `SpinningMomo` 目录（可在设置中自定义路径）。\n\n::: info 适用场景\n- 游戏内置截图无法保存当前超高分辨率画面时\n- 需要对其他游戏或窗口截图时\n\n正常情况下进行《无限暖暖》游戏摄影时不需要用到此功能。\n:::"
  },
  {
    "path": "docs/zh/features/window.md",
    "content": "# 比例与分辨率\n\n## 选择目标窗口\n\n::: info 支持的游戏\n程序默认选择《无限暖暖》作为目标窗口。同时兼容多数窗口化运行的其他游戏，如：\n- 《最终幻想14》\n- 《鸣潮》\n- 《崩坏：星穹铁道》（不完全适配自定义比例）\n- 《燕云十六声》（建议使用程序的截图功能）\n\n如需调整其他窗口，可通过**右键单击悬浮窗或托盘图标**在菜单中选择窗口，**务必将游戏设置为窗口化运行**。\n:::\n\n## 比例预设\n\n| 比例 | 适用场景 |\n|:--|:--|\n| 32:9 | 超宽屏，全景构图 |\n| 21:9 | 宽屏，电影感构图 |\n| 16:9 | 标准横屏 |\n| 3:2 | 经典相机比例 |\n| 1:1 | 方形构图 |\n| 3:4 | 小红书推荐比例 |\n| 2:3 | 人像竖拍 |\n| 9:16 | 手机竖屏 / 短视频 |\n\n## 分辨率预设\n\n| 预设 | 等效基准 | 总像素数 |\n|:--|:--|:--|\n| 1080P | 1920×1080 | 约 207 万 |\n| 2K | 2560×1440 | 约 369 万 |\n| 4K | 3840×2160 | 约 830 万 |\n| 6K | 5760×3240 | 约 1870 万 |\n| 8K | 7680×4320 | 约 3320 万 |\n| 12K | 11520×6480 | 约 7460 万 |\n\n::: tip 分辨率的计算逻辑\n程序先按选定的分辨率预设确定总像素数，再按选定的比例重新分配宽高。\n例如：**8K + 9:16** → 输出 **4320×7680**，总像素数与 8K 相近。\n:::\n\n::: warning 性能说明\n分辨率越高，对显存、内存和虚拟内存的消耗越大。RTX 3060 12G + 32G 内存的环境下，12K 分辨率会出现明显卡顿。如遇游戏崩溃，可通过任务管理器确认资源瓶颈，或适当增加虚拟内存。\n:::\n\n## 辅助功能\n\n**预览窗**：当游戏窗口超出屏幕时，提供类似 Photoshop 导航器的实时悬浮预览，支持滚轮缩放和拖拽移动。\n\n**叠加层**：将超出屏幕的大窗口缩放渲染到全屏叠加层上，在超高分辨率下保持正常的鼠标交互。对 CPU 有额外开销，已明显卡顿时建议关闭。\n\n**黑边模式**：在窗口底部铺全屏黑色背景，为非屏幕原生比例的窗口提供沉浸式体验。\n"
  },
  {
    "path": "docs/zh/guide/getting-started.md",
    "content": "# 安装与运行\r\n\r\n::: info 版本与文档\r\n当前仓库发布的新版仍在快速迭代，部分功能或体验可能不稳定。若你更希望使用**行为相对固定的版本**，可下载 [v0.7.7](https://github.com/ChanIok/SpinningMomo/releases/tag/v0.7.7)，并阅读该版本对应的说明文档：[v0.7.7 文档专区](/v0/)。\r\n:::\r\n\r\n本页面将带你完成初次配置，并拍出你的第一张超清竖构图。\r\n\r\n## 下载程序\r\n\r\n| 下载源 | 链接 | 说明 |\r\n|:--|:--|:--|\r\n| **GitHub** | [点击下载](https://github.com/ChanIok/SpinningMomo/releases/latest) | 国内访问可能受限 |\r\n| **百度网盘** | [点击下载](https://pan.baidu.com/s/1UL9EJa2ogSZ4DcnGa2XcRQ?pwd=momo) | 提取码：`momo` |\r\n\r\n**版本类型说明：** 提供 **安装版（.exe）** 与 **便携版（.zip）**。**推荐大多数用户使用安装版**（含安装与卸载管理）。便携版为免安装绿色包，解压即可运行。\r\n\r\n### 系统要求\r\n\r\n::: warning 运行环境\r\n- **操作系统**：Windows 10 1903 (Build 18362) 或更高版本（64 位）\r\n- **显卡驱动**：支持 DirectX 11，并保持驱动为最新版本\r\n- **WebView2**：主界面依赖 [Microsoft WebView2 运行时](https://developer.microsoft.com/zh-cn/microsoft-edge/webview2/)（**≥ 123.0.2420.47**），现代 Windows 通常已内置\r\n:::\r\n\r\n## 首次启动\r\n\r\n运行程序后，可能会弹出以下系统提示：\r\n\r\n::: warning Windows 安全提示\r\n- **SmartScreen 提示**：点击 **更多信息** → **仍要运行**（开源软件无商业代码签名）\r\n- **UAC 提示**：点击 **是** 授予管理员权限（程序调整游戏窗口必须使用此权限）\r\n:::\r\n\r\n## 初始配置向导\r\n\r\n首次启动后，程序会进入配置向导：\r\n\r\n**第 1 步**：选择界面语言和主题（深色/浅色/跟随系统）\r\n\r\n**第 2 步**：确认目标窗口标题\r\n\r\n配置完成后，程序浮窗将自动出现在屏幕上。\r\n\r\n::: tip\r\n按 **`` Ctrl + ` ``**（键盘左上角反引号键，数字 `1` 左边）可随时隐藏/显示浮窗。\r\n:::\r\n\r\n## 游戏内前置设置\r\n\r\n在开始拍摄前，进入《无限暖暖》确认以下设置：\r\n\r\n- **显示模式**：选择 **窗口模式**\r\n- **拍照 - 照片画质**：选择 **窗口分辨率**\r\n\r\n## 拍出第一张超清竖构图\r\n\r\n1. 打开大喵相机进入摄影模式，找好场景和角色位置\r\n2. 在程序浮窗中，选择比例 **9:16**，分辨率选择 **8K**（电脑性能较弱可选 4K 或 6K）\r\n3. 此时画面会扩展超出屏幕，**属正常现象**\r\n\r\n::: tip 画面超出屏幕时\r\n可在浮窗中开启**叠加层**或**预览窗**功能，在屏幕范围内实时预览完整画面。\r\n:::\r\n\r\n4. 按空格键拍照\r\n5. 完成拍摄后，在浮窗中点击 **重置** 恢复窗口到正常大小\r\n"
  },
  {
    "path": "installer/Bundle.wxs",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\n<!--\n  SpinningMomo Bundle Installer\n  WiX v6 Burn Bundle - provides modern UI installer (.exe)\n-->\n\n<Wix xmlns=\"http://wixtoolset.org/schemas/v4/wxs\"\n     xmlns:bal=\"http://wixtoolset.org/schemas/v4/wxs/bal\">\n\n  <Bundle\n    Name=\"SpinningMomo\"\n    Manufacturer=\"InfinityMomo\"\n    Version=\"$(var.ProductVersion)\"\n    UpgradeCode=\"F9A8B7C6-5D4E-3F2A-1B0C-9D8E7F6A5B4C\"\n    IconSourceFile=\"$(var.ProjectDir)\\resources\\icon.ico\"\n    AboutUrl=\"https://github.com/ChanIok/SpinningMomo\"\n    HelpUrl=\"https://github.com/ChanIok/SpinningMomo/issues\">\n\n    <!-- 与 Package.wxs 中 INSTALLFOLDER 默认一致；Options 页修改的是此变量，需经 MsiProperty 传给 MSI -->\n    <Variable\n      Name=\"InstallFolder\"\n      Type=\"formatted\"\n      Value=\"[LocalAppDataFolder]Programs\\SpinningMomo\"\n      bal:Overridable=\"yes\" />\n\n    <!-- Standard Bootstrapper Application with modern UI -->\n    <BootstrapperApplication>\n      <bal:WixStandardBootstrapperApplication\n        Theme=\"hyperlinkLicense\"\n        LocalizationFile=\"$(var.ProjectDir)\\installer\\bundle\\thm.wxl\"\n        LicenseUrl=\"#(loc.LicenseUrl)\"\n        LaunchTarget=\"[InstallFolder]\\SpinningMomo.exe\"\n        LaunchWorkingFolder=\"[InstallFolder]\"\n        LogoFile=\"$(var.ProjectDir)\\docs\\public\\logo.png\" />\n      <PayloadGroupRef Id=\"BundleLocalizationPayloads\" />\n    </BootstrapperApplication>\n\n    <Chain>\n      <!-- Install the MSI package -->\n      <MsiPackage\n        Id=\"MainPackage\"\n        SourceFile=\"$(var.MsiPath)\"\n        Visible=\"no\">\n        <MsiProperty Name=\"INSTALLFOLDER\" Value=\"[InstallFolder]\" />\n      </MsiPackage>\n    </Chain>\n\n  </Bundle>\n\n  <Fragment>\n    <PayloadGroup Id=\"BundleLocalizationPayloads\">\n      <Payload Id=\"BundleLoc1028\" Name=\"1028\\thm.wxl\" SourceFile=\"$(var.ProjectDir)\\installer\\bundle\\payloads\\2052\\thm.wxl\" />\n      <Payload Id=\"BundleLoc2052\" Name=\"2052\\thm.wxl\" SourceFile=\"$(var.ProjectDir)\\installer\\bundle\\payloads\\2052\\thm.wxl\" />\n      <Payload Id=\"BundleLoc3076\" Name=\"3076\\thm.wxl\" SourceFile=\"$(var.ProjectDir)\\installer\\bundle\\payloads\\2052\\thm.wxl\" />\n      <Payload Id=\"BundleLoc4100\" Name=\"4100\\thm.wxl\" SourceFile=\"$(var.ProjectDir)\\installer\\bundle\\payloads\\2052\\thm.wxl\" />\n      <Payload Id=\"BundleLoc5124\" Name=\"5124\\thm.wxl\" SourceFile=\"$(var.ProjectDir)\\installer\\bundle\\payloads\\2052\\thm.wxl\" />\n    </PayloadGroup>\n  </Fragment>\n\n</Wix>\n"
  },
  {
    "path": "installer/CleanupAppDataRoot.js",
    "content": "// 彻底卸载时递归删除 %LocalAppData%\\SpinningMomo（含 webview2、缩略图等）。\n// 路径由 Package.wxs 中 Type 51 CustomAction（SetRemoveAppDataDir）写入 REMOVEAPPDATADIR，再经 deferred 传入 CustomActionData。\n// 注意：MSI 嵌入 JScript 的 Session 对象不支持 Session.Log，调用会报 1720 且中断脚本。\n\nfunction RemoveAppDataRootDeferred() {\n  try {\n    var folder = Session.Property(\"CustomActionData\");\n    if (!folder || folder === \"\") {\n      return 1;\n    }\n    var fso = new ActiveXObject(\"Scripting.FileSystemObject\");\n    if (fso.FolderExists(folder)) {\n      fso.DeleteFolder(folder, true);\n    }\n    return 1;\n  } catch (e) {\n    return 1;\n  }\n}\n"
  },
  {
    "path": "installer/DetectRunningSpinningMomo.js",
    "content": "function DetectRunningSpinningMomo() {\n    try {\n        var wmi = GetObject(\"winmgmts:{impersonationLevel=impersonate}!\\\\\\\\.\\\\root\\\\cimv2\");\n        var results = wmi.ExecQuery(\"SELECT ProcessId FROM Win32_Process WHERE Name='SpinningMomo.exe'\");\n        var hasRunningProcess = false;\n\n        var enumerator = new Enumerator(results);\n        for (; !enumerator.atEnd(); enumerator.moveNext()) {\n            hasRunningProcess = true;\n            break;\n        }\n\n        Session.Property(\"SPINNINGMOMO_RUNNING\") = hasRunningProcess ? \"1\" : \"\";\n        return 1;\n    }\n    catch (e) {\n        Session.Log(\"DetectRunningSpinningMomo script error: \" + e.message);\n        return 1;\n    }\n}\n"
  },
  {
    "path": "installer/License.rtf",
    "content": "{\\rtf1\\ansi\\deff0{\\fonttbl{\\f0 Arial;}}\\f0\\fs20                     GNU GENERAL PUBLIC LICENSE\n                       Version 3, 29 June 2007\n\n Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>\n Everyone is permitted to copy and distribute verbatim copies\n of this license document, but changing it is not allowed.\n\n                            Preamble\n\n  The GNU General Public License is a free, copyleft license for\nsoftware and other kinds of works.\n\n  The licenses for most software and other practical works are designed\nto take away your freedom to share and change the works.  By contrast,\nthe GNU General Public License is intended to guarantee your freedom to\nshare and change all versions of a program--to make sure it remains free\nsoftware for all its users.  We, the Free Software Foundation, use the\nGNU General Public License for most of our software; it applies also to\nany other work released this way by its authors.  You can apply it to\nyour programs, too.\n\n  When we speak of free software, we are referring to freedom, not\nprice.  Our General Public Licenses are designed to make sure that you\nhave the freedom to distribute copies of free software (and charge for\nthem if you wish), that you receive source code or can get it if you\nwant it, that you can change the software or use pieces of it in new\nfree programs, and that you know you can do these things.\n\n  To protect your rights, we need to prevent others from denying you\nthese rights or asking you to surrender the rights.  Therefore, you have\ncertain responsibilities if you distribute copies of the software, or if\nyou modify it: responsibilities to respect the freedom of others.\n\n  For example, if you distribute copies of such a program, whether\ngratis or for a fee, you must pass on to the recipients the same\nfreedoms that you received.  You must make sure that they, too, receive\nor can get the source code.  And you must show them these terms so they\nknow their rights.\n\n  Developers that use the GNU GPL protect your rights with two steps:\n(1) assert copyright on the software, and (2) offer you this License\ngiving you legal permission to copy, distribute and/or modify it.\n\n  For the developers' and authors' protection, the GPL clearly explains\nthat there is no warranty for this free software.  For both users' and\nauthors' sake, the GPL requires that modified versions be marked as\nchanged, so that their problems will not be attributed erroneously to\nauthors of previous versions.\n\n  Some devices are designed to deny users access to install or run\nmodified versions of the software inside them, although the manufacturer\ncan do so.  This is fundamentally incompatible with the aim of\nprotecting users' freedom to change the software.  The systematic\npattern of such abuse occurs in the area of products for individuals to\nuse, which is precisely where it is most unacceptable.  Therefore, we\nhave designed this version of the GPL to prohibit the practice for those\nproducts.  If such problems arise substantially in other domains, we\nstand ready to extend this provision to those domains in future versions\nof the GPL, as needed to protect the freedom of users.\n\n  Finally, every program is threatened constantly by software patents.\nStates should not allow patents to restrict development and use of\nsoftware on general-purpose computers, but in those that do, we wish to\navoid the special danger that patents applied to a free program could\nmake it effectively proprietary.  To prevent this, the GPL assures that\npatents cannot be used to render the program non-free.\n\n  The precise terms and conditions for copying, distribution and\nmodification follow.\n\n                       TERMS AND CONDITIONS\n\n  0. Definitions.\n\n  \"This License\" refers to version 3 of the GNU General Public License.\n\n  \"Copyright\" also means copyright-like laws that apply to other kinds of\nworks, such as semiconductor masks.\n\n  \"The Program\" refers to any copyrightable work licensed under this\nLicense.  Each licensee is addressed as \"you\".  \"Licensees\" and\n\"recipients\" may be individuals or organizations.\n\n  To \"modify\" a work means to copy from or adapt all or part of the work\nin a fashion requiring copyright permission, other than the making of an\nexact copy.  The resulting work is called a \"modified version\" of the\nearlier work or a work \"based on\" the earlier work.\n\n  A \"covered work\" means either the unmodified Program or a work based\non the Program.\n\n  To \"propagate\" a work means to do anything with it that, without\npermission, would make you directly or secondarily liable for\ninfringement under applicable copyright law, except executing it on a\ncomputer or modifying a private copy.  Propagation includes copying,\ndistribution (with or without modification), making available to the\npublic, and in some countries other activities as well.\n\n  To \"convey\" a work means any kind of propagation that enables other\nparties to make or receive copies.  Mere interaction with a user through\na computer network, with no transfer of a copy, is not conveying.\n\n  An interactive user interface displays \"Appropriate Legal Notices\"\nto the extent that it includes a convenient and prominently visible\nfeature that (1) displays an appropriate copyright notice, and (2)\ntells the user that there is no warranty for the work (except to the\nextent that warranties are provided), that licensees may convey the\nwork under this License, and how to view a copy of this License.  If\nthe interface presents a list of user commands or options, such as a\nmenu, a prominent item in the list meets this criterion.\n\n  1. Source Code.\n\n  The \"source code\" for a work means the preferred form of the work\nfor making modifications to it.  \"Object code\" means any non-source\nform of a work.\n\n  A \"Standard Interface\" means an interface that either is an official\nstandard defined by a recognized standards body, or, in the case of\ninterfaces specified for a particular programming language, one that\nis widely used among developers working in that language.\n\n  The \"System Libraries\" of an executable work include anything, other\nthan the work as a whole, that (a) is included in the normal form of\npackaging a Major Component, but which is not part of that Major\nComponent, and (b) serves only to enable use of the work with that\nMajor Component, or to implement a Standard Interface for which an\nimplementation is available to the public in source code form.  A\n\"Major Component\", in this context, means a major essential component\n(kernel, window system, and so on) of the specific operating system\n(if any) on which the executable work runs, or a compiler used to\nproduce the work, or an object code interpreter used to run it.\n\n  The \"Corresponding Source\" for a work in object code form means all\nthe source code needed to generate, install, and (for an executable\nwork) run the object code and to modify the work, including scripts to\ncontrol those activities.  However, it does not include the work's\nSystem Libraries, or general-purpose tools or generally available free\nprograms which are used unmodified in performing those activities but\nwhich are not part of the work.  For example, Corresponding Source\nincludes interface definition files associated with source files for\nthe work, and the source code for shared libraries and dynamically\nlinked subprograms that the work is specifically designed to require,\nsuch as by intimate data communication or control flow between those\nsubprograms and other parts of the work.\n\n  The Corresponding Source need not include anything that users\ncan regenerate automatically from other parts of the Corresponding\nSource.\n\n  The Corresponding Source for a work in source code form is that\nsame work.\n\n  2. Basic Permissions.\n\n  All rights granted under this License are granted for the term of\ncopyright on the Program, and are irrevocable provided the stated\nconditions are met.  This License explicitly affirms your unlimited\npermission to run the unmodified Program.  The output from running a\ncovered work is covered by this License only if the output, given its\ncontent, constitutes a covered work.  This License acknowledges your\nrights of fair use or other equivalent, as provided by copyright law.\n\n  You may make, run and propagate covered works that you do not\nconvey, without conditions so long as your license otherwise remains\nin force.  You may convey covered works to others for the sole purpose\nof having them make modifications exclusively for you, or provide you\nwith facilities for running those works, provided that you comply with\nthe terms of this License in conveying all material for which you do\nnot control copyright.  Those thus making or running the covered works\nfor you must do so exclusively on your behalf, under your direction\nand control, on terms that prohibit them from making any copies of\nyour copyrighted material outside their relationship with you.\n\n  Conveying under any other circumstances is permitted solely under\nthe conditions stated below.  Sublicensing is not allowed; section 10\nmakes it unnecessary.\n\n  3. Protecting Users' Legal Rights From Anti-Circumvention Law.\n\n  No covered work shall be deemed part of an effective technological\nmeasure under any applicable law fulfilling obligations under article\n11 of the WIPO copyright treaty adopted on 20 December 1996, or\nsimilar laws prohibiting or restricting circumvention of such\nmeasures.\n\n  When you convey a covered work, you waive any legal power to forbid\ncircumvention of technological measures to the extent such circumvention\nis effected by exercising rights under this License with respect to\nthe covered work, and you disclaim any intention to limit operation or\nmodification of the work as a means of enforcing, against the work's\nusers, your or third parties' legal rights to forbid circumvention of\ntechnological measures.\n\n  4. Conveying Verbatim Copies.\n\n  You may convey verbatim copies of the Program's source code as you\nreceive it, in any medium, provided that you conspicuously and\nappropriately publish on each copy an appropriate copyright notice;\nkeep intact all notices stating that this License and any\nnon-permissive terms added in accord with section 7 apply to the code;\nkeep intact all notices of the absence of any warranty; and give all\nrecipients a copy of this License along with the Program.\n\n  You may charge any price or no price for each copy that you convey,\nand you may offer support or warranty protection for a fee.\n\n  5. Conveying Modified Source Versions.\n\n  You may convey a work based on the Program, or the modifications to\nproduce it from the Program, in the form of source code under the\nterms of section 4, provided that you also meet all of these conditions:\n\n    a) The work must carry prominent notices stating that you modified\n    it, and giving a relevant date.\n\n    b) The work must carry prominent notices stating that it is\n    released under this License and any conditions added under section\n    7.  This requirement modifies the requirement in section 4 to\n    \"keep intact all notices\".\n\n    c) You must license the entire work, as a whole, under this\n    License to anyone who comes into possession of a copy.  This\n    License will therefore apply, along with any applicable section 7\n    additional terms, to the whole of the work, and all its parts,\n    regardless of how they are packaged.  This License gives no\n    permission to license the work in any other way, but it does not\n    invalidate such permission if you have separately received it.\n\n    d) If the work has interactive user interfaces, each must display\n    Appropriate Legal Notices; however, if the Program has interactive\n    interfaces that do not display Appropriate Legal Notices, your\n    work need not make them do so.\n\n  A compilation of a covered work with other separate and independent\nworks, which are not by their nature extensions of the covered work,\nand which are not combined with it such as to form a larger program,\nin or on a volume of a storage or distribution medium, is called an\n\"aggregate\" if the compilation and its resulting copyright are not\nused to limit the access or legal rights of the compilation's users\nbeyond what the individual works permit.  Inclusion of a covered work\nin an aggregate does not cause this License to apply to the other\nparts of the aggregate.\n\n  6. Conveying Non-Source Forms.\n\n  You may convey a covered work in object code form under the terms\nof sections 4 and 5, provided that you also convey the\nmachine-readable Corresponding Source under the terms of this License,\nin one of these ways:\n\n    a) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by the\n    Corresponding Source fixed on a durable physical medium\n    customarily used for software interchange.\n\n    b) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by a\n    written offer, valid for at least three years and valid for as\n    long as you offer spare parts or customer support for that product\n    model, to give anyone who possesses the object code either (1) a\n    copy of the Corresponding Source for all the software in the\n    product that is covered by this License, on a durable physical\n    medium customarily used for software interchange, for a price no\n    more than your reasonable cost of physically performing this\n    conveying of source, or (2) access to copy the\n    Corresponding Source from a network server at no charge.\n\n    c) Convey individual copies of the object code with a copy of the\n    written offer to provide the Corresponding Source.  This\n    alternative is allowed only occasionally and noncommercially, and\n    only if you received the object code with such an offer, in accord\n    with subsection 6b.\n\n    d) Convey the object code by offering access from a designated\n    place (gratis or for a charge), and offer equivalent access to the\n    Corresponding Source in the same way through the same place at no\n    further charge.  You need not require recipients to copy the\n    Corresponding Source along with the object code.  If the place to\n    copy the object code is a network server, the Corresponding Source\n    may be on a different server (operated by you or a third party)\n    that supports equivalent copying facilities, provided you maintain\n    clear directions next to the object code saying where to find the\n    Corresponding Source.  Regardless of what server hosts the\n    Corresponding Source, you remain obligated to ensure that it is\n    available for as long as needed to satisfy these requirements.\n\n    e) Convey the object code using peer-to-peer transmission, provided\n    you inform other peers where the object code and Corresponding\n    Source of the work are being offered to the general public at no\n    charge under subsection 6d.\n\n  A separable portion of the object code, whose source code is excluded\nfrom the Corresponding Source as a System Library, need not be\nincluded in conveying the object code work.\n\n  A \"User Product\" is either (1) a \"consumer product\", which means any\ntangible personal property which is normally used for personal, family,\nor household purposes, or (2) anything designed or sold for incorporation\ninto a dwelling.  In determining whether a product is a consumer product,\ndoubtful cases shall be resolved in favor of coverage.  For a particular\nproduct received by a particular user, \"normally used\" refers to a\ntypical or common use of that class of product, regardless of the status\nof the particular user or of the way in which the particular user\nactually uses, or expects or is expected to use, the product.  A product\nis a consumer product regardless of whether the product has substantial\ncommercial, industrial or non-consumer uses, unless such uses represent\nthe only significant mode of use of the product.\n\n  \"Installation Information\" for a User Product means any methods,\nprocedures, authorization keys, or other information required to install\nand execute modified versions of a covered work in that User Product from\na modified version of its Corresponding Source.  The information must\nsuffice to ensure that the continued functioning of the modified object\ncode is in no case prevented or interfered with solely because\nmodification has been made.\n\n  If you convey an object code work under this section in, or with, or\nspecifically for use in, a User Product, and the conveying occurs as\npart of a transaction in which the right of possession and use of the\nUser Product is transferred to the recipient in perpetuity or for a\nfixed term (regardless of how the transaction is characterized), the\nCorresponding Source conveyed under this section must be accompanied\nby the Installation Information.  But this requirement does not apply\nif neither you nor any third party retains the ability to install\nmodified object code on the User Product (for example, the work has\nbeen installed in ROM).\n\n  The requirement to provide Installation Information does not include a\nrequirement to continue to provide support service, warranty, or updates\nfor a work that has been modified or installed by the recipient, or for\nthe User Product in which it has been modified or installed.  Access to a\nnetwork may be denied when the modification itself materially and\nadversely affects the operation of the network or violates the rules and\nprotocols for communication across the network.\n\n  Corresponding Source conveyed, and Installation Information provided,\nin accord with this section must be in a format that is publicly\ndocumented (and with an implementation available to the public in\nsource code form), and must require no special password or key for\nunpacking, reading or copying.\n\n  7. Additional Terms.\n\n  \"Additional permissions\" are terms that supplement the terms of this\nLicense by making exceptions from one or more of its conditions.\nAdditional permissions that are applicable to the entire Program shall\nbe treated as though they were included in this License, to the extent\nthat they are valid under applicable law.  If additional permissions\napply only to part of the Program, that part may be used separately\nunder those permissions, but the entire Program remains governed by\nthis License without regard to the additional permissions.\n\n  When you convey a copy of a covered work, you may at your option\nremove any additional permissions from that copy, or from any part of\nit.  (Additional permissions may be written to require their own\nremoval in certain cases when you modify the work.)  You may place\nadditional permissions on material, added by you to a covered work,\nfor which you have or can give appropriate copyright permission.\n\n  Notwithstanding any other provision of this License, for material you\nadd to a covered work, you may (if authorized by the copyright holders of\nthat material) supplement the terms of this License with terms:\n\n    a) Disclaiming warranty or limiting liability differently from the\n    terms of sections 15 and 16 of this License; or\n\n    b) Requiring preservation of specified reasonable legal notices or\n    author attributions in that material or in the Appropriate Legal\n    Notices displayed by works containing it; or\n\n    c) Prohibiting misrepresentation of the origin of that material, or\n    requiring that modified versions of such material be marked in\n    reasonable ways as different from the original version; or\n\n    d) Limiting the use for publicity purposes of names of licensors or\n    authors of the material; or\n\n    e) Declining to grant rights under trademark law for use of some\n    trade names, trademarks, or service marks; or\n\n    f) Requiring indemnification of licensors and authors of that\n    material by anyone who conveys the material (or modified versions of\n    it) with contractual assumptions of liability to the recipient, for\n    any liability that these contractual assumptions directly impose on\n    those licensors and authors.\n\n  All other non-permissive additional terms are considered \"further\nrestrictions\" within the meaning of section 10.  If the Program as you\nreceived it, or any part of it, contains a notice stating that it is\ngoverned by this License along with a term that is a further\nrestriction, you may remove that term.  If a license document contains\na further restriction but permits relicensing or conveying under this\nLicense, you may add to a covered work material governed by the terms\nof that license document, provided that the further restriction does\nnot survive such relicensing or conveying.\n\n  If you add terms to a covered work in accord with this section, you\nmust place, in the relevant source files, a statement of the\nadditional terms that apply to those files, or a notice indicating\nwhere to find the applicable terms.\n\n  Additional terms, permissive or non-permissive, may be stated in the\nform of a separately written license, or stated as exceptions;\nthe above requirements apply either way.\n\n  8. Termination.\n\n  You may not propagate or modify a covered work except as expressly\nprovided under this License.  Any attempt otherwise to propagate or\nmodify it is void, and will automatically terminate your rights under\nthis License (including any patent licenses granted under the third\nparagraph of section 11).\n\n  However, if you cease all violation of this License, then your\nlicense from a particular copyright holder is reinstated (a)\nprovisionally, unless and until the copyright holder explicitly and\nfinally terminates your license, and (b) permanently, if the copyright\nholder fails to notify you of the violation by some reasonable means\nprior to 60 days after the cessation.\n\n  Moreover, your license from a particular copyright holder is\nreinstated permanently if the copyright holder notifies you of the\nviolation by some reasonable means, this is the first time you have\nreceived notice of violation of this License (for any work) from that\ncopyright holder, and you cure the violation prior to 30 days after\nyour receipt of the notice.\n\n  Termination of your rights under this section does not terminate the\nlicenses of parties who have received copies or rights from you under\nthis License.  If your rights have been terminated and not permanently\nreinstated, you do not qualify to receive new licenses for the same\nmaterial under section 10.\n\n  9. Acceptance Not Required for Having Copies.\n\n  You are not required to accept this License in order to receive or\nrun a copy of the Program.  Ancillary propagation of a covered work\noccurring solely as a consequence of using peer-to-peer transmission\nto receive a copy likewise does not require acceptance.  However,\nnothing other than this License grants you permission to propagate or\nmodify any covered work.  These actions infringe copyright if you do\nnot accept this License.  Therefore, by modifying or propagating a\ncovered work, you indicate your acceptance of this License to do so.\n\n  10. Automatic Licensing of Downstream Recipients.\n\n  Each time you convey a covered work, the recipient automatically\nreceives a license from the original licensors, to run, modify and\npropagate that work, subject to this License.  You are not responsible\nfor enforcing compliance by third parties with this License.\n\n  An \"entity transaction\" is a transaction transferring control of an\norganization, or substantially all assets of one, or subdividing an\norganization, or merging organizations.  If propagation of a covered\nwork results from an entity transaction, each party to that\ntransaction who receives a copy of the work also receives whatever\nlicenses to the work the party's predecessor in interest had or could\ngive under the previous paragraph, plus a right to possession of the\nCorresponding Source of the work from the predecessor in interest, if\nthe predecessor has it or can get it with reasonable efforts.\n\n  You may not impose any further restrictions on the exercise of the\nrights granted or affirmed under this License.  For example, you may\nnot impose a license fee, royalty, or other charge for exercise of\nrights granted under this License, and you may not initiate litigation\n(including a cross-claim or counterclaim in a lawsuit) alleging that\nany patent claim is infringed by making, using, selling, offering for\nsale, or importing the Program or any portion of it.\n\n  11. Patents.\n\n  A \"contributor\" is a copyright holder who authorizes use under this\nLicense of the Program or a work on which the Program is based.  The\nwork thus licensed is called the contributor's \"contributor version\".\n\n  A contributor's \"essential patent claims\" are all patent claims\nowned or controlled by the contributor, whether already acquired or\nhereafter acquired, that would be infringed by some manner, permitted\nby this License, of making, using, or selling its contributor version,\nbut do not include claims that would be infringed only as a\nconsequence of further modification of the contributor version.  For\npurposes of this definition, \"control\" includes the right to grant\npatent sublicenses in a manner consistent with the requirements of\nthis License.\n\n  Each contributor grants you a non-exclusive, worldwide, royalty-free\npatent license under the contributor's essential patent claims, to\nmake, use, sell, offer for sale, import and otherwise run, modify and\npropagate the contents of its contributor version.\n\n  In the following three paragraphs, a \"patent license\" is any express\nagreement or commitment, however denominated, not to enforce a patent\n(such as an express permission to practice a patent or covenant not to\nsue for patent infringement).  To \"grant\" such a patent license to a\nparty means to make such an agreement or commitment not to enforce a\npatent against the party.\n\n  If you convey a covered work, knowingly relying on a patent license,\nand the Corresponding Source of the work is not available for anyone\nto copy, free of charge and under the terms of this License, through a\npublicly available network server or other readily accessible means,\nthen you must either (1) cause the Corresponding Source to be so\navailable, or (2) arrange to deprive yourself of the benefit of the\npatent license for this particular work, or (3) arrange, in a manner\nconsistent with the requirements of this License, to extend the patent\nlicense to downstream recipients.  \"Knowingly relying\" means you have\nactual knowledge that, but for the patent license, your conveying the\ncovered work in a country, or your recipient's use of the covered work\nin a country, would infringe one or more identifiable patents in that\ncountry that you have reason to believe are valid.\n\n  If, pursuant to or in connection with a single transaction or\narrangement, you convey, or propagate by procuring conveyance of, a\ncovered work, and grant a patent license to some of the parties\nreceiving the covered work authorizing them to use, propagate, modify\nor convey a specific copy of the covered work, then the patent license\nyou grant is automatically extended to all recipients of the covered\nwork and works based on it.\n\n  A patent license is \"discriminatory\" if it does not include within\nthe scope of its coverage, prohibits the exercise of, or is\nconditioned on the non-exercise of one or more of the rights that are\nspecifically granted under this License.  You may not convey a covered\nwork if you are a party to an arrangement with a third party that is\nin the business of distributing software, under which you make payment\nto the third party based on the extent of your activity of conveying\nthe work, and under which the third party grants, to any of the\nparties who would receive the covered work from you, a discriminatory\npatent license (a) in connection with copies of the covered work\nconveyed by you (or copies made from those copies), or (b) primarily\nfor and in connection with specific products or compilations that\ncontain the covered work, unless you entered into that arrangement,\nor that patent license was granted, prior to 28 March 2007.\n\n  Nothing in this License shall be construed as excluding or limiting\nany implied license or other defenses to infringement that may\notherwise be available to you under applicable patent law.\n\n  12. No Surrender of Others' Freedom.\n\n  If conditions are imposed on you (whether by court order, agreement or\notherwise) that contradict the conditions of this License, they do not\nexcuse you from the conditions of this License.  If you cannot convey a\ncovered work so as to satisfy simultaneously your obligations under this\nLicense and any other pertinent obligations, then as a consequence you may\nnot convey it at all.  For example, if you agree to terms that obligate you\nto collect a royalty for further conveying from those to whom you convey\nthe Program, the only way you could satisfy both those terms and this\nLicense would be to refrain entirely from conveying the Program.\n\n  13. Use with the GNU Affero General Public License.\n\n  Notwithstanding any other provision of this License, you have\npermission to link or combine any covered work with a work licensed\nunder version 3 of the GNU Affero General Public License into a single\ncombined work, and to convey the resulting work.  The terms of this\nLicense will continue to apply to the part which is the covered work,\nbut the special requirements of the GNU Affero General Public License,\nsection 13, concerning interaction through a network will apply to the\ncombination as such.\n\n  14. Revised Versions of this License.\n\n  The Free Software Foundation may publish revised and/or new versions of\nthe GNU General Public License from time to time.  Such new versions will\nbe similar in spirit to the present version, but may differ in detail to\naddress new problems or concerns.\n\n  Each version is given a distinguishing version number.  If the\nProgram specifies that a certain numbered version of the GNU General\nPublic License \"or any later version\" applies to it, you have the\noption of following the terms and conditions either of that numbered\nversion or of any later version published by the Free Software\nFoundation.  If the Program does not specify a version number of the\nGNU General Public License, you may choose any version ever published\nby the Free Software Foundation.\n\n  If the Program specifies that a proxy can decide which future\nversions of the GNU General Public License can be used, that proxy's\npublic statement of acceptance of a version permanently authorizes you\nto choose that version for the Program.\n\n  Later license versions may give you additional or different\npermissions.  However, no additional obligations are imposed on any\nauthor or copyright holder as a result of your choosing to follow a\nlater version.\n\n  15. Disclaimer of Warranty.\n\n  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY\nAPPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT\nHOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM \"AS IS\" WITHOUT WARRANTY\nOF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,\nTHE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR\nPURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM\nIS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF\nALL NECESSARY SERVICING, REPAIR OR CORRECTION.\n\n  16. Limitation of Liability.\n\n  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING\nWILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS\nTHE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY\nGENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE\nUSE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF\nDATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD\nPARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),\nEVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF\nSUCH DAMAGES.\n\n  17. Interpretation of Sections 15 and 16.\n\n  If the disclaimer of warranty and limitation of liability provided\nabove cannot be given local legal effect according to their terms,\nreviewing courts shall apply local law that most closely approximates\nan absolute waiver of all civil liability in connection with the\nProgram, unless a warranty or assumption of liability accompanies a\ncopy of the Program in return for a fee.\n\n                     END OF TERMS AND CONDITIONS\n\n            How to Apply These Terms to Your New Programs\n\n  If you develop a new program, and you want it to be of the greatest\npossible use to the public, the best way to achieve this is to make it\nfree software which everyone can redistribute and change under these terms.\n\n  To do so, attach the following notices to the program.  It is safest\nto attach them to the start of each source file to most effectively\nstate the exclusion of warranty; and each file should have at least\nthe \"copyright\" line and a pointer to where the full notice is found.\n\n    <one line to give the program's name and a brief idea of what it does.>\n    Copyright (C) <year>  <name of author>\n\n    This program is free software: you can redistribute it and/or modify\n    it under the terms of the GNU General Public License as published by\n    the Free Software Foundation, either version 3 of the License, or\n    (at your option) any later version.\n\n    This program is distributed in the hope that it will be useful,\n    but WITHOUT ANY WARRANTY; without even the implied warranty of\n    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n    GNU General Public License for more details.\n\n    You should have received a copy of the GNU General Public License\n    along with this program.  If not, see <https://www.gnu.org/licenses/>.\n\nAlso add information on how to contact you by electronic and paper mail.\n\n  If the program does terminal interaction, make it output a short\nnotice like this when it starts in an interactive mode:\n\n    <program>  Copyright (C) <year>  <name of author>\n    This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.\n    This is free software, and you are welcome to redistribute it\n    under certain conditions; type `show c' for details.\n\nThe hypothetical commands `show w' and `show c' should show the appropriate\nparts of the General Public License.  Of course, your program's commands\nmight be different; for a GUI interface, you would use an \"about box\".\n\n  You should also get your employer (if you work as a programmer) or school,\nif any, to sign a \"copyright disclaimer\" for the program, if necessary.\nFor more information on this, and how to apply and follow the GNU GPL, see\n<https://www.gnu.org/licenses/>.\n\n  The GNU General Public License does not permit incorporating your program\ninto proprietary programs.  If your program is a subroutine library, you\nmay consider it more useful to permit linking proprietary applications with\nthe library.  If this is what you want to do, use the GNU Lesser General\nPublic License instead of this License.  But first, please read\n<https://www.gnu.org/licenses/why-not-lgpl.html>.\n}\n"
  },
  {
    "path": "installer/Package.en-us.wxl",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<WixLocalization Culture=\"en-US\" xmlns=\"http://wixtoolset.org/schemas/v4/wxl\">\n  <String Id=\"DowngradeError\" Value=\"A newer version of [ProductName] is already installed. Setup will now exit.\" />\n  <String Id=\"ShortcutDescription\" Value=\"Window adjustment tool for Infinity Nikki photography\" />\n  <String Id=\"CloseAppDescription\" Value=\"[ProductName] is currently running. Please close it before uninstalling or upgrading.\" />\n  <String Id=\"CloseAppError\" Value=\"[ProductName] is still running. Please exit the app (including tray icon) and try again.\" />\n</WixLocalization>\n"
  },
  {
    "path": "installer/Package.wxs",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\n<!--\n  SpinningMomo MSI Installer Configuration\n  WiX v5/v6 format - uses Files element for automatic file harvesting\n-->\n\n<Wix xmlns=\"http://wixtoolset.org/schemas/v4/wxs\"\n     xmlns:ui=\"http://wixtoolset.org/schemas/v4/wxs/ui\"\n     xmlns:util=\"http://wixtoolset.org/schemas/v4/wxs/util\">\n  <Package\n    Name=\"SpinningMomo\"\n    Manufacturer=\"InfinityMomo\"\n    Version=\"$(var.ProductVersion)\"\n    UpgradeCode=\"AE1DB654-768A-488C-8DBE-1E213939C662\"\n    Language=\"1033\"\n    Codepage=\"1252\"\n    InstallerVersion=\"500\"\n    Scope=\"perUser\"\n    Compressed=\"yes\">\n\n    <SummaryInformation\n      Description=\"SpinningMomo - Window adjustment tool for Infinity Nikki\"\n      Manufacturer=\"InfinityMomo\" />\n\n    <Binary Id=\"DetectProcessScript\" SourceFile=\"$(var.ProjectDir)\\installer\\DetectRunningSpinningMomo.js\" />\n    <Binary Id=\"CleanupAppDataScript\" SourceFile=\"$(var.ProjectDir)\\installer\\CleanupAppDataRoot.js\" />\n    <CustomAction\n      Id=\"DetectRunningSpinningMomo\"\n      BinaryRef=\"DetectProcessScript\"\n      JScriptCall=\"DetectRunningSpinningMomo\"\n      Execute=\"immediate\"\n      Return=\"check\" />\n    <!-- Type 51：由 MSI 设置 REMOVEAPPDATADIR（避免 JScript 写 Session.Property 在部分系统上无效） -->\n    <CustomAction\n      Id=\"SetRemoveAppDataDir\"\n      Property=\"REMOVEAPPDATADIR\"\n      Value=\"[LocalAppDataFolder]SpinningMomo\" />\n    <!-- Id 必须全大写：deferred+Impersonate 时仅「公共属性」会写入 CustomActionData，否则路径传不进去 -->\n    <CustomAction\n      Id=\"REMOVEAPPDATADIR\"\n      BinaryRef=\"CleanupAppDataScript\"\n      JScriptCall=\"RemoveAppDataRootDeferred\"\n      Execute=\"deferred\"\n      Impersonate=\"yes\"\n      Return=\"ignore\" />\n    <InstallUISequence>\n      <Custom\n        Action=\"DetectRunningSpinningMomo\"\n        Before=\"LaunchConditions\" />\n    </InstallUISequence>\n    <InstallExecuteSequence>\n      <Custom\n        Action=\"DetectRunningSpinningMomo\"\n        Before=\"LaunchConditions\" />\n      <Custom\n        Action=\"SetRemoveAppDataDir\"\n        Before=\"REMOVEAPPDATADIR\"\n        Condition='REMOVE=\"ALL\" AND NOT UPGRADINGPRODUCTCODE' />\n      <Custom\n        Action=\"REMOVEAPPDATADIR\"\n        After=\"RemoveFiles\"\n        Condition='REMOVE=\"ALL\" AND NOT UPGRADINGPRODUCTCODE' />\n    </InstallExecuteSequence>\n\n    <!-- Upgrade logic: install new version first, then remove old components -->\n    <!-- Using afterInstallExecute to preserve desktop shortcut positions -->\n    <MajorUpgrade\n      DowngradeErrorMessage=\"!(loc.DowngradeError)\"\n      Schedule=\"afterInstallExecute\" />\n\n    <!-- Prevent install/upgrade/uninstall from continuing when app process is still running -->\n    <util:CloseApplication\n      Id=\"CloseSpinningMomo\"\n      Target=\"SpinningMomo.exe\"\n      Description=\"!(loc.CloseAppDescription)\"\n      CloseMessage=\"yes\"\n      EndSessionMessage=\"yes\"\n      ElevatedCloseMessage=\"yes\"\n      ElevatedEndSessionMessage=\"yes\"\n      RebootPrompt=\"no\"\n      PromptToContinue=\"no\"\n      Timeout=\"15\"\n      Property=\"SPINNINGMOMO_RUNNING\" />\n\n    <Launch\n      Condition=\"NOT SPINNINGMOMO_RUNNING\"\n      Message=\"!(loc.CloseAppError)\" />\n\n    <!-- Embed CAB into MSI -->\n    <MediaTemplate EmbedCab=\"yes\" CompressionLevel=\"high\" />\n\n    <!-- Icon for Add/Remove Programs -->\n    <Icon Id=\"AppIcon\" SourceFile=\"$(var.ProjectDir)\\resources\\icon.ico\" />\n    <Property Id=\"ARPPRODUCTICON\" Value=\"AppIcon\" />\n    <Property Id=\"ARPURLINFOABOUT\" Value=\"https://github.com/ChanIok/SpinningMomo\" />\n\n    <!-- Features -->\n    <Feature Id=\"MainFeature\" Title=\"SpinningMomo\" Level=\"1\">\n      <ComponentGroupRef Id=\"MainComponents\" />\n      <ComponentGroupRef Id=\"WebResources\" />\n      <ComponentRef Id=\"StartMenuShortcut\" />\n      <ComponentRef Id=\"DesktopShortcut\" />\n      <ComponentRef Id=\"CleanupUserData\" />\n      <ComponentRef Id=\"StartMenuShortcutRemove\" />\n      <ComponentRef Id=\"DesktopShortcutRemove\" />\n    </Feature>\n\n    <!-- UI: Use WiX standard install directory UI -->\n    <Property Id=\"WIXUI_INSTALLDIR\" Value=\"INSTALLFOLDER\" />\n    <WixVariable Id=\"WixUILicenseRtf\" Value=\"$(var.ProjectDir)\\installer\\License.rtf\" />\n    <ui:WixUI Id=\"WixUI_InstallDir\" />\n\n  </Package>\n\n  <!-- Directory Structure -->\n  <Fragment>\n    <StandardDirectory Id=\"LocalAppDataFolder\">\n      <Directory Id=\"ProgramsFolder\" Name=\"Programs\">\n        <Directory Id=\"INSTALLFOLDER\" Name=\"SpinningMomo\">\n          <Directory Id=\"ResourcesFolder\" Name=\"resources\">\n            <Directory Id=\"WebFolder\" Name=\"web\" />\n          </Directory>\n        </Directory>\n      </Directory>\n    </StandardDirectory>\n\n    <StandardDirectory Id=\"ProgramMenuFolder\">\n      <Directory Id=\"AppMenuFolder\" Name=\"SpinningMomo\" />\n    </StandardDirectory>\n\n    <StandardDirectory Id=\"DesktopFolder\" />\n  </Fragment>\n\n  <!-- Main Components -->\n  <Fragment>\n    <ComponentGroup Id=\"MainComponents\" Directory=\"INSTALLFOLDER\">\n      <File Source=\"$(var.DistDir)\\SpinningMomo.exe\" />\n      <File Source=\"$(var.DistDir)\\LEGAL.md\" />\n      <File Source=\"$(var.DistDir)\\LICENSE\" />\n    </ComponentGroup>\n  </Fragment>\n\n  <!-- Web Resources: WiX v5+ Files element for automatic harvesting -->\n  <Fragment>\n    <ComponentGroup Id=\"WebResources\" Directory=\"WebFolder\">\n      <Files Include=\"$(var.DistDir)\\resources\\web\\**\" />\n    </ComponentGroup>\n  </Fragment>\n\n  <!-- Shortcuts -->\n  <Fragment>\n    <!-- Start Menu Shortcut - 创建部分（升级保留） -->\n    <Component Id=\"StartMenuShortcut\" \n               Directory=\"AppMenuFolder\" \n               Guid=\"7808BA3C-C658-440F-976C-838362DD1FFF\"\n               Permanent=\"yes\"\n               Condition=\"NOT WIX_UPGRADE_DETECTED\">\n      <Shortcut Id=\"StartMenuShortcutFile\"\n                Name=\"SpinningMomo\"\n                Description=\"!(loc.ShortcutDescription)\"\n                Target=\"[INSTALLFOLDER]SpinningMomo.exe\"\n                WorkingDirectory=\"INSTALLFOLDER\"\n                Icon=\"AppIcon\"\n                Advertise=\"no\" />\n      <RemoveFolder Id=\"RemoveAppMenuFolder\" On=\"uninstall\" />   <!-- 保留原有，清理空文件夹 -->\n      <RegistryValue Root=\"HKCU\" Key=\"Software\\SpinningMomo\"\n                     Name=\"StartMenuShortcut\" Type=\"integer\" Value=\"1\" KeyPath=\"yes\" />\n    </Component>\n\n    <!-- Start Menu Shortcut - 删除部分（仅完全卸载时执行） -->\n    <Component Id=\"StartMenuShortcutRemove\" \n               Directory=\"AppMenuFolder\" \n               Guid=\"5E694D68-9DFD-4510-9EA1-698F8A09739D\">   <!-- 新 GUID，必须唯一 -->\n      <RemoveFile Id=\"RemoveStartMenuLnk\" \n                  Name=\"SpinningMomo.lnk\" \n                  On=\"uninstall\" />\n      <RegistryValue Root=\"HKCU\" Key=\"Software\\SpinningMomo\"\n                     Name=\"StartMenuShortcutRemove\" Type=\"integer\" Value=\"1\" KeyPath=\"yes\" />\n    </Component>\n\n    <!-- Desktop Shortcut - 创建部分（升级保留） -->\n    <Component Id=\"DesktopShortcut\" \n               Directory=\"DesktopFolder\" \n               Guid=\"CA8D282A-3752-4E74-9252-76AB6F280997\"\n               Permanent=\"yes\"\n               Condition=\"NOT WIX_UPGRADE_DETECTED\">\n      <Shortcut Id=\"DesktopShortcutFile\"\n                Name=\"SpinningMomo\"\n                Description=\"!(loc.ShortcutDescription)\"\n                Target=\"[INSTALLFOLDER]SpinningMomo.exe\"\n                WorkingDirectory=\"INSTALLFOLDER\"\n                Icon=\"AppIcon\"\n                Advertise=\"no\" />\n      <RegistryValue Root=\"HKCU\" Key=\"Software\\SpinningMomo\"\n                     Name=\"DesktopShortcut\" Type=\"integer\" Value=\"1\" KeyPath=\"yes\" />\n    </Component>\n\n    <!-- Desktop Shortcut - 删除部分（仅完全卸载时执行） -->\n    <Component Id=\"DesktopShortcutRemove\" \n               Directory=\"DesktopFolder\" \n               Guid=\"81DD2135-CFFF-4EAD-902C-D25BD1C5612B\">   <!-- 新 GUID，必须唯一 -->\n      <RemoveFile Id=\"RemoveDesktopLnk\" \n                  Name=\"SpinningMomo.lnk\" \n                  On=\"uninstall\" />\n      <RegistryValue Root=\"HKCU\" Key=\"Software\\SpinningMomo\"\n                     Name=\"DesktopShortcutRemove\" Type=\"integer\" Value=\"1\" KeyPath=\"yes\" />\n    </Component>\n  </Fragment>\n\n  <!-- Remove app data on uninstall（路径由 SetRemoveAppDataDir 写入 REMOVEAPPDATADIR；递归删除由 CleanupAppDataRoot.js 的 deferred CA 完成） -->\n  <Fragment>\n    <Component Id=\"CleanupUserData\" Directory=\"INSTALLFOLDER\" Guid=\"7DF1D902-6FF4-43EF-B58A-837AE33B4494\">\n      <!-- Remove install folder itself if empty -->\n      <RemoveFolder Id=\"RemoveInstallFolder\" Directory=\"INSTALLFOLDER\" On=\"uninstall\" />\n      <!-- Registry key for component tracking -->\n      <RegistryValue Root=\"HKCU\" Key=\"Software\\SpinningMomo\"\n                     Name=\"AppDataRoot\" Type=\"string\"\n                     Value=\"[LocalAppDataFolder]SpinningMomo\" />\n      <RegistryValue Root=\"HKCU\" Key=\"Software\\SpinningMomo\"\n                     Name=\"Cleanup\" Type=\"integer\" Value=\"1\" KeyPath=\"yes\" />\n    </Component>\n  </Fragment>\n\n</Wix>\n"
  },
  {
    "path": "installer/bundle/payloads/2052/thm.wxl",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<WixLocalization Culture=\"zh-cn\" Language=\"2052\" xmlns=\"http://wixtoolset.org/schemas/v4/wxl\">\n  <String Id=\"Caption\" Value=\"[WixBundleName] 安装程序\" />\n  <String Id=\"Title\" Value=\"[WixBundleName]\" />\n  <String Id=\"CheckingForUpdatesLabel\" Value=\"正在检查更新\" />\n  <String Id=\"UpdateButton\" Value=\"更新到版本 [WixStdBAUpdateAvailable](&amp;U)\" />\n  <String Id=\"InstallHeader\" Value=\"欢迎\" />\n  <String Id=\"InstallMessage\" Value=\"安装程序将在你的电脑上安装 [WixBundleName]。点击“安装”继续，或点击“取消”退出。\" />\n  <String Id=\"InstallMessageOptions\" Value=\"安装程序将在你的电脑上安装 [WixBundleName]。点击“安装”继续，点击“选项”设置安装选项，或点击“取消”退出。\" />\n  <String Id=\"InstallVersion\" Value=\"版本 [WixBundleVersion]\" />\n  <String Id=\"ConfirmCancelMessage\" Value=\"确定要取消安装吗？\" />\n  <String Id=\"ExecuteUpgradeRelatedBundleMessage\" Value=\"旧版本\" />\n  <String Id=\"HelpHeader\" Value=\"安装帮助\" />\n  <String Id=\"HelpText\" Value=\"/install | /repair | /uninstall | /layout [directory] - 安装、修复、卸载，或在指定目录创建完整的本地安装副本。默认执行安装。&#xA;&#xA;/passive | /quiet - 显示最少界面且不提示，或完全不显示界面且不提示。默认显示完整界面和所有提示。&#xA;&#xA;/norestart - 禁止任何重启尝试。默认情况下，界面会在重启前进行提示。&#xA;/log log.txt - 写入指定日志文件。默认会在 %TEMP% 中创建日志文件。\" />\n  <String Id=\"HelpCloseButton\" Value=\"关闭(&amp;C)\" />\n  <String Id=\"InstallLicenseLinkText\" Value=\"[WixBundleName] &lt;a href=&quot;#&quot;&gt;许可条款&lt;/a&gt;。\" />\n  <String Id=\"InstallAcceptCheckbox\" Value=\"我同意许可条款和条件(&amp;A)\" />\n  <String Id=\"InstallOptionsButton\" Value=\"选项(&amp;O)\" />\n  <String Id=\"InstallInstallButton\" Value=\"安装(&amp;I)\" />\n  <String Id=\"InstallCancelButton\" Value=\"取消(&amp;C)\" />\n  <String Id=\"OptionsHeader\" Value=\"安装选项\" />\n  <String Id=\"OptionsLocationLabel\" Value=\"安装位置：\" />\n  <String Id=\"OptionsPerUserScopeText\" Value=\"仅为我安装 [WixBundleName](&amp;M)\" />\n  <String Id=\"OptionsPerMachineScopeText\" Value=\"为所有用户安装 [WixBundleName](&amp;U)\" />\n  <String Id=\"OptionsBrowseButton\" Value=\"浏览(&amp;B)\" />\n  <String Id=\"OptionsOkButton\" Value=\"确定(&amp;O)\" />\n  <String Id=\"OptionsCancelButton\" Value=\"取消(&amp;C)\" />\n  <String Id=\"ProgressHeader\" Value=\"安装进度\" />\n  <String Id=\"ProgressLabel\" Value=\"正在处理：\" />\n  <String Id=\"OverallProgressPackageText\" Value=\"正在初始化...\" />\n  <String Id=\"ProgressCancelButton\" Value=\"取消(&amp;C)\" />\n  <String Id=\"ModifyHeader\" Value=\"修改安装\" />\n  <String Id=\"ModifyRepairButton\" Value=\"修复(&amp;R)\" />\n  <String Id=\"ModifyUninstallButton\" Value=\"卸载(&amp;U)\" />\n  <String Id=\"ModifyCancelButton\" Value=\"取消(&amp;C)\" />\n  <String Id=\"SuccessHeader\" Value=\"安装成功\" />\n  <String Id=\"SuccessCacheHeader\" Value=\"缓存完成\" />\n  <String Id=\"SuccessInstallHeader\" Value=\"安装已完成\" />\n  <String Id=\"SuccessLayoutHeader\" Value=\"布局已完成\" />\n  <String Id=\"SuccessModifyHeader\" Value=\"修改已完成\" />\n  <String Id=\"SuccessRepairHeader\" Value=\"修复已完成\" />\n  <String Id=\"SuccessUninstallHeader\" Value=\"卸载已完成\" />\n  <String Id=\"SuccessUnsafeUninstallHeader\" Value=\"卸载已完成\" />\n  <String Id=\"SuccessLaunchButton\" Value=\"启动(&amp;L)\" />\n  <String Id=\"SuccessRestartText\" Value=\"你必须重新启动电脑后才能使用该软件。\" />\n  <String Id=\"SuccessUninstallRestartText\" Value=\"你必须重新启动电脑才能完成软件移除。\" />\n  <String Id=\"SuccessRestartButton\" Value=\"重启(&amp;R)\" />\n  <String Id=\"SuccessCloseButton\" Value=\"关闭(&amp;C)\" />\n  <String Id=\"FailureHeader\" Value=\"安装失败\" />\n  <String Id=\"FailureCacheHeader\" Value=\"缓存失败\" />\n  <String Id=\"FailureInstallHeader\" Value=\"安装失败\" />\n  <String Id=\"FailureLayoutHeader\" Value=\"布局失败\" />\n  <String Id=\"FailureModifyHeader\" Value=\"修改失败\" />\n  <String Id=\"FailureRepairHeader\" Value=\"修复失败\" />\n  <String Id=\"FailureUninstallHeader\" Value=\"卸载失败\" />\n  <String Id=\"FailureUnsafeUninstallHeader\" Value=\"卸载失败\" />\n  <String Id=\"FailureHyperlinkLogText\" Value=\"一个或多个问题导致安装失败。请先修复这些问题后再重试。更多信息请查看&lt;a href=&quot;#&quot;&gt;日志文件&lt;/a&gt;。\" />\n  <String Id=\"FailureRestartText\" Value=\"你必须重新启动电脑才能完成软件回滚。\" />\n  <String Id=\"FailureRestartButton\" Value=\"重启(&amp;R)\" />\n  <String Id=\"FailureCloseButton\" Value=\"关闭(&amp;C)\" />\n  <String Id=\"FilesInUseTitle\" Value=\"文件正在使用中\" />\n  <String Id=\"FilesInUseLabel\" Value=\"以下应用正在使用需要更新的文件：\" />\n  <String Id=\"FilesInUseNetfxCloseRadioButton\" Value=\"关闭这些应用(&amp;A)。\" />\n  <String Id=\"FilesInUseCloseRadioButton\" Value=\"关闭这些应用，并尝试重新启动它们(&amp;A)。\" />\n  <String Id=\"FilesInUseDontCloseRadioButton\" Value=\"不要关闭应用(&amp;D)。需要重新启动电脑。\" />\n  <String Id=\"FilesInUseRetryButton\" Value=\"重试(&amp;R)\" />\n  <String Id=\"FilesInUseIgnoreButton\" Value=\"忽略(&amp;I)\" />\n  <String Id=\"FilesInUseExitButton\" Value=\"退出(&amp;X)\" />\n  <String Id=\"LicenseUrl\" Value=\"https://spin.infinitymomo.com/zh/about/legal\" />\n</WixLocalization>\n"
  },
  {
    "path": "installer/bundle/thm.wxl",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<WixLocalization Culture=\"en-us\" Language=\"1033\" xmlns=\"http://wixtoolset.org/schemas/v4/wxl\">\n  <String Id=\"Caption\" Value=\"[WixBundleName] Setup\" />\n  <String Id=\"Title\" Value=\"[WixBundleName]\" />\n  <String Id=\"CheckingForUpdatesLabel\" Value=\"Checking for updates\" />\n  <String Id=\"UpdateButton\" Value=\"&amp;Update to version [WixStdBAUpdateAvailable]\" />\n  <String Id=\"InstallHeader\" Value=\"Welcome\" />\n  <String Id=\"InstallMessage\" Value=\"Setup will install [WixBundleName] on your computer. Click Install to continue or Cancel to exit.\" />\n  <String Id=\"InstallMessageOptions\" Value=\"Setup will install [WixBundleName] on your computer. Click Install to continue, Options to set installation options, or Cancel to exit.\" />\n  <String Id=\"InstallVersion\" Value=\"Version [WixBundleVersion]\" />\n  <String Id=\"ConfirmCancelMessage\" Value=\"Are you sure you want to cancel?\" />\n  <String Id=\"ExecuteUpgradeRelatedBundleMessage\" Value=\"Previous version\" />\n  <String Id=\"HelpHeader\" Value=\"Setup Help\" />\n  <String Id=\"HelpText\" Value=\"/install | /repair | /uninstall | /layout [directory] - installs, repairs, uninstalls or&#xA;   creates a complete local copy of the bundle in directory. Install is the default.&#xA;&#xA;/passive | /quiet - displays minimal UI with no prompts or displays no UI and&#xA;   no prompts. By default UI and all prompts are displayed.&#xA;&#xA;/norestart - suppress any attempts to restart. By default UI will prompt before restart.&#xA;/log log.txt - logs to a specific file. By default a log file is created in %TEMP%.\" />\n  <String Id=\"HelpCloseButton\" Value=\"&amp;Close\" />\n  <String Id=\"InstallLicenseLinkText\" Value=\"[WixBundleName] &lt;a href=&quot;#&quot;&gt;license terms&lt;/a&gt;.\" />\n  <String Id=\"InstallAcceptCheckbox\" Value=\"I &amp;agree to the license terms and conditions\" />\n  <String Id=\"InstallOptionsButton\" Value=\"&amp;Options\" />\n  <String Id=\"InstallInstallButton\" Value=\"&amp;Install\" />\n  <String Id=\"InstallCancelButton\" Value=\"&amp;Cancel\" />\n  <String Id=\"OptionsHeader\" Value=\"Setup Options\" />\n  <String Id=\"OptionsLocationLabel\" Value=\"Install location:\" />\n  <String Id=\"OptionsPerUserScopeText\" Value=\"Install [WixBundleName] just for &amp;me\" />\n  <String Id=\"OptionsPerMachineScopeText\" Value=\"Install [WixBundleName] for all &amp;users\" />\n  <String Id=\"OptionsBrowseButton\" Value=\"&amp;Browse\" />\n  <String Id=\"OptionsOkButton\" Value=\"&amp;OK\" />\n  <String Id=\"OptionsCancelButton\" Value=\"&amp;Cancel\" />\n  <String Id=\"ProgressHeader\" Value=\"Setup Progress\" />\n  <String Id=\"ProgressLabel\" Value=\"Processing:\" />\n  <String Id=\"OverallProgressPackageText\" Value=\"Initializing...\" />\n  <String Id=\"ProgressCancelButton\" Value=\"&amp;Cancel\" />\n  <String Id=\"ModifyHeader\" Value=\"Modify Setup\" />\n  <String Id=\"ModifyRepairButton\" Value=\"&amp;Repair\" />\n  <String Id=\"ModifyUninstallButton\" Value=\"&amp;Uninstall\" />\n  <String Id=\"ModifyCancelButton\" Value=\"&amp;Cancel\" />\n  <String Id=\"SuccessHeader\" Value=\"Setup Successful\" />\n  <String Id=\"SuccessCacheHeader\" Value=\"Cache Successfully Completed\" />\n  <String Id=\"SuccessInstallHeader\" Value=\"Installation Successfully Completed\" />\n  <String Id=\"SuccessLayoutHeader\" Value=\"Layout Successfully Completed\" />\n  <String Id=\"SuccessModifyHeader\" Value=\"Modify Successfully Completed\" />\n  <String Id=\"SuccessRepairHeader\" Value=\"Repair Successfully Completed\" />\n  <String Id=\"SuccessUninstallHeader\" Value=\"Uninstall Successfully Completed\" />\n  <String Id=\"SuccessUnsafeUninstallHeader\" Value=\"Uninstall Successfully Completed\" />\n  <String Id=\"SuccessLaunchButton\" Value=\"&amp;Launch\" />\n  <String Id=\"SuccessRestartText\" Value=\"You must restart your computer before you can use the software.\" />\n  <String Id=\"SuccessUninstallRestartText\" Value=\"You must restart your computer to complete the removal of the software.\" />\n  <String Id=\"SuccessRestartButton\" Value=\"&amp;Restart\" />\n  <String Id=\"SuccessCloseButton\" Value=\"&amp;Close\" />\n  <String Id=\"FailureHeader\" Value=\"Setup Failed\" />\n  <String Id=\"FailureCacheHeader\" Value=\"Cache Failed\" />\n  <String Id=\"FailureInstallHeader\" Value=\"Setup Failed\" />\n  <String Id=\"FailureLayoutHeader\" Value=\"Layout Failed\" />\n  <String Id=\"FailureModifyHeader\" Value=\"Modify Failed\" />\n  <String Id=\"FailureRepairHeader\" Value=\"Repair Failed\" />\n  <String Id=\"FailureUninstallHeader\" Value=\"Uninstall Failed\" />\n  <String Id=\"FailureUnsafeUninstallHeader\" Value=\"Uninstall Failed\" />\n  <String Id=\"FailureHyperlinkLogText\" Value=\"One or more issues caused the setup to fail. Please fix the issues and then retry setup. For more information see the &lt;a href=&quot;#&quot;&gt;log file&lt;/a&gt;.\" />\n  <String Id=\"FailureRestartText\" Value=\"You must restart your computer to complete the rollback of the software.\" />\n  <String Id=\"FailureRestartButton\" Value=\"&amp;Restart\" />\n  <String Id=\"FailureCloseButton\" Value=\"&amp;Close\" />\n  <String Id=\"FilesInUseTitle\" Value=\"Files In Use\" />\n  <String Id=\"FilesInUseLabel\" Value=\"The following applications are using files that need to be updated:\" />\n  <String Id=\"FilesInUseNetfxCloseRadioButton\" Value=\"Close the &amp;applications.\" />\n  <String Id=\"FilesInUseCloseRadioButton\" Value=\"Close the &amp;applications and attempt to restart them.\" />\n  <String Id=\"FilesInUseDontCloseRadioButton\" Value=\"&amp;Do not close applications. A reboot will be required.\" />\n  <String Id=\"FilesInUseRetryButton\" Value=\"&amp;Retry\" />\n  <String Id=\"FilesInUseIgnoreButton\" Value=\"&amp;Ignore\" />\n  <String Id=\"FilesInUseExitButton\" Value=\"E&amp;xit\" />\n  <String Id=\"LicenseUrl\" Value=\"https://spin.infinitymomo.com/en/about/legal\" />\n</WixLocalization>\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"spinning-momo\",\n  \"private\": true,\n  \"license\": \"GPL-3.0\",\n  \"scripts\": {\n    \"prepare\": \"husky\",\n    \"build\": \"npm run build:cpp && npm run build:web && npm run build:dist\",\n    \"build:cpp\": \"xmake config -m release && xmake build && xmake config -m debug\",\n    \"build:cpp:ci\": \"xmake config -m release -y && xmake build -y\",\n    \"build:ci\": \"npm run build:cpp:ci && npm run build:web && npm run build:dist\",\n    \"build:web\": \"cd web && npm run build\",\n    \"build:dist\": \"node scripts/prepare-dist.js\",\n    \"build:portable\": \"node scripts/build-portable.js\",\n    \"build:installer\": \"powershell -ExecutionPolicy Bypass -File scripts/build-msi.ps1\",\n    \"build:checksums\": \"node scripts/generate-checksums.js\",\n    \"release\": \"npm run build && npm run build:portable && npm run build:installer && npm run build:checksums\",\n    \"release:version\": \"node scripts/release-version.js\",\n    \"format:cpp\": \"node scripts/format-cpp.js\",\n    \"format:web\": \"cd web && prettier --write .\"\n  },\n  \"devDependencies\": {\n    \"esbuild\": \"^0.25.12\",\n    \"fast-glob\": \"^3.3.2\",\n    \"husky\": \"^9.1.7\",\n    \"lint-staged\": \"^16.2.4\"\n  },\n  \"lint-staged\": {\n    \"src/**/*.{cpp,ixx,h,hpp}\": [\n      \"node scripts/format-cpp.js --files\"\n    ],\n    \"web/**/*.{js,ts,vue,json,css,md}\": [\n      \"node scripts/format-web.js\"\n    ]\n  }\n}\n"
  },
  {
    "path": "resources/app.manifest",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\r\n<assembly xmlns=\"urn:schemas-microsoft-com:asm.v1\" manifestVersion=\"1.0\">\r\n  <!-- 动态请求管理员权限 -->\r\n  <trustInfo xmlns=\"urn:schemas-microsoft-com:asm.v3\">\r\n    <security>\r\n      <requestedPrivileges>\r\n        <requestedExecutionLevel level=\"asInvoker\" uiAccess=\"false\" />\r\n      </requestedPrivileges>\r\n    </security>\r\n  </trustInfo>\r\n\r\n  <!-- 兼容性设置 - 仅支持Windows 10和Windows 11 -->\r\n  <compatibility xmlns=\"urn:schemas-microsoft-com:compatibility.v1\">\r\n    <application>\r\n      <!-- Windows 10 and Windows 11 -->\r\n      <supportedOS Id=\"{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}\"/>\r\n    </application>\r\n  </compatibility>\r\n\r\n  <!-- DPI感知设置 - 使用最新的PerMonitorV2设置 -->\r\n  <application xmlns=\"urn:schemas-microsoft-com:asm.v3\">\r\n    <windowsSettings>\r\n      <dpiAware xmlns=\"http://schemas.microsoft.com/SMI/2005/WindowsSettings\">true/PM</dpiAware>\r\n      <dpiAwareness xmlns=\"http://schemas.microsoft.com/SMI/2016/WindowsSettings\">PerMonitorV2</dpiAwareness>\r\n      <!-- 长路径支持 -->\r\n      <longPathAware xmlns=\"http://schemas.microsoft.com/SMI/2016/WindowsSettings\">true</longPathAware>\r\n      <!-- 启用Windows 11新特性支持 -->\r\n      <activeCodePage xmlns=\"http://schemas.microsoft.com/SMI/2019/WindowsSettings\">UTF-8</activeCodePage>\r\n      <!-- 启用SegmentHeap内存优化 -->\r\n      <heapType xmlns=\"http://schemas.microsoft.com/SMI/2020/WindowsSettings\">SegmentHeap</heapType>\r\n    </windowsSettings>\r\n  </application>\r\n\r\n  <!-- 启用视觉样式 -->\r\n  <dependency>\r\n    <dependentAssembly>\r\n      <assemblyIdentity\r\n          type=\"win32\"\r\n          name=\"Microsoft.Windows.Common-Controls\"\r\n          version=\"6.0.0.0\"\r\n          processorArchitecture=\"*\"\r\n          publicKeyToken=\"6595b64144ccf1df\"\r\n          language=\"*\"\r\n      />\r\n    </dependentAssembly>\r\n  </dependency>\r\n</assembly>\r\n"
  },
  {
    "path": "resources/app.rc",
    "content": "#include <windows.h>\n\n#define APP_VERSION_NUM 2, 0, 8, 0\n#define APP_VERSION_STR \"2.0.8.0\"\n#define PRODUCT_NAME \"SpinningMomo\"\n#define FILE_DESCRIPTION \"SpinningMomo\"\n#define COPYRIGHT_INFO \"Copyright (c) 2024-2026 InfinityMomo\"\n#define AUTHOR_NAME \"InfinityMomo\"\n#define INTERNAL_NAME \"SpinningMomo\"\n#define ORIGINAL_FILENAME \"SpinningMomo.exe\"\n\n#define IDI_ICON1 101\nIDI_ICON1 ICON \"icon.ico\"\r\n\r\n// 版本信息资源\r\nVS_VERSION_INFO VERSIONINFO\nFILEVERSION     APP_VERSION_NUM\nPRODUCTVERSION  APP_VERSION_NUM\nFILEFLAGSMASK   VS_FFI_FILEFLAGSMASK\r\n#ifdef _DEBUG\r\nFILEFLAGS       VS_FF_DEBUG\r\n#else\r\nFILEFLAGS       0\r\n#endif\r\nFILEOS          VOS_NT_WINDOWS32\r\nFILETYPE        VFT_APP\r\nFILESUBTYPE     VFT2_UNKNOWN\r\nBEGIN\r\n    BLOCK \"StringFileInfo\"\r\n    BEGIN\r\n        BLOCK \"040904E4\" // English (United States)\r\n        BEGIN\r\n            VALUE \"FileDescription\",  FILE_DESCRIPTION\n            VALUE \"FileVersion\",      APP_VERSION_STR\n            VALUE \"InternalName\",     INTERNAL_NAME\n            VALUE \"LegalCopyright\",   COPYRIGHT_INFO\r\n            VALUE \"OriginalFilename\", ORIGINAL_FILENAME\r\n            VALUE \"ProductName\",      PRODUCT_NAME\r\n            VALUE \"ProductVersion\",   APP_VERSION_STR\n            VALUE \"Author\",           AUTHOR_NAME\r\n        END\r\n        BLOCK \"080404B0\" // Chinese (Simplified, PRC)\r\n        BEGIN\r\n            VALUE \"FileDescription\",  FILE_DESCRIPTION\n            VALUE \"FileVersion\",      APP_VERSION_STR\n            VALUE \"InternalName\",     INTERNAL_NAME\n            VALUE \"LegalCopyright\",   COPYRIGHT_INFO\r\n            VALUE \"OriginalFilename\", ORIGINAL_FILENAME\r\n            VALUE \"ProductName\",      PRODUCT_NAME\r\n            VALUE \"ProductVersion\",   APP_VERSION_STR\n            VALUE \"Author\",           AUTHOR_NAME\r\n        END\r\n    END\r\n    BLOCK \"VarFileInfo\"\r\n    BEGIN\r\n        VALUE \"Translation\", 0x409, 1252, 0x804, 1200 // English (United States), Chinese (Simplified, PRC)\r\n    END\r\nEND\n"
  },
  {
    "path": "scripts/build-msi.ps1",
    "content": "# Build MSI and Bundle installer locally\n# Prerequisites: \n#   - .NET SDK 8.0+\n#   - WiX v5+: dotnet tool install --global wix --version 5.*\n#   - WiX extensions:\n#       wix extension add WixToolset.UI.wixext\n#       wix extension add WixToolset.Util.wixext\n#       wix extension add WixToolset.BootstrapperApplications.wixext\n\nparam(\n    [string]$Version = \"\",\n    [switch]$MsiOnly  # Only build MSI, skip Bundle\n)\n\n$ErrorActionPreference = \"Stop\"\n$ProjectDir = Split-Path -Parent $PSScriptRoot\nSet-Location $ProjectDir\n\n# Extract version from version.json if not provided\nif ([string]::IsNullOrEmpty($Version)) {\n    $versionInfo = Get-Content \"version.json\" -Raw | ConvertFrom-Json\n    if ($null -eq $versionInfo.version -or $versionInfo.version -notmatch '^\\d+\\.\\d+\\.\\d+$') {\n        Write-Error \"Could not extract version from version.json\"\n        exit 1\n    }\n    $Version = $versionInfo.version\n}\n\nWrite-Host \"Building SpinningMomo v$Version MSI...\" -ForegroundColor Cyan\n\n# Check if WiX is installed\nif (-not (Get-Command wix -ErrorAction SilentlyContinue)) {\n    Write-Error \"WiX v5+ not found. Install with: dotnet tool install --global wix --version 5.*\"\n    exit 1\n}\n\n# Build project if dist doesn't exist or is outdated\nif (-not (Test-Path \"dist/SpinningMomo.exe\")) {\n    Write-Host \"Building project...\" -ForegroundColor Yellow\n    npm run build\n    if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }\n}\n\n# Build MSI (WiX v5+ uses Files element - no need for heat harvesting)\nWrite-Host \"Building MSI...\" -ForegroundColor Yellow\n$distDir = Join-Path $ProjectDir \"dist\"\n$outputMsi = Join-Path $distDir \"SpinningMomo-$Version-x64.msi\"\n\nwix build `\n    -arch x64 `\n    -d ProductVersion=$Version `\n    -d ProjectDir=$ProjectDir `\n    -d DistDir=$distDir `\n    -ext WixToolset.UI.wixext `\n    -ext WixToolset.Util.wixext `\n    -culture en-US `\n    -loc installer/Package.en-us.wxl `\n    -out $outputMsi `\n    installer/Package.wxs\n\nif ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }\n\nWrite-Host \"Success! Created: $outputMsi\" -ForegroundColor Green\n\nif ($MsiOnly) {\n    exit 0\n}\n\n# Build Bundle (.exe with modern UI)\nWrite-Host \"`nBuilding Bundle installer...\" -ForegroundColor Yellow\n$outputExe = Join-Path $distDir \"SpinningMomo-$Version-x64-Setup.exe\"\n\nwix build `\n    -arch x64 `\n    -d ProductVersion=$Version `\n    -d ProjectDir=$ProjectDir `\n    -d MsiPath=$outputMsi `\n    -ext WixToolset.BootstrapperApplications.wixext `\n    -culture en-US `\n    -out $outputExe `\n    installer/Bundle.wxs\n\nif ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }\n\nWrite-Host \"`nSuccess! Created:\" -ForegroundColor Green\nWrite-Host \"  MSI:    $outputMsi\" -ForegroundColor Green\nWrite-Host \"  Bundle: $outputExe\" -ForegroundColor Green\n"
  },
  {
    "path": "scripts/build-portable.js",
    "content": "const path = require(\"path\");\nconst fs = require(\"fs\");\nconst { execSync } = require(\"child_process\");\n\nfunction getVersion() {\n  const versionFile = fs.readFileSync(path.join(__dirname, \"..\", \"version.json\"), \"utf8\");\n  const versionInfo = JSON.parse(versionFile);\n  if (typeof versionInfo.version !== \"string\" || !/^\\d+\\.\\d+\\.\\d+$/.test(versionInfo.version)) {\n    throw new Error(\"Could not extract version from version.json\");\n  }\n  return versionInfo.version;\n}\n\nfunction main() {\n  const projectDir = path.join(__dirname, \"..\");\n  const distDir = path.join(projectDir, \"dist\");\n\n  // Verify dist directory exists with required files\n  const exePath = path.join(distDir, \"SpinningMomo.exe\");\n  const resourcesDir = path.join(distDir, \"resources\");\n  const legalPath = path.join(distDir, \"LEGAL.md\");\n  const licensePath = path.join(distDir, \"LICENSE\");\n\n  if (!fs.existsSync(exePath)) {\n    console.error(\"dist/SpinningMomo.exe not found. Run 'npm run build' first.\");\n    process.exit(1);\n  }\n\n  if (!fs.existsSync(resourcesDir)) {\n    console.error(\"dist/resources not found. Run 'npm run build' first.\");\n    process.exit(1);\n  }\n  if (!fs.existsSync(legalPath)) {\n    console.error(\"dist/LEGAL.md not found. Run 'npm run build' first.\");\n    process.exit(1);\n  }\n  if (!fs.existsSync(licensePath)) {\n    console.error(\"dist/LICENSE not found. Run 'npm run build' first.\");\n    process.exit(1);\n  }\n\n  const version = getVersion();\n  const zipName = `SpinningMomo-${version}-x64-Portable.zip`;\n  const zipPath = path.join(distDir, zipName);\n\n  console.log(`Creating portable package: ${zipName}`);\n\n  // Create portable marker file\n  const portableMarker = path.join(distDir, \"portable\");\n  fs.writeFileSync(portableMarker, \"\");\n\n  // Remove existing ZIP if present\n  if (fs.existsSync(zipPath)) {\n    fs.unlinkSync(zipPath);\n  }\n\n  // Create ZIP using PowerShell (Windows native, no extra dependencies)\n  const filesToZip = [\"SpinningMomo.exe\", \"resources\", \"LEGAL.md\", \"LICENSE\", \"portable\"]\n    .map((f) => `\"${path.join(distDir, f)}\"`)\n    .join(\", \");\n\n  execSync(\n    `powershell -Command \"Compress-Archive -Path ${filesToZip} -DestinationPath '${zipPath}' -Force\"`,\n    { stdio: \"inherit\" }\n  );\n\n  // Clean up portable marker (only needed inside ZIP)\n  fs.unlinkSync(portableMarker);\n\n  console.log(`Done! Created: ${zipPath}`);\n}\n\nmain();\n"
  },
  {
    "path": "scripts/fetch-third-party.ps1",
    "content": "$ErrorActionPreference = \"Stop\"\n\nSet-Location (Split-Path -Parent $PSScriptRoot)\n\nNew-Item -ItemType Directory -Force \"third_party\" | Out-Null\n\n$dkmHeader = \"third_party/dkm/include/dkm.hpp\"\nif (Test-Path $dkmHeader) {\n    Write-Host \"DKM already exists, skip.\"\n} else {\n    Write-Host \"Cloning DKM...\"\n    git clone --depth 1 https://github.com/genbattle/dkm.git third_party/dkm\n}\n"
  },
  {
    "path": "scripts/format-cpp.js",
    "content": "const { execSync, spawnSync } = require(\"child_process\");\nconst fs = require(\"fs\");\nconst fg = require(\"fast-glob\");\n\n// 常见的 clang-format 路径（VS2022）\nconst KNOWN_PATHS = [\n  \"C:\\\\Program Files\\\\Microsoft Visual Studio\\\\2022\\\\Community\\\\VC\\\\Tools\\\\Llvm\\\\x64\\\\bin\\\\clang-format.exe\",\n  \"C:\\\\Program Files\\\\Microsoft Visual Studio\\\\2022\\\\Professional\\\\VC\\\\Tools\\\\Llvm\\\\x64\\\\bin\\\\clang-format.exe\",\n  \"C:\\\\Program Files\\\\Microsoft Visual Studio\\\\2022\\\\Enterprise\\\\VC\\\\Tools\\\\Llvm\\\\x64\\\\bin\\\\clang-format.exe\",\n  \"C:\\\\Program Files (x86)\\\\Microsoft Visual Studio\\\\2022\\\\BuildTools\\\\VC\\\\Tools\\\\Llvm\\\\x64\\\\bin\\\\clang-format.exe\",\n];\n\nfunction findClangFormat() {\n  // 1. 先检查 PATH\n  try {\n    const result = execSync(\"where clang-format\", {\n      encoding: \"utf-8\",\n      stdio: [\"pipe\", \"pipe\", \"pipe\"],\n    });\n    const firstPath = result.trim().split(/\\r?\\n/)[0];\n    if (firstPath && fs.existsSync(firstPath)) {\n      return firstPath;\n    }\n  } catch {\n    // where 命令失败，继续检查已知路径\n  }\n\n  // 2. 检查已知路径\n  for (const p of KNOWN_PATHS) {\n    if (fs.existsSync(p)) {\n      return p;\n    }\n  }\n\n  return null;\n}\n\nfunction main() {\n  const clangFormat = findClangFormat();\n\n  if (!clangFormat) {\n    console.log(\"⚠ clang-format not found, skipping.\");\n    process.exit(0);\n  }\n\n  // 获取要格式化的文件\n  const args = process.argv.slice(2);\n\n  // 检查是否是 --files 模式（lint-staged 传递文件列表）\n  let files;\n  if (args[0] === \"--files\") {\n    files = args.slice(1);\n  } else if (args.length > 0) {\n    files = args;\n  } else {\n    // 默认模式：在 Node 里自己展开 glob，避免 Windows shell 不展开通配符的问题\n    files = fg.sync([\"src/**/*.cpp\", \"src/**/*.ixx\", \"src/**/*.h\", \"src/**/*.hpp\"], {\n      dot: false,\n      onlyFiles: true,\n      unique: true,\n    });\n  }\n\n  if (!files || files.length === 0) {\n    process.exit(0);\n  }\n\n  const result = spawnSync(clangFormat, [\"-i\", ...files], {\n    stdio: \"inherit\",\n    shell: false,\n  });\n\n  process.exit(result.status || 0);\n}\n\nmain();\n"
  },
  {
    "path": "scripts/format-web.js",
    "content": "const { spawnSync } = require(\"child_process\");\nconst path = require(\"path\");\n\nfunction main() {\n  const args = process.argv.slice(2);\n  \n  if (args.length === 0) {\n    console.log(\"No files to format\");\n    process.exit(0);\n  }\n\n  // 将绝对路径转换为相对于 web 目录的路径\n  const webDir = path.join(__dirname, \"..\", \"web\");\n  const relativeFiles = args.map((file) => path.relative(webDir, file));\n\n  // 使用 web 目录下的 prettier\n  const prettierPath = path.join(webDir, \"node_modules\", \".bin\", \"prettier.cmd\");\n\n  const result = spawnSync(prettierPath, [\"--write\", ...relativeFiles], {\n    cwd: webDir,\n    stdio: \"inherit\",\n    shell: true,\n  });\n\n  process.exit(result.status || 0);\n}\n\nmain();\n"
  },
  {
    "path": "scripts/generate-checksums.js",
    "content": "const path = require(\"path\");\nconst fs = require(\"fs\");\nconst crypto = require(\"crypto\");\n\nfunction calculateSHA256(filePath) {\n  const fileBuffer = fs.readFileSync(filePath);\n  const hashSum = crypto.createHash(\"sha256\");\n  hashSum.update(fileBuffer);\n  return hashSum.digest(\"hex\");\n}\n\nfunction main() {\n  const distDir = path.join(__dirname, \"..\", \"dist\");\n\n  // Find release files (Setup.exe and Portable.zip)\n  const files = fs.readdirSync(distDir).filter((f) => {\n    return f.endsWith(\"-Setup.exe\") || f.endsWith(\"-Portable.zip\");\n  });\n\n  if (files.length === 0) {\n    console.error(\"No release files found in dist/\");\n    console.error(\"Run 'npm run build:portable' and 'npm run build:installer' first.\");\n    process.exit(1);\n  }\n\n  console.log(\"Generating SHA256 checksums...\");\n\n  const checksums = files.map((file) => {\n    const filePath = path.join(distDir, file);\n    const hash = calculateSHA256(filePath);\n    console.log(`  ${hash}  ${file}`);\n    return `${hash}  ${file}`;\n  });\n\n  const outputPath = path.join(distDir, \"SHA256SUMS.txt\");\n  fs.writeFileSync(outputPath, checksums.join(\"\\n\") + \"\\n\", \"utf8\");\n\n  console.log(`\\nDone! Created: ${outputPath}`);\n}\n\nmain();\n"
  },
  {
    "path": "scripts/generate-embedded-locales.js",
    "content": "#!/usr/bin/env node\n\n// 本地化模块生成脚本\n// 将 locales 目录下的 JSON 文件转换为 C++ 模块文件\n\nconst fs = require(\"fs\");\nconst path = require(\"path\");\n\n// 项目根目录\nconst projectRoot = path.resolve(__dirname, \"..\");\n\n// 输入和输出目录\nconst localesDir = path.join(projectRoot, \"src\", \"locales\");\nconst embeddedDir = path.join(projectRoot, \"src\", \"core\", \"i18n\", \"embedded\");\n\n// 语言映射配置\nconst languageMappings = {\n  \"en-US\": {\n    moduleName: \"Core.I18n.Embedded.EnUS\",\n    variableName: \"en_us_json\",\n    comment: \"English\",\n  },\n  \"zh-CN\": {\n    moduleName: \"Core.I18n.Embedded.ZhCN\",\n    variableName: \"zh_cn_json\",\n    comment: \"Chinese\",\n  },\n};\n\n// 将语言代码转换为文件名格式 (如 zh-CN -> zh_cn)\nfunction toFileNameFormat(langCode) {\n  return langCode.toLowerCase().replace(/-/g, \"_\");\n}\n\n// 将语言代码转换为模块名格式 (如 en-US -> EnUS)\nfunction toModuleNameFormat(langCode) {\n  return langCode\n    .split(\"-\")\n    .map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())\n    .join(\"\");\n}\n\n// 生成 C++ 模块文件内容\nfunction generateCppModule(\n  sourceFile,\n  jsonContent,\n  moduleName,\n  variableName,\n  languageComment\n) {\n  const fileSize = Buffer.byteLength(jsonContent, \"utf8\");\n\n  // 转义 JSON 内容中的特殊字符\n  const escapedJson = jsonContent.replace(/\\\\/g, \"\\\\\\\\\").replace(/\"/g, '\\\\\"');\n\n  return `// Auto-generated embedded ${languageComment} locale module\n// DO NOT EDIT - This file contains embedded locale data\n//\n// Source: ${sourceFile}\n// Module: ${moduleName}\n// Variable: ${variableName}\n\nmodule;\n\nexport module ${moduleName};\n\nimport std;\n\nexport namespace EmbeddedLocales {\n    // Embedded ${languageComment} JSON content as string_view\n    // Size: ${fileSize} bytes\n    constexpr std::string_view ${variableName} = R\"EmbeddedJson(${jsonContent})EmbeddedJson\";\n}\n`;\n}\n\n// 处理单个语言文件\nfunction processLanguageFile(fileName) {\n  // 获取语言代码 (去除 .json 扩展名)\n  const langCode = path.basename(fileName, \".json\");\n\n  // 构建文件路径\n  const inputPath = path.join(localesDir, fileName);\n  const outputPath = path.join(\n    embeddedDir,\n    `${toFileNameFormat(langCode)}.ixx`\n  );\n\n  // 读取 JSON 文件内容\n  const jsonContent = fs.readFileSync(inputPath, \"utf8\");\n\n  // 获取相对于项目根目录的路径用于注释\n  const relativePath = path\n    .relative(projectRoot, inputPath)\n    .replace(/\\\\/g, \"/\");\n\n  // 获取映射配置或使用默认配置\n  const mapping = languageMappings[langCode] || {\n    moduleName: `Core.I18n.Embedded.${toModuleNameFormat(langCode)}`,\n    variableName: `${toFileNameFormat(langCode)}_json`,\n    comment: langCode,\n  };\n\n  // 生成 C++ 模块内容\n  const cppContent = generateCppModule(\n    relativePath,\n    jsonContent,\n    mapping.moduleName,\n    mapping.variableName,\n    mapping.comment\n  );\n\n  // 写入输出文件\n  fs.writeFileSync(outputPath, cppContent);\n\n  console.log(\n    `Generated embedded locale: ${fileName} -> ${path.basename(\n      outputPath\n    )} (${Buffer.byteLength(jsonContent, \"utf8\")} bytes)`\n  );\n}\n\n// 主函数\nfunction main() {\n  console.log(\"Generating embedded locale modules...\");\n\n  // 确保输出目录存在\n  if (!fs.existsSync(embeddedDir)) {\n    fs.mkdirSync(embeddedDir, { recursive: true });\n  }\n\n  // 读取 locales 目录下的所有文件\n  const files = fs.readdirSync(localesDir);\n\n  // 过滤出 JSON 文件\n  const jsonFiles = files.filter((file) => path.extname(file) === \".json\");\n\n  // 处理每个 JSON 文件\n  jsonFiles.forEach(processLanguageFile);\n\n  console.log(\"Successfully generated all embedded locale modules\");\n}\n\n// 执行主函数\nmain();\n"
  },
  {
    "path": "scripts/generate-map-injection-cpp.js",
    "content": "const fs = require(\"fs\");\nconst path = require(\"path\");\nconst { pathToFileURL } = require(\"url\");\nconst esbuild = require(\"esbuild\");\n\nfunction splitIntoChunks(content, chunkSize) {\n  const chunks = [];\n  for (let i = 0; i < content.length; i += chunkSize) {\n    chunks.push(content.slice(i, i + chunkSize));\n  }\n  return chunks;\n}\n\nfunction toCppModule(scriptContent) {\n  const rawDelimiter = \"SM_MAP_JS\";\n  const chunkSize = 4 * 1024;\n  const scriptChunks = splitIntoChunks(scriptContent, chunkSize);\n  const literalExpression = scriptChunks\n    .map((chunk) => `LR\"${rawDelimiter}(${chunk})${rawDelimiter}\"`)\n    .join(\"\\n    \");\n\n  return `// Auto-generated map injection script module\n// DO NOT EDIT - This file is generated by scripts/generate-map-injection-cpp.js\n\nmodule;\n\nexport module Extensions.InfinityNikki.Generated.MapInjectionScript;\n\nimport std;\n\nexport namespace Extensions::InfinityNikki::Generated {\n\nconstexpr std::wstring_view map_bridge_script =\n    ${literalExpression};\n\n}  // namespace Extensions::InfinityNikki::Generated\n`;\n}\n\nasync function main() {\n  const projectRoot = path.resolve(__dirname, \"..\");\n  const sourceEntry = path.join(\n    projectRoot,\n    \"web\",\n    \"src\",\n    \"features\",\n    \"map\",\n    \"injection\",\n    \"source\",\n    \"index.js\"\n  );\n  const generatedModulePath = path.join(\n    projectRoot,\n    \"src\",\n    \"extensions\",\n    \"infinity_nikki\",\n    \"generated\",\n    \"map_injection_script.ixx\"\n  );\n\n  if (!fs.existsSync(sourceEntry)) {\n    throw new Error(`Map injection source entry not found: ${sourceEntry}`);\n  }\n\n  const sourceModule = await import(pathToFileURL(sourceEntry).href);\n  if (typeof sourceModule.buildMapBridgeScriptTemplate !== \"function\") {\n    throw new Error(\"buildMapBridgeScriptTemplate() is missing in injection source entry.\");\n  }\n\n  const bridgeTemplate = sourceModule.buildMapBridgeScriptTemplate();\n  const minified = esbuild.transformSync(bridgeTemplate, {\n    loader: \"js\",\n    minify: true,\n  });\n\n  const scriptContent = minified.code.trim();\n  fs.mkdirSync(path.dirname(generatedModulePath), { recursive: true });\n  fs.writeFileSync(generatedModulePath, toCppModule(scriptContent), \"utf8\");\n\n  console.log(\n    `Generated C++ map injection module: ${path.relative(projectRoot, generatedModulePath)}`\n  );\n}\n\nmain().catch((error) => {\n  const message = error instanceof Error ? error.message : String(error);\n  console.error(`Error: ${message}`);\n  process.exit(1);\n});\n"
  },
  {
    "path": "scripts/generate-migrations.js",
    "content": "#!/usr/bin/env node\n\n// SQL 迁移脚本生成器\n// 将 src/migrations 目录下的 SQL 文件转换为 C++ 模块文件\n//\n// 用法:\n//     node scripts/generate-migrations.js\n//\n// 功能:\n//     - 读取 src/migrations/*.sql 文件\n//     - 解析 SQL 文件中的版本号和描述（从注释中读取）\n//     - 按分号分割 SQL 语句\n//     - 生成 C++ 模块文件到 src/core/upgrade/generated/\n\nconst fs = require(\"fs\");\nconst path = require(\"path\");\n\n// ============================================================================\n// 配置\n// ============================================================================\n\n// 项目根目录\nconst projectRoot = path.resolve(__dirname, \"..\");\n\n// 输入和输出目录\nconst migrationsDir = path.join(projectRoot, \"src\", \"migrations\");\nconst generatedDir = path.join(\n  projectRoot,\n  \"src\",\n  \"core\",\n  \"migration\",\n  \"generated\"\n);\n\n// ============================================================================\n// SQL 解析\n// ============================================================================\n\n/**\n * 从 SQL 文件名中提取版本号\n *\n * 格式: 001_description.sql -> version: 1\n *\n * @param {string} fileName - SQL 文件名\n * @returns {number|null} 版本号\n */\nfunction extractVersionFromFilename(fileName) {\n  const match = fileName.match(/^(\\d+)_/);\n  return match ? parseInt(match[1], 10) : null;\n}\n\n/**\n * 将 SQL 文件内容分割成独立的语句\n *\n * 规则:\n * - 按分号分割\n * - 忽略空语句\n * - 保留触发器等复杂语句（BEGIN...END 内的分号不分割）\n *\n * @param {string} sqlContent - SQL 文件内容\n * @returns {string[]} SQL 语句数组\n */\nfunction splitSqlStatements(sqlContent) {\n  const statements = [];\n  const currentStatement = [];\n  let inBeginEnd = false;\n\n  const lines = sqlContent.split(\"\\n\");\n\n  for (const line of lines) {\n    const stripped = line.trim();\n\n    // 跳过纯注释行和空行\n    if (!stripped || stripped.startsWith(\"--\")) {\n      continue;\n    }\n\n    // 检测 BEGIN...END 块\n    if (/\\bBEGIN\\b/i.test(stripped)) {\n      inBeginEnd = true;\n    }\n\n    currentStatement.push(line);\n\n    // 在非 BEGIN...END 块中遇到分号，表示语句结束\n    if (line.includes(\";\") && !inBeginEnd) {\n      // 提取分号之前的内容作为完整语句\n      const stmt = currentStatement\n        .join(\"\\n\")\n        .trim()\n        .replace(/;+\\s*$/, \"\")\n        .trim();\n      if (stmt) {\n        statements.push(stmt);\n      }\n      currentStatement.length = 0; // 清空数组\n    }\n\n    // 检测 END 块结束\n    if (/\\bEND\\b/i.test(stripped)) {\n      inBeginEnd = false;\n      // END 语句后通常有分号，作为完整语句\n      if (line.includes(\";\")) {\n        const stmt = currentStatement\n          .join(\"\\n\")\n          .trim()\n          .replace(/;+\\s*$/, \"\")\n          .trim();\n        if (stmt) {\n          statements.push(stmt);\n        }\n        currentStatement.length = 0;\n      }\n    }\n  }\n\n  // 处理最后可能剩余的语句\n  if (currentStatement.length > 0) {\n    const stmt = currentStatement\n      .join(\"\\n\")\n      .trim()\n      .replace(/;+\\s*$/, \"\")\n      .trim();\n    if (stmt) {\n      statements.push(stmt);\n    }\n  }\n\n  return statements;\n}\n\n// ============================================================================\n// C++ 代码生成\n// ============================================================================\n\n/**\n * 生成 C++ 模块文件内容\n *\n * @param {string} migrationFile - 迁移文件名\n * @param {number} version - 版本号\n * @param {string[]} sqlStatements - SQL 语句数组\n * @returns {string} C++ 模块文件内容\n */\nfunction generateCppModule(migrationFile, version, sqlStatements) {\n  // 模块名称格式: V001, V002, ...\n  const moduleSuffix = `V${String(version).padStart(3, \"0\")}`;\n  const moduleName = `Core.Migration.Schema.${moduleSuffix}`;\n  const structName = moduleSuffix; // 结构体名称，如 V001\n\n  // 生成语句数组\n  const statementsCode = sqlStatements.map((stmt) => {\n    // 使用自定义分隔符\n    let delimiter = \"SQL\";\n    // 确保 SQL 中不包含 )SQL\" 这样的序列\n    while (stmt.includes(`)${delimiter}\"`)) {\n      delimiter += \"X\";\n    }\n\n    return `      R\"${delimiter}(\n${stmt}\n        )${delimiter}\"`;\n  });\n\n  const statementsArray = statementsCode.join(\",\\n\");\n\n  return `// Auto-generated SQL schema module\n// DO NOT EDIT - This file is generated from src/migrations/${migrationFile}\n\nmodule;\n\nexport module ${moduleName};\n\nimport std;\n\nexport namespace Core::Migration::Schema {\n\nstruct ${structName} {\n  static constexpr std::array<std::string_view, ${sqlStatements.length}> statements = {\n${statementsArray}\n  };\n};\n\n}  // namespace Core::Migration::Schema\n`;\n}\n\n// ============================================================================\n// 文件处理\n// ============================================================================\n\n/**\n * 处理单个 SQL 迁移文件\n *\n * @param {string} sqlFile - SQL 文件路径\n * @returns {boolean} 成功返回 true\n */\nfunction processMigrationFile(sqlFile) {\n  const fileName = path.basename(sqlFile);\n  console.log(`Processing: ${fileName}`);\n\n  try {\n    // 从文件名提取版本号\n    const version = extractVersionFromFilename(fileName);\n\n    if (version === null) {\n      console.log(\n        `  ⚠️  Warning: Cannot extract version from filename ${fileName}, skipping`\n      );\n      return false;\n    }\n\n    // 读取 SQL 文件\n    const sqlContent = fs.readFileSync(sqlFile, \"utf8\");\n\n    // 分割 SQL 语句\n    const statements = splitSqlStatements(sqlContent);\n\n    if (statements.length === 0) {\n      console.log(`  ⚠️  Warning: No SQL statements found in ${fileName}`);\n      return false;\n    }\n\n    console.log(`  Version: ${version}`);\n    console.log(`  Statements: ${statements.length}`);\n\n    // 生成 C++ 代码\n    const cppContent = generateCppModule(fileName, version, statements);\n\n    // 输出文件路径\n    const outputFile = path.join(\n      generatedDir,\n      `schema_${String(version).padStart(3, \"0\")}.ixx`\n    );\n    fs.writeFileSync(outputFile, cppContent, \"utf8\");\n\n    const relativePath = path\n      .relative(projectRoot, outputFile)\n      .replace(/\\\\/g, \"/\");\n    console.log(`  ✓ Generated: ${relativePath}`);\n    return true;\n  } catch (error) {\n    console.log(`  ✗ Error processing ${fileName}: ${error.message}`);\n    console.error(error.stack);\n    return false;\n  }\n}\n\n/**\n * 生成索引模块，导出所有 Schema\n *\n * @param {number[]} processedVersions - 已处理的版本号数组\n */\nfunction generateIndexModule(processedVersions) {\n  if (processedVersions.length === 0) {\n    return;\n  }\n\n  processedVersions.sort((a, b) => a - b);\n\n  // 生成 import 语句\n  const imports = processedVersions.map((ver) => {\n    return `export import Core.Migration.Schema.V${String(ver).padStart(\n      3,\n      \"0\"\n    )};`;\n  });\n\n  const importsCode = imports.join(\"\\n\");\n\n  const indexContent = `// Auto-generated schema index\n// DO NOT EDIT - This file imports all generated schema modules\n\nmodule;\n\nexport module Core.Migration.Schema;\n\n// Import all schema modules\n${importsCode}\n`;\n\n  const indexFile = path.join(generatedDir, \"schema.ixx\");\n  fs.writeFileSync(indexFile, indexContent, \"utf8\");\n\n  const relativePath = path\n    .relative(projectRoot, indexFile)\n    .replace(/\\\\/g, \"/\");\n  console.log(`\\n✓ Generated index: ${relativePath}`);\n}\n\n// ============================================================================\n// 主函数\n// ============================================================================\n\n/**\n * 主函数\n */\nfunction main() {\n  console.log(\"=\".repeat(70));\n  console.log(\"SQL Migration Generator\");\n  console.log(\"=\".repeat(70));\n  console.log();\n\n  // 检查输入目录\n  if (!fs.existsSync(migrationsDir)) {\n    console.log(`✗ Error: Migrations directory not found: ${migrationsDir}`);\n    process.exit(1);\n  }\n\n  // 创建输出目录\n  if (!fs.existsSync(generatedDir)) {\n    fs.mkdirSync(generatedDir, { recursive: true });\n  }\n\n  const relativePath = path\n    .relative(projectRoot, generatedDir)\n    .replace(/\\\\/g, \"/\");\n  console.log(`Output directory: ${relativePath}`);\n  console.log();\n\n  // 获取所有 SQL 文件\n  const allFiles = fs.readdirSync(migrationsDir);\n  const sqlFiles = allFiles\n    .filter((file) => path.extname(file) === \".sql\")\n    .map((file) => path.join(migrationsDir, file))\n    .sort();\n\n  if (sqlFiles.length === 0) {\n    console.log(`✗ No SQL files found in ${migrationsDir}`);\n    process.exit(1);\n  }\n\n  console.log(`Found ${sqlFiles.length} SQL file(s)`);\n  console.log();\n\n  // 处理每个 SQL 文件\n  const processedVersions = [];\n  let successCount = 0;\n\n  for (const sqlFile of sqlFiles) {\n    if (processMigrationFile(sqlFile)) {\n      // 提取版本号\n      const fileName = path.basename(sqlFile);\n      const versionMatch = fileName.match(/^(\\d+)_/);\n      if (versionMatch) {\n        processedVersions.push(parseInt(versionMatch[1], 10));\n      }\n      successCount++;\n    }\n    console.log();\n  }\n\n  // 生成索引模块\n  if (processedVersions.length > 0) {\n    generateIndexModule(processedVersions);\n  }\n\n  // 输出总结\n  console.log(\"=\".repeat(70));\n  console.log(\n    `✓ Successfully generated ${successCount}/${sqlFiles.length} schema module(s)`\n  );\n  console.log(\"=\".repeat(70));\n\n  process.exit(successCount === sqlFiles.length ? 0 : 1);\n}\n\n// 执行主函数\nmain();\n"
  },
  {
    "path": "scripts/prepare-dist.js",
    "content": "const path = require(\"path\");\nconst fs = require(\"fs\");\n\nfunction main() {\n  const projectDir = path.join(__dirname, \"..\");\n  const distDir = path.join(projectDir, \"dist\");\n  const webDist = path.join(projectDir, \"web\", \"dist\");\n  const exePath = path.join(projectDir, \"build\", \"windows\", \"x64\", \"release\", \"SpinningMomo.exe\");\n  const legalPath = path.join(projectDir, \"LEGAL.md\");\n  const licensePath = path.join(projectDir, \"LICENSE\");\n\n  if (!fs.existsSync(webDist)) {\n    console.error(\"web/dist not found. Run 'npm run build:web' first.\");\n    process.exit(1);\n  }\n\n  if (!fs.existsSync(exePath)) {\n    console.error(\"SpinningMomo.exe not found. Run 'npm run build:cpp' first.\");\n    process.exit(1);\n  }\n  if (!fs.existsSync(legalPath)) {\n    console.error(\"LEGAL.md not found.\");\n    process.exit(1);\n  }\n  if (!fs.existsSync(licensePath)) {\n    console.error(\"LICENSE not found.\");\n    process.exit(1);\n  }\n\n  console.log(`Preparing ${distDir}...`);\n\n  if (fs.existsSync(distDir)) {\n    fs.rmSync(distDir, { recursive: true, force: true });\n  }\n  fs.mkdirSync(distDir, { recursive: true });\n\n  fs.copyFileSync(exePath, path.join(distDir, \"SpinningMomo.exe\"));\n  fs.copyFileSync(legalPath, path.join(distDir, \"LEGAL.md\"));\n  fs.copyFileSync(licensePath, path.join(distDir, \"LICENSE\"));\n  fs.cpSync(webDist, path.join(distDir, \"resources\", \"web\"), { recursive: true });\n\n  console.log(\"Done!\");\n}\n\nmain();\n"
  },
  {
    "path": "scripts/quick-cleanup-spinningmomo.ps1",
    "content": "# powershell -NoProfile -ExecutionPolicy Bypass -File \"./scripts/quick-cleanup-spinningmomo.ps1\"\n\nSet-StrictMode -Version Latest\n$ErrorActionPreference = 'Stop'\n\nfunction Test-IsAdministrator {\n  $identity = [Security.Principal.WindowsIdentity]::GetCurrent()\n  $principal = New-Object Security.Principal.WindowsPrincipal($identity)\n  return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)\n}\n\nfunction Convert-GuidToPackedCode {\n  param([Parameter(Mandatory = $true)][string]$GuidText)\n\n  $guid = [Guid]$GuidText\n  $n = $guid.ToString('N').ToUpperInvariant()\n  $a = $n.Substring(0, 8)\n  $b = $n.Substring(8, 4)\n  $c = $n.Substring(12, 4)\n  $d = $n.Substring(16, 4)\n  $e = $n.Substring(20, 12)\n\n  $reverseText = {\n    param([string]$s)\n    $chars = $s.ToCharArray()\n    [array]::Reverse($chars)\n    -join $chars\n  }\n\n  $swapNibblePairs = {\n    param([string]$s)\n    $chunks = for ($i = 0; $i -lt $s.Length; $i += 2) { $s.Substring($i, 2) }\n    ($chunks | ForEach-Object { $_.Substring(1, 1) + $_.Substring(0, 1) }) -join ''\n  }\n\n  \"$(& $reverseText $a)$(& $reverseText $b)$(& $reverseText $c)$(& $swapNibblePairs $d)$(& $swapNibblePairs $e)\"\n}\n\nfunction Convert-PackedCodeToGuid {\n  param([Parameter(Mandatory = $true)][string]$PackedCode)\n\n  if ($PackedCode -notmatch '^[0-9A-Fa-f]{32}$') {\n    throw \"Invalid packed code: $PackedCode\"\n  }\n\n  $p = $PackedCode.ToUpperInvariant()\n  $a = $p.Substring(0, 8)\n  $b = $p.Substring(8, 4)\n  $c = $p.Substring(12, 4)\n  $d = $p.Substring(16, 4)\n  $e = $p.Substring(20, 12)\n\n  $reverseText = {\n    param([string]$s)\n    $chars = $s.ToCharArray()\n    [array]::Reverse($chars)\n    -join $chars\n  }\n\n  $swapNibblePairs = {\n    param([string]$s)\n    $chunks = for ($i = 0; $i -lt $s.Length; $i += 2) { $s.Substring($i, 2) }\n    ($chunks | ForEach-Object { $_.Substring(1, 1) + $_.Substring(0, 1) }) -join ''\n  }\n\n  $guidN = \"$(& $reverseText $a)$(& $reverseText $b)$(& $reverseText $c)$(& $swapNibblePairs $d)$(& $swapNibblePairs $e)\"\n  return ([Guid]::ParseExact($guidN, 'N').ToString('D').ToUpperInvariant())\n}\n\nfunction Remove-KeyIfExists {\n  param([Parameter(Mandatory = $true)][string]$RegPath)\n  if (Test-Path \"Registry::$RegPath\") {\n    Remove-Item -Path \"Registry::$RegPath\" -Recurse -Force -ErrorAction SilentlyContinue\n    Write-Host \"Removed key: $RegPath\"\n  }\n}\n\nfunction Remove-PathIfExists {\n  param([Parameter(Mandatory = $true)][string]$Path)\n  if (Test-Path $Path) {\n    Remove-Item -Path $Path -Recurse -Force -ErrorAction SilentlyContinue\n    Write-Host \"Removed path: $Path\"\n  }\n}\n\nif (-not (Test-IsAdministrator)) {\n  throw \"Please run this script in an elevated PowerShell (Run as Administrator).\"\n}\n\nWrite-Host \"Quick cleanup started...\" -ForegroundColor Cyan\n\n# SpinningMomo key component GUIDs (for occupancy detection)\n$componentGuids = @(\n  '{CA8D282A-3752-4E74-9252-76AB6F280997}', # DesktopShortcut\n  '{81DD2135-CFFF-4EAD-902C-D25BD1C5612B}', # DesktopShortcutRemove\n  '{7808BA3C-C658-440F-976C-838362DD1FFF}', # StartMenuShortcut\n  '{5E694D68-9DFD-4510-9EA1-698F8A09739D}', # StartMenuShortcutRemove\n  '{7DF1D902-6FF4-43EF-B58A-837AE33B4494}'  # CleanupUserData\n)\n\n$componentPacked = @{}\nforeach ($g in $componentGuids) {\n  $normalized = ([Guid]$g).ToString('D').ToUpperInvariant()\n  $componentPacked[$normalized] = Convert-GuidToPackedCode $normalized\n}\n\n$userDataRoot = 'Registry::HKEY_LOCAL_MACHINE\\Software\\Microsoft\\Windows\\CurrentVersion\\Installer\\UserData'\n$detectedProductPacked = New-Object System.Collections.Generic.HashSet[string]\n\n# 1) Detect all product packed codes occupying our components.\nif (Test-Path $userDataRoot) {\n  foreach ($sidRoot in (Get-ChildItem -Path $userDataRoot -ErrorAction SilentlyContinue)) {\n    foreach ($packedComp in $componentPacked.Values) {\n      $ck = Join-Path $sidRoot.PSPath \"Components\\$packedComp\"\n      if (-not (Test-Path $ck)) { continue }\n      $item = Get-ItemProperty -Path $ck -ErrorAction SilentlyContinue\n      if ($null -eq $item) { continue }\n\n      foreach ($prop in $item.PSObject.Properties) {\n        if ($prop.Name -in @('PSPath', 'PSParentPath', 'PSChildName', 'PSDrive', 'PSProvider')) { continue }\n        if ($prop.Name -notmatch '^[0-9A-Fa-f]{32}$') { continue }\n        if ($prop.Name -eq '00000000000000000000000000000000') { continue }\n        $null = $detectedProductPacked.Add($prop.Name.ToUpperInvariant())\n      }\n    }\n  }\n}\n\n$detectedProductGuids = @()\nforeach ($pp in $detectedProductPacked) {\n  try {\n    $detectedProductGuids += \"{$(Convert-PackedCodeToGuid $pp)}\"\n  }\n  catch {\n    # ignore malformed packed code\n  }\n}\n\nWrite-Host \"Detected product codes:\"\nif ($detectedProductGuids.Count -eq 0) {\n  Write-Host \" - (none)\" -ForegroundColor Yellow\n} else {\n  $detectedProductGuids | Sort-Object -Unique | ForEach-Object { Write-Host \" - $_\" }\n}\n\n# 2) Remove all matching product client values from Installer\\UserData\\*\\Components\\*\nif ((Test-Path $userDataRoot) -and $detectedProductPacked.Count -gt 0) {\n  foreach ($sidRoot in (Get-ChildItem -Path $userDataRoot -ErrorAction SilentlyContinue)) {\n    $componentsRoot = Join-Path $sidRoot.PSPath 'Components'\n    if (-not (Test-Path $componentsRoot)) { continue }\n    foreach ($ck in (Get-ChildItem -Path $componentsRoot -ErrorAction SilentlyContinue)) {\n      $keyPath = $ck.PSPath\n      $item = Get-ItemProperty -Path $keyPath -ErrorAction SilentlyContinue\n      if ($null -eq $item) { continue }\n      $regPath = $keyPath -replace '^Microsoft\\.PowerShell\\.Core\\\\Registry::', ''\n      foreach ($pp in $detectedProductPacked) {\n        if ($item.PSObject.Properties.Name -contains $pp) {\n          Remove-ItemProperty -Path \"Registry::$regPath\" -Name $pp -Force -ErrorAction SilentlyContinue\n          Write-Host \"Removed component client: $pp @ $regPath\"\n        }\n      }\n    }\n  }\n}\n\n# 3) Remove uninstall entries by detected product codes + display name fallback.\nforeach ($pg in ($detectedProductGuids | Sort-Object -Unique)) {\n  Remove-KeyIfExists \"HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\$pg\"\n  Remove-KeyIfExists \"HKEY_LOCAL_MACHINE\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\$pg\"\n  Remove-KeyIfExists \"HKEY_LOCAL_MACHINE\\Software\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\$pg\"\n}\n\n$uninstallRoots = @(\n  'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall',\n  'HKLM:\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall',\n  'HKLM:\\Software\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n)\nforeach ($root in $uninstallRoots) {\n  if (-not (Test-Path $root)) { continue }\n  foreach ($k in (Get-ChildItem -Path $root -ErrorAction SilentlyContinue)) {\n    $p = Get-ItemProperty -Path $k.PSPath -ErrorAction SilentlyContinue\n    if ($null -eq $p) { continue }\n    $hasDisplayName = $p.PSObject.Properties.Name -contains 'DisplayName'\n    if ($hasDisplayName -and $p.DisplayName -eq 'SpinningMomo') {\n      Remove-Item -Path $k.PSPath -Recurse -Force -ErrorAction SilentlyContinue\n      Write-Host \"Removed uninstall entry by DisplayName: $($k.PSChildName)\"\n    }\n  }\n}\n\n# 4) Remove app-specific registry key.\nRemove-KeyIfExists 'HKEY_CURRENT_USER\\Software\\SpinningMomo'\n\n# 5) Remove package cache folders related to detected product codes and bundle code entries.\n$packageCacheRoot = \"$env:LOCALAPPDATA\\Package Cache\"\nif (Test-Path $packageCacheRoot) {\n  foreach ($pg in ($detectedProductGuids | Sort-Object -Unique)) {\n    $prefix = Join-Path $packageCacheRoot ($pg + 'v*')\n    foreach ($m in (Get-ChildItem -Path $prefix -ErrorAction SilentlyContinue)) {\n      Remove-PathIfExists $m.FullName\n    }\n  }\n  foreach ($m in (Get-ChildItem -Path (Join-Path $packageCacheRoot '{*}') -ErrorAction SilentlyContinue)) {\n    $setupExe = Join-Path $m.FullName 'SpinningMomo-*-Setup.exe'\n    $hasSetup = Get-ChildItem -Path $setupExe -ErrorAction SilentlyContinue\n    if ($hasSetup) {\n      Remove-PathIfExists $m.FullName\n    }\n  }\n}\n\n# 6) Remove file leftovers (does not touch repo files like version.json).\nRemove-PathIfExists \"$env:USERPROFILE\\Desktop\\SpinningMomo.lnk\"\nRemove-PathIfExists \"$env:APPDATA\\Microsoft\\Windows\\Start Menu\\Programs\\SpinningMomo\\SpinningMomo.lnk\"\nRemove-PathIfExists \"$env:LOCALAPPDATA\\Programs\\SpinningMomo\"\nRemove-PathIfExists \"$env:LOCALAPPDATA\\SpinningMomo\"\n\nWrite-Host \"Quick cleanup finished.\" -ForegroundColor Green\n"
  },
  {
    "path": "scripts/release-version.js",
    "content": "// 运行此脚本更新版本号\n// npm run release:version -- 2.0.1\n\nconst fs = require(\"fs\");\nconst path = require(\"path\");\n\nfunction normalizeVersion(input) {\n  const cleaned = input.startsWith(\"v\") || input.startsWith(\"V\") ? input.slice(1) : input;\n  const isValid = /^\\d+\\.\\d+\\.\\d+$/.test(cleaned);\n  if (!isValid) {\n    throw new Error(\"Invalid version. Use X.Y.Z (optionally prefixed with v).\");\n  }\n  return cleaned;\n}\n\nfunction toVersion4Parts(version) {\n  const parts = version.split(\".\").map((p) => Number.parseInt(p, 10));\n  while (parts.length < 4) {\n    parts.push(0);\n  }\n  return parts;\n}\n\nfunction updateVersionJson(filePath, version) {\n  const updated = `${JSON.stringify({ version }, null, 2)}\\n`;\n  fs.writeFileSync(filePath, updated, \"utf8\");\n}\n\nfunction updateAppRc(filePath, version4Parts) {\n  const content = fs.readFileSync(filePath, \"utf8\");\n  const versionNum = version4Parts.join(\", \");\n  const versionStr = version4Parts.join(\".\");\n\n  const hasVersionNum = /#define APP_VERSION_NUM .*/.test(content);\n  const hasVersionStr = /#define APP_VERSION_STR \".*\"/.test(content);\n\n  if (!hasVersionNum || !hasVersionStr) {\n    throw new Error(\"resources/app.rc format is unexpected. APP_VERSION_NUM / APP_VERSION_STR not found.\");\n  }\n\n  const updated = content\n    .replace(/#define APP_VERSION_NUM .*/, `#define APP_VERSION_NUM ${versionNum}`)\n    .replace(/#define APP_VERSION_STR \".*\"/, `#define APP_VERSION_STR \"${versionStr}\"`);\n\n  fs.writeFileSync(filePath, updated, \"utf8\");\n}\n\nfunction updateVersionModule(filePath, version4Parts) {\n  const content = fs.readFileSync(filePath, \"utf8\");\n  const versionStr = version4Parts.join(\".\");\n  const pattern = /export auto get_app_version\\(\\) -> std::string \\{ return \".*\"; \\}/;\n\n  if (!pattern.test(content)) {\n    throw new Error(\"src/vendor/version.ixx format is unexpected. get_app_version() not found.\");\n  }\n\n  const updated = content.replace(\n    pattern,\n    `export auto get_app_version() -> std::string { return \"${versionStr}\"; }`\n  );\n\n  fs.writeFileSync(filePath, updated, \"utf8\");\n}\n\nfunction updateVersionTxt(filePath, version) {\n  fs.writeFileSync(filePath, `${version}\\n`, \"utf8\");\n}\n\nfunction main() {\n  const rawVersion = process.argv[2];\n  if (!rawVersion) {\n    console.error(\"Usage: npm run release:version -- <version>\");\n    console.error(\"Example: npm run release:version -- 2.1.0\");\n    process.exit(1);\n  }\n\n  const projectRoot = path.join(__dirname, \"..\");\n  const versionJsonPath = path.join(projectRoot, \"version.json\");\n  const appRcPath = path.join(projectRoot, \"resources\", \"app.rc\");\n  const versionModulePath = path.join(projectRoot, \"src\", \"vendor\", \"version.ixx\");\n  const versionTxtPath = path.join(projectRoot, \"docs\", \"public\", \"version.txt\");\n  process.chdir(projectRoot);\n\n  const version = normalizeVersion(rawVersion);\n  const version4Parts = toVersion4Parts(version);\n  const tagName = `v${version}`;\n\n  updateVersionJson(versionJsonPath, version);\n  updateAppRc(appRcPath, version4Parts);\n  updateVersionModule(versionModulePath, version4Parts);\n  updateVersionTxt(versionTxtPath, version);\n\n  console.log(\"\");\n  console.log(`Version files updated for ${tagName}`);\n  console.log(\"Updated:\");\n  console.log(`- version.json -> ${version}`);\n  console.log(`- resources/app.rc -> ${version4Parts.join(\".\")}`);\n  console.log(`- src/vendor/version.ixx -> ${version4Parts.join(\".\")}`);\n  console.log(`- docs/public/version.txt -> ${version}`);\n  console.log(\"\");\n  console.log(\"Next:\");\n  console.log(\"- Review the diff and commit it\");\n  console.log(`- Create tag: git tag ${tagName}`);\n  console.log(`- Push branch and tag: git push origin HEAD ${tagName}`);\n}\n\ntry {\n  main();\n} catch (error) {\n  const message = error instanceof Error ? error.message : String(error);\n  console.error(`Error: ${message}`);\n  process.exit(1);\n}\n"
  },
  {
    "path": "src/app.cpp",
    "content": "module;\n\nmodule App;\n\nimport std;\nimport Core.Initializer;\nimport Core.RuntimeInfo;\nimport Core.Shutdown;\nimport Core.State;\nimport UI.FloatingWindow.State;\nimport Utils.Logger;\nimport Vendor.Windows;\n\nApplication::Application() = default;\nApplication::~Application() {\n  if (m_app_state) {\n    Core::Shutdown::shutdown_application(*m_app_state);\n  }\n}\n\nauto Application::Initialize(Vendor::Windows::HINSTANCE hInstance) -> bool {\n  m_h_instance = hInstance;\n\n  try {\n    // 创建 AppState, 其构造函数会自动初始化所有子状态\n    m_app_state = std::make_unique<Core::State::AppState>();\n\n    m_app_state->floating_window->window.instance = m_h_instance;\n\n    Core::RuntimeInfo::collect(*m_app_state);\n\n    // 调用统一的初始化器\n    if (auto result = Core::Initializer::initialize_application(*m_app_state, m_h_instance);\n        !result) {\n      Logger().error(\"Failed to initialize application: {}\", result.error());\n      return false;\n    }\n\n    return true;\n\n  } catch (const std::exception& e) {\n    Logger().error(\"Exception during initialization: {}\", e.what());\n    return false;\n  }\n}\n\nauto Application::Run() -> int {\n  Vendor::Windows::MSG msg{};\n\n  // 消息驱动的事件循环：\n  // - WM_APP_PROCESS_EVENTS: 处理异步事件队列\n  // - WM_TIMER: 处理通知动画更新（固定 60fps 帧率）\n  // 没有任务时 GetMessage 会阻塞，零 CPU 占用\n  while (Vendor::Windows::GetWindowMessage(&msg, nullptr, 0, 0)) {\n    if (msg.message == Vendor::Windows::kWM_QUIT) {\n      return static_cast<int>(msg.wParam);\n    }\n    Vendor::Windows::TranslateWindowMessage(&msg);\n    Vendor::Windows::DispatchWindowMessageW(&msg);\n  }\n\n  return static_cast<int>(msg.wParam);\n}\n"
  },
  {
    "path": "src/app.ixx",
    "content": "module;\n\nexport module App;\n\nimport std;\nimport Vendor.Windows;\nimport Core.Events;\nimport Core.State;\nimport UI.FloatingWindow;\n\n// 主应用程序类\nexport class Application {\n public:\n  Application();\n  ~Application();\n\n  // 禁用拷贝和移动\n  Application(const Application&) = delete;\n  auto operator=(const Application&) -> Application& = delete;\n  Application(Application&&) = delete;\n  auto operator=(Application&&) -> Application& = delete;\n\n  // 现代C++风格的初始化\n  [[nodiscard]] auto Initialize(Vendor::Windows::HINSTANCE hInstance) -> bool;\n\n  // 运行应用程序\n  [[nodiscard]] auto Run() -> int;\n\n private:\n  // 应用状态\n  std::unique_ptr<Core::State::AppState> m_app_state;\n\n  Vendor::Windows::HINSTANCE m_h_instance = nullptr;\n};\n"
  },
  {
    "path": "src/core/async/async.cpp",
    "content": "module;\n\n#include <asio.hpp>\n\nmodule Core.Async;\n\nimport std;\nimport Core.Async.State;\nimport Utils.Logger;\n\nnamespace Core::Async {\n\nauto start(Core::Async::State::AsyncState& runtime, size_t thread_count)\n    -> std::expected<void, std::string> {\n  // 检查是否已经运行\n  if (runtime.is_running.exchange(true)) {\n    Logger().warn(\"AsyncRuntime already started\");\n    return std::unexpected(\"AsyncRuntime already started\");\n  }\n\n  try {\n    // 确定线程数\n    if (thread_count == 0) {\n      thread_count = std::thread::hardware_concurrency();\n      if (thread_count == 0) thread_count = 2;  // 备用值\n    }\n    runtime.thread_count = thread_count;\n\n    // 初始化io_context\n    runtime.io_context = std::make_unique<asio::io_context>();\n\n    Logger().info(\"Starting AsyncRuntime with {} threads\", thread_count);\n\n    // 创建工作线程池\n    runtime.worker_threads.reserve(thread_count);\n    for (size_t i = 0; i < thread_count; ++i) {\n      runtime.worker_threads.emplace_back([&runtime, i]() {\n        try {\n          auto work = asio::make_work_guard(*runtime.io_context);\n          runtime.io_context->run();\n        } catch (const std::exception& e) {\n          Logger().error(\"AsyncRuntime worker thread {} error: {}\", i, e.what());\n        }\n      });\n    }\n\n    Logger().info(\"AsyncRuntime started successfully\");\n    return {};\n\n  } catch (const std::exception& e) {\n    // 启动失败，恢复状态\n    runtime.is_running = false;\n    runtime.io_context.reset();\n    runtime.worker_threads.clear();\n\n    auto error_msg = std::format(\"Failed to start AsyncRuntime: {}\", e.what());\n    Logger().error(error_msg);\n    return std::unexpected(error_msg);\n  }\n}\n\nauto stop(Core::Async::State::AsyncState& runtime) -> void {\n  if (!runtime.is_running.exchange(false)) {\n    return;  // 已经停止\n  }\n\n  Logger().info(\"Stopping AsyncRuntime\");\n\n  try {\n    // 标记关闭请求\n    runtime.shutdown_requested = true;\n\n    // 停止io_context\n    if (runtime.io_context) {\n      runtime.io_context->stop();\n    }\n\n    // 等待所有工作线程结束\n    for (auto& worker : runtime.worker_threads) {\n      if (worker.joinable()) {\n        worker.join();\n      }\n    }\n\n    // 清理资源\n    runtime.worker_threads.clear();\n    runtime.io_context.reset();\n    runtime.shutdown_requested = false;\n\n    Logger().info(\"AsyncRuntime stopped\");\n\n  } catch (const std::exception& e) {\n    Logger().error(\"Error during AsyncRuntime shutdown: {}\", e.what());\n  }\n}\n\nauto is_running(const Core::Async::State::AsyncState& runtime) -> bool {\n  return runtime.is_running.load();\n}\n\nauto get_io_context(Core::Async::State::AsyncState& runtime) -> asio::io_context* {\n  return runtime.io_context.get();\n}\n\n}  // namespace Core::Async"
  },
  {
    "path": "src/core/async/async.ixx",
    "content": "module;\n\n#include <asio.hpp>\n\nexport module Core.Async;\n\nimport std;\nimport Core.Async.State;\n\nnamespace Core::Async {\n\n// 启动异步运行时（包含初始化）\nexport auto start(Core::Async::State::AsyncState& runtime, size_t thread_count = 0)\n    -> std::expected<void, std::string>;\n\n// 停止异步运行时（包含清理）\nexport auto stop(Core::Async::State::AsyncState& runtime) -> void;\n\n// 检查运行时是否正在运行\nexport auto is_running(const Core::Async::State::AsyncState& runtime) -> bool;\n\n// 获取io_context用于提交任务\nexport auto get_io_context(Core::Async::State::AsyncState& runtime) -> asio::io_context*;\n\n}  // namespace Core::Async"
  },
  {
    "path": "src/core/async/state.ixx",
    "content": "module;\r\n\r\n#include <asio.hpp>\r\n\r\nexport module Core.Async.State;\r\n\r\nimport std;\r\n\r\nexport namespace Core::Async::State {\r\n\r\nstruct AsyncState {\r\n  // 核心asio状态\r\n  std::unique_ptr<asio::io_context> io_context;\r\n  std::vector<std::jthread> worker_threads;\r\n\r\n  // 运行状态\r\n  std::atomic<bool> is_running{false};\r\n  std::atomic<bool> shutdown_requested{false};\r\n\r\n  // 配置\r\n  size_t thread_count = 0;  // 0表示使用硬件并发数\r\n\r\n  };\r\n\r\n}  // namespace Core::Async::State"
  },
  {
    "path": "src/core/async/ui_awaitable.ixx",
    "content": "module;\n\n#include <windows.h>\n\nexport module Core.Async.UiAwaitable;\n\nimport std;\n\nnamespace Core::Async {\n\n// 用于存储定时器 ID 到协程句柄的映射\ninline std::unordered_map<UINT_PTR, std::coroutine_handle<>>& get_timer_handles() {\n  static std::unordered_map<UINT_PTR, std::coroutine_handle<>> handles;\n  return handles;\n}\n\n// 定时器回调函数\ninline VOID CALLBACK ui_timer_proc(HWND, UINT, UINT_PTR id, DWORD) {\n  auto& handles = get_timer_handles();\n  auto it = handles.find(id);\n  if (it != handles.end()) {\n    auto handle = it->second;\n    handles.erase(it);\n    KillTimer(nullptr, id);\n    handle.resume();  // 恢复协程，在 UI 线程执行\n  }\n}\n\n// UI 线程延迟等待的 awaitable\n// 使用 Windows 原生定时器，完全事件驱动，无轮询\nexport struct ui_delay {\n  std::chrono::milliseconds duration;\n\n  // 如果延迟 <= 0，立即完成\n  bool await_ready() const noexcept { return duration.count() <= 0; }\n\n  // 挂起协程，设置 Windows 定时器\n  void await_suspend(std::coroutine_handle<> h) const {\n    UINT_PTR timer_id = SetTimer(nullptr, 0, static_cast<UINT>(duration.count()), ui_timer_proc);\n\n    if (timer_id == 0) {\n      // SetTimer 失败，立即恢复协程\n      h.resume();\n      return;\n    }\n\n    // 记录映射关系\n    get_timer_handles()[timer_id] = h;\n  }\n\n  // 恢复时无返回值\n  void await_resume() const noexcept {}\n};\n\n// UI 线程协程的返回类型\n// 协程立即开始执行，完成后自动清理\nexport struct ui_task {\n  struct promise_type {\n    ui_task get_return_object() noexcept { return {}; }\n    std::suspend_never initial_suspend() noexcept { return {}; }  // 立即开始\n    std::suspend_never final_suspend() noexcept { return {}; }    // 完成后不挂起\n    void return_void() noexcept {}\n    void unhandled_exception() noexcept {\n      // 可以在这里记录异常\n    }\n  };\n};\n\n}  // namespace Core::Async\n"
  },
  {
    "path": "src/core/commands/builtin.cpp",
    "content": "module;\n\nmodule Core.Commands;\n\nimport std;\nimport Core.State;\nimport Core.Commands;\nimport Features.Settings.State;\nimport Features.Screenshot.UseCase;\nimport Features.Recording.UseCase;\nimport Features.ReplayBuffer.UseCase;\nimport Features.ReplayBuffer.Types;\nimport Features.ReplayBuffer.State;\nimport Features.Letterbox.UseCase;\nimport Features.Overlay.UseCase;\nimport Features.Preview.UseCase;\nimport Features.Letterbox.State;\nimport Features.Recording.State;\nimport Features.Overlay.State;\nimport Features.Preview.State;\nimport Features.WindowControl.UseCase;\nimport UI.FloatingWindow;\nimport UI.WebViewWindow;\nimport Utils.Logger;\nimport Utils.Path;\nimport Utils.System;\nimport Vendor.BuildConfig;\nimport Vendor.Windows;\n\nnamespace Core::Commands {\n\n// 注册所有内置命令\nauto register_builtin_commands(Core::State::AppState& state, CommandRegistry& registry) -> void {\n  Logger().info(\"Registering builtin commands...\");\n\n  // === 应用层命令 ===\n\n  // 打开主界面（WebView2 或浏览器）\n  register_command(registry,\n                   {\n                       .id = \"app.main\",\n                       .i18n_key = \"menu.app_main\",\n                       .is_toggle = false,\n                       .action = [&state]() { UI::WebViewWindow::activate_window(state); },\n                   });\n\n  // 退出应用\n  register_command(registry, {\n                                 .id = \"app.exit\",\n                                 .i18n_key = \"menu.app_exit\",\n                                 .is_toggle = false,\n                                 .action = []() { Vendor::Windows::PostQuitMessage(0); },\n                             });\n\n  // === 悬浮窗控制 ===\n\n  // 激活悬浮窗\n  register_command(registry,\n                   {\n                       .id = \"app.float\",\n                       .i18n_key = \"menu.app_float\",\n                       .is_toggle = false,\n                       .action = [&state]() { UI::FloatingWindow::toggle_visibility(state); },\n                       .hotkey =\n                           HotkeyBinding{\n                               .modifiers = 1,  // MOD_CONTROL\n                               .key = 192,      // VK_OEM_3 (`)\n                               .settings_path = \"app.hotkey.floating_window\",\n                           },\n                   });\n\n  // === 截图功能 ===\n\n  // 截图\n  register_command(registry,\n                   {\n                       .id = \"screenshot.capture\",\n                       .i18n_key = \"menu.screenshot_capture\",\n                       .is_toggle = false,\n                       .action = [&state]() { Features::Screenshot::UseCase::capture(state); },\n                       .hotkey =\n                           HotkeyBinding{\n                               .modifiers = 0,  // 无修饰键\n                               .key = 44,       // VK_SNAPSHOT (PrintScreen)\n                               .settings_path = \"app.hotkey.screenshot\",\n                           },\n                   });\n\n  // 打开输出目录\n  register_command(\n      registry,\n      {\n          .id = \"output.open_folder\",\n          .i18n_key = \"menu.output_open_folder\",\n          .is_toggle = false,\n          .action =\n              [&state]() {\n                auto output_dir_result =\n                    Utils::Path::GetOutputDirectory(state.settings->raw.features.output_dir_path);\n                if (!output_dir_result) {\n                  Logger().error(\"Failed to resolve output directory: {}\",\n                                 output_dir_result.error());\n                  return;\n                }\n\n                auto open_result = Utils::System::open_directory(output_dir_result.value());\n                if (!open_result) {\n                  Logger().error(\"Failed to open output directory: {}\", open_result.error());\n                }\n              },\n      });\n\n  // 打开游戏相册目录\n  register_command(\n      registry,\n      {\n          .id = \"external_album.open_folder\",\n          .i18n_key = \"menu.external_album_open_folder\",\n          .is_toggle = false,\n          .action =\n              [&state]() {\n                std::filesystem::path folder_to_open;\n\n                const auto& external_album_path = state.settings->raw.features.external_album_path;\n                if (!external_album_path.empty()) {\n                  folder_to_open = external_album_path;\n                } else {\n                  auto output_dir_result =\n                      Utils::Path::GetOutputDirectory(state.settings->raw.features.output_dir_path);\n                  if (!output_dir_result) {\n                    Logger().error(\"Failed to resolve fallback output directory: {}\",\n                                   output_dir_result.error());\n                    return;\n                  }\n                  folder_to_open = output_dir_result.value();\n                }\n\n                auto open_result = Utils::System::open_directory(folder_to_open);\n                if (!open_result) {\n                  Logger().error(\"Failed to open external album directory: {}\",\n                                 open_result.error());\n                }\n              },\n      });\n\n  // === 独立功能 ===\n\n  // 切换预览窗\n  register_command(\n      registry, {\n                    .id = \"preview.toggle\",\n                    .i18n_key = \"menu.preview_toggle\",\n                    .is_toggle = true,\n                    .action =\n                        [&state]() {\n                          Features::Preview::UseCase::toggle_preview(state);\n                          UI::FloatingWindow::request_repaint(state);\n                        },\n                    .get_state = [&state]() -> bool {\n                      return state.preview ? state.preview->running.load(std::memory_order_acquire)\n                                           : false;\n                    },\n                });\n\n  // 切换叠加层\n  register_command(registry, {\n                                 .id = \"overlay.toggle\",\n                                 .i18n_key = \"menu.overlay_toggle\",\n                                 .is_toggle = true,\n                                 .action =\n                                     [&state]() {\n                                       Features::Overlay::UseCase::toggle_overlay(state);\n                                       UI::FloatingWindow::request_repaint(state);\n                                     },\n                                 .get_state = [&state]() -> bool {\n                                   return state.overlay && state.overlay->enabled;\n                                 },\n                             });\n\n  // 切换黑边模式\n  register_command(registry, {\n                                 .id = \"letterbox.toggle\",\n                                 .i18n_key = \"menu.letterbox_toggle\",\n                                 .is_toggle = true,\n                                 .action =\n                                     [&state]() {\n                                       Features::Letterbox::UseCase::toggle_letterbox(state);\n                                       UI::FloatingWindow::request_repaint(state);\n                                     },\n                                 .get_state = [&state]() -> bool {\n                                   return state.letterbox && state.letterbox->enabled;\n                                 },\n                             });\n\n  // 切换录制\n  register_command(\n      registry,\n      {\n          .id = \"recording.toggle\",\n          .i18n_key = \"menu.recording_toggle\",\n          .is_toggle = true,\n          .action =\n              [&state]() {\n                if (auto result = Features::Recording::UseCase::toggle_recording(state); !result) {\n                  Logger().error(\"Recording toggle failed: {}\", result.error());\n                }\n                UI::FloatingWindow::request_repaint(state);\n              },\n          .get_state = [&state]() -> bool {\n            return state.recording && state.recording->status ==\n                                          Features::Recording::Types::RecordingStatus::Recording;\n          },\n          .hotkey =\n              HotkeyBinding{\n                  .modifiers = 0,  // 无修饰键\n                  .key = 0x77,     // VK_F8 (F8)\n                  .settings_path = \"app.hotkey.recording\",\n              },\n      });\n\n  // === 动态照片和即时回放 ===\n  if (Vendor::BuildConfig::is_debug_build()) {\n    // 切换动态照片模式（仅运行时）\n    register_command(\n        registry,\n        {\n            .id = \"motion_photo.toggle\",\n            .i18n_key = \"menu.motion_photo_toggle\",\n            .is_toggle = true,\n            .action =\n                [&state]() {\n                  if (auto result = Features::ReplayBuffer::UseCase::toggle_motion_photo(state);\n                      !result) {\n                    Logger().error(\"Motion Photo toggle failed: {}\", result.error());\n                  }\n                  UI::FloatingWindow::request_repaint(state);\n                },\n            .get_state = [&state]() -> bool {\n              return state.replay_buffer &&\n                     state.replay_buffer->motion_photo_enabled.load(std::memory_order_acquire);\n            },\n        });\n\n    // 切换即时回放模式（仅运行时）\n    register_command(\n        registry,\n        {\n            .id = \"replay_buffer.toggle\",\n            .i18n_key = \"menu.replay_buffer_toggle\",\n            .is_toggle = true,\n            .action =\n                [&state]() {\n                  if (auto result = Features::ReplayBuffer::UseCase::toggle_replay_buffer(state);\n                      !result) {\n                    Logger().error(\"Instant Replay toggle failed: {}\", result.error());\n                  }\n                  UI::FloatingWindow::request_repaint(state);\n                },\n            .get_state = [&state]() -> bool {\n              return state.replay_buffer &&\n                     state.replay_buffer->replay_enabled.load(std::memory_order_acquire);\n            },\n        });\n\n    // 保存即时回放\n    register_command(\n        registry, {\n                      .id = \"replay_buffer.save\",\n                      .i18n_key = \"menu.replay_buffer_save\",\n                      .is_toggle = false,\n                      .action =\n                          [&state]() {\n                            if (auto result = Features::ReplayBuffer::UseCase::save_replay(state);\n                                !result) {\n                              Logger().error(\"Save replay failed: {}\", result.error());\n                            }\n                          },\n                  });\n  } else {\n    Logger().info(\"Skipping experimental replay commands in release build\");\n  }\n\n  // === 窗口操作 ===\n\n  // 重置窗口变换\n  register_command(\n      registry,\n      {\n          .id = \"window.reset\",\n          .i18n_key = \"menu.window_reset\",\n          .is_toggle = false,\n          .action = [&state]() { Features::WindowControl::UseCase::reset_window_transform(state); },\n      });\n\n  Logger().info(\"Registered {} builtin commands\", registry.descriptors.size());\n}\n\n}  // namespace Core::Commands\n"
  },
  {
    "path": "src/core/commands/registry.cpp",
    "content": "module;\n\nmodule Core.Commands;\n\nimport std;\nimport Core.State;\nimport Core.Commands.State;\nimport Features.Settings.State;\nimport Utils.Logger;\nimport <windows.h>;\n\nnamespace Core::Commands {\n\nstatic Core::Commands::State::CommandState* g_mouse_hotkey_state = nullptr;\n\n// 这个钩子故意不消费任何按键消息，只保留一个最小的全局键盘挂载。\nLRESULT CALLBACK keyboard_keepalive_proc(int code, WPARAM wParam, LPARAM lParam) {\n  return CallNextHookEx(nullptr, code, wParam, lParam);\n}\n\nauto install_keyboard_keepalive_hook(Core::State::AppState& state) -> void {\n  if (!state.commands) {\n    Logger().warn(\"Skip keyboard keepalive hook installation: command state is not ready\");\n    return;\n  }\n\n  auto& cmd_state = *state.commands;\n  if (cmd_state.keyboard_keepalive_hook) {\n    return;\n  }\n\n  // 独立于 RegisterHotKey/设置刷新生命周期，避免热键重载时反复装卸这个常驻钩子。\n  auto hook =\n      SetWindowsHookExW(WH_KEYBOARD_LL, keyboard_keepalive_proc, GetModuleHandleW(nullptr), 0);\n  if (!hook) {\n    auto error = GetLastError();\n    Logger().warn(\"Failed to install keyboard keepalive hook, error code: {}\", error);\n    return;\n  }\n\n  cmd_state.keyboard_keepalive_hook = hook;\n  Logger().info(\"Keyboard keepalive hook installed\");\n}\n\nauto uninstall_keyboard_keepalive_hook(Core::State::AppState& state) -> void {\n  if (!state.commands) {\n    return;\n  }\n\n  auto& cmd_state = *state.commands;\n  if (!cmd_state.keyboard_keepalive_hook) {\n    return;\n  }\n\n  UnhookWindowsHookEx(cmd_state.keyboard_keepalive_hook);\n  cmd_state.keyboard_keepalive_hook = nullptr;\n  Logger().info(\"Keyboard keepalive hook uninstalled\");\n}\n\nauto is_mouse_side_key(UINT key) -> bool { return key == VK_XBUTTON1 || key == VK_XBUTTON2; }\n\nauto make_mouse_combo(UINT modifiers, UINT key) -> std::uint32_t {\n  constexpr UINT kSupportedModifiers = MOD_ALT | MOD_CONTROL | MOD_SHIFT | MOD_WIN;\n  auto masked_modifiers = modifiers & kSupportedModifiers;\n  return (static_cast<std::uint32_t>(masked_modifiers & 0xFFFFu) << 16u) |\n         static_cast<std::uint32_t>(key & 0xFFFFu);\n}\n\nauto get_current_hotkey_modifiers() -> UINT {\n  UINT modifiers = 0;\n  if ((GetAsyncKeyState(VK_MENU) & 0x8000) != 0) {\n    modifiers |= MOD_ALT;\n  }\n  if ((GetAsyncKeyState(VK_CONTROL) & 0x8000) != 0) {\n    modifiers |= MOD_CONTROL;\n  }\n  if ((GetAsyncKeyState(VK_SHIFT) & 0x8000) != 0) {\n    modifiers |= MOD_SHIFT;\n  }\n  if ((GetAsyncKeyState(VK_LWIN) & 0x8000) != 0 || (GetAsyncKeyState(VK_RWIN) & 0x8000) != 0) {\n    modifiers |= MOD_WIN;\n  }\n  return modifiers;\n}\n\nLRESULT CALLBACK mouse_hotkey_proc(int code, WPARAM wParam, LPARAM lParam) {\n  if (code == HC_ACTION && g_mouse_hotkey_state && wParam == WM_XBUTTONDOWN) {\n    auto* mouse_info = reinterpret_cast<const MSLLHOOKSTRUCT*>(lParam);\n    if (mouse_info) {\n      UINT key = 0;\n      auto button = HIWORD(mouse_info->mouseData);\n      if (button == XBUTTON1) {\n        key = VK_XBUTTON1;\n      } else if (button == XBUTTON2) {\n        key = VK_XBUTTON2;\n      }\n\n      if (key != 0) {\n        auto combo = make_mouse_combo(get_current_hotkey_modifiers(), key);\n        auto it = g_mouse_hotkey_state->mouse_combo_to_hotkey_id.find(combo);\n        if (it != g_mouse_hotkey_state->mouse_combo_to_hotkey_id.end()) {\n          auto target_hwnd = g_mouse_hotkey_state->mouse_hotkey_target_hwnd;\n          if (target_hwnd && IsWindow(target_hwnd)) {\n            PostMessageW(target_hwnd, WM_HOTKEY, static_cast<WPARAM>(it->second), 0);\n          }\n        }\n      }\n    }\n  }\n\n  return CallNextHookEx(nullptr, code, wParam, lParam);\n}\n\nauto install_mouse_hotkey_hook(Core::Commands::State::CommandState& cmd_state, HWND hwnd) -> bool {\n  if (cmd_state.mouse_hotkey_hook) {\n    cmd_state.mouse_hotkey_target_hwnd = hwnd;\n    g_mouse_hotkey_state = &cmd_state;\n    return true;\n  }\n\n  auto hook = SetWindowsHookExW(WH_MOUSE_LL, mouse_hotkey_proc, GetModuleHandleW(nullptr), 0);\n  if (!hook) {\n    auto error = GetLastError();\n    Logger().error(\"Failed to install mouse hotkey hook, error code: {}\", error);\n    return false;\n  }\n\n  cmd_state.mouse_hotkey_hook = hook;\n  cmd_state.mouse_hotkey_target_hwnd = hwnd;\n  g_mouse_hotkey_state = &cmd_state;\n  Logger().info(\"Mouse hotkey hook installed\");\n  return true;\n}\n\nauto uninstall_mouse_hotkey_hook(Core::Commands::State::CommandState& cmd_state) -> void {\n  if (cmd_state.mouse_hotkey_hook) {\n    UnhookWindowsHookEx(cmd_state.mouse_hotkey_hook);\n    cmd_state.mouse_hotkey_hook = nullptr;\n    Logger().info(\"Mouse hotkey hook uninstalled\");\n  }\n\n  cmd_state.mouse_hotkey_target_hwnd = nullptr;\n  cmd_state.mouse_combo_to_hotkey_id.clear();\n  if (g_mouse_hotkey_state == &cmd_state) {\n    g_mouse_hotkey_state = nullptr;\n  }\n}\n\nauto register_command(CommandRegistry& registry, CommandDescriptor descriptor) -> void {\n  const std::string id = descriptor.id;\n\n  if (registry.descriptors.contains(id)) {\n    Logger().warn(\"Command already registered: {}\", id);\n    return;\n  }\n\n  registry.descriptors.emplace(id, std::move(descriptor));\n  registry.registration_order.push_back(id);\n\n  Logger().debug(\"Registered command: {}\", id);\n}\n\nauto invoke_command(CommandRegistry& registry, const std::string& id) -> bool {\n  auto it = registry.descriptors.find(id);\n  if (it == registry.descriptors.end()) {\n    Logger().warn(\"Command not found: {}\", id);\n    return false;\n  }\n\n  if (!it->second.action) {\n    Logger().warn(\"Command has no action: {}\", id);\n    return false;\n  }\n\n  try {\n    it->second.action();\n    Logger().debug(\"Invoked command: {}\", id);\n    return true;\n  } catch (const std::exception& e) {\n    Logger().error(\"Failed to invoke command {}: {}\", id, e.what());\n    return false;\n  }\n}\n\nauto get_command(const CommandRegistry& registry, const std::string& id)\n    -> const CommandDescriptor* {\n  auto it = registry.descriptors.find(id);\n  if (it == registry.descriptors.end()) {\n    return nullptr;\n  }\n  return &it->second;\n}\n\nauto get_all_commands(const CommandRegistry& registry) -> std::vector<CommandDescriptor> {\n  std::vector<CommandDescriptor> result;\n  result.reserve(registry.registration_order.size());\n\n  for (const auto& id : registry.registration_order) {\n    auto it = registry.descriptors.find(id);\n    if (it != registry.descriptors.end()) {\n      result.push_back(it->second);\n    }\n  }\n\n  return result;\n}\n\n// 从 settings 获取热键配置（根据 settings_path）\nauto get_hotkey_from_settings(const Core::State::AppState& state, const HotkeyBinding& binding)\n    -> std::pair<UINT, UINT> {\n  const auto& settings = state.settings->raw;\n\n  if (binding.settings_path == \"app.hotkey.floating_window\") {\n    auto result = std::pair{settings.app.hotkey.floating_window.modifiers,\n                            settings.app.hotkey.floating_window.key};\n    Logger().debug(\"Hotkey config for '{}': modifiers={}, key={}\", binding.settings_path,\n                   result.first, result.second);\n    return result;\n  } else if (binding.settings_path == \"app.hotkey.screenshot\") {\n    auto result =\n        std::pair{settings.app.hotkey.screenshot.modifiers, settings.app.hotkey.screenshot.key};\n    Logger().debug(\"Hotkey config for '{}': modifiers={}, key={}\", binding.settings_path,\n                   result.first, result.second);\n    return result;\n  } else if (binding.settings_path == \"app.hotkey.recording\") {\n    auto result =\n        std::pair{settings.app.hotkey.recording.modifiers, settings.app.hotkey.recording.key};\n    Logger().debug(\"Hotkey config for '{}': modifiers={}, key={}\", binding.settings_path,\n                   result.first, result.second);\n    return result;\n  }\n\n  // 如果没有配置，使用默认值\n  Logger().debug(\"Using default hotkey for '{}': modifiers={}, key={}\", binding.settings_path,\n                 binding.modifiers, binding.key);\n  return {binding.modifiers, binding.key};\n}\n\nauto register_all_hotkeys(Core::State::AppState& state, HWND hwnd) -> void {\n  Logger().info(\"=== Starting hotkey registration ===\");\n\n  if (!hwnd) {\n    Logger().error(\"Cannot register hotkeys: HWND is null\");\n    return;\n  }\n\n  Logger().debug(\"HWND for hotkey registration: {}\", reinterpret_cast<void*>(hwnd));\n\n  auto& cmd_state = *state.commands;\n  uninstall_mouse_hotkey_hook(cmd_state);\n  cmd_state.hotkey_to_command.clear();\n  cmd_state.next_hotkey_id = 1;\n\n  Logger().info(\"Total commands in registry: {}\", cmd_state.registry.descriptors.size());\n\n  for (const auto& [id, descriptor] : cmd_state.registry.descriptors) {\n    if (!descriptor.hotkey) {\n      continue;\n    }\n\n    const auto& binding = *descriptor.hotkey;\n    Logger().debug(\"Processing hotkey for command '{}', settings_path='{}'\", id,\n                   binding.settings_path);\n\n    auto [modifiers, key] = get_hotkey_from_settings(state, binding);\n\n    if (key == 0) {\n      Logger().warn(\"Hotkey key is 0 for command '{}', skipping registration\", id);\n      continue;\n    }\n\n    int hotkey_id = cmd_state.next_hotkey_id++;\n\n    if (is_mouse_side_key(key)) {\n      auto combo = make_mouse_combo(modifiers, key);\n      if (cmd_state.mouse_combo_to_hotkey_id.contains(combo)) {\n        Logger().error(\"Duplicate mouse hotkey for command '{}' (modifiers={}, key={}), skipping\",\n                       id, modifiers & (MOD_ALT | MOD_CONTROL | MOD_SHIFT | MOD_WIN), key);\n        continue;\n      }\n\n      cmd_state.hotkey_to_command[hotkey_id] = id;\n      cmd_state.mouse_combo_to_hotkey_id[combo] = hotkey_id;\n\n      Logger().info(\"Registered mouse hotkey {} for command '{}' (modifiers={}, key={})\", hotkey_id,\n                    id, modifiers & (MOD_ALT | MOD_CONTROL | MOD_SHIFT | MOD_WIN), key);\n      continue;\n    }\n\n    if (::RegisterHotKey(hwnd, hotkey_id, modifiers, key)) {\n      cmd_state.hotkey_to_command[hotkey_id] = id;\n      Logger().info(\"Successfully registered hotkey {} for command '{}' (modifiers={}, key={})\",\n                    hotkey_id, id, modifiers, key);\n      continue;\n    }\n\n    DWORD error = ::GetLastError();\n    Logger().error(\n        \"Failed to register hotkey for command '{}' (modifiers={}, key={}), error code: {}\", id,\n        modifiers, key, error);\n  }\n\n  if (!cmd_state.mouse_combo_to_hotkey_id.empty() && !install_mouse_hotkey_hook(cmd_state, hwnd)) {\n    for (const auto& [_, mouse_hotkey_id] : cmd_state.mouse_combo_to_hotkey_id) {\n      cmd_state.hotkey_to_command.erase(mouse_hotkey_id);\n    }\n    cmd_state.mouse_combo_to_hotkey_id.clear();\n    Logger().error(\"Mouse side-button hotkeys disabled because hook installation failed\");\n  }\n\n  Logger().info(\"=== Hotkey registration complete: {} hotkeys registered ===\",\n                cmd_state.hotkey_to_command.size());\n}\n\nauto unregister_all_hotkeys(Core::State::AppState& state, HWND hwnd) -> void {\n  auto& cmd_state = *state.commands;\n  uninstall_mouse_hotkey_hook(cmd_state);\n\n  if (hwnd) {\n    for (const auto& [hotkey_id, _] : cmd_state.hotkey_to_command) {\n      ::UnregisterHotKey(hwnd, hotkey_id);\n    }\n  }\n\n  Logger().info(\"Unregistered {} hotkeys\", cmd_state.hotkey_to_command.size());\n  cmd_state.hotkey_to_command.clear();\n  cmd_state.next_hotkey_id = 1;\n}\n\nauto handle_hotkey(Core::State::AppState& state, int hotkey_id) -> std::optional<std::string> {\n  Logger().debug(\"Received hotkey event, hotkey_id={}\", hotkey_id);\n\n  auto& cmd_state = *state.commands;\n\n  auto it = cmd_state.hotkey_to_command.find(hotkey_id);\n  if (it != cmd_state.hotkey_to_command.end()) {\n    const auto& command_id = it->second;\n    Logger().info(\"Hotkey {} mapped to command '{}', invoking...\", hotkey_id, command_id);\n    invoke_command(cmd_state.registry, command_id);\n    return command_id;\n  }\n\n  Logger().warn(\"Hotkey {} not found in hotkey_to_command map (map size: {})\", hotkey_id,\n                cmd_state.hotkey_to_command.size());\n  return std::nullopt;\n}\n\n}  // namespace Core::Commands\n"
  },
  {
    "path": "src/core/commands/registry.ixx",
    "content": "module;\n\nexport module Core.Commands;\n\nimport std;\nimport Core.State;\nimport Vendor.Windows;\n\nnamespace Core::Commands {\n\n// 热键绑定\nexport struct HotkeyBinding {\n  Vendor::Windows::UINT modifiers = 0;  // MOD_CONTROL=1, MOD_ALT=2, MOD_SHIFT=4\n  Vendor::Windows::UINT key = 0;        // 虚拟键码 (VK_*)\n  std::string settings_path;            // 设置文件中的路径，如 \"app.hotkey.floating_window\"\n};\n\n// 命令描述符\nexport struct CommandDescriptor {\n  std::string id;        // 唯一标识，如 \"screenshot.capture\"\n  std::string i18n_key;  // i18n 键，如 \"menu.screenshot_capture\"\n\n  bool is_toggle = false;  // 是否为切换类型\n\n  std::function<void()> action;               // 点击执行的动作\n  std::function<bool()> get_state = nullptr;  // toggle 类型：获取当前状态\n\n  std::optional<HotkeyBinding> hotkey;  // 热键绑定（可选）\n};\n\n// 运行时命令注册表\nexport struct CommandRegistry {\n  std::unordered_map<std::string, CommandDescriptor> descriptors;\n  std::vector<std::string> registration_order;  // 保持注册顺序\n};\n\n// === API ===\n\n// 注册命令\nexport auto register_command(CommandRegistry& registry, CommandDescriptor descriptor) -> void;\n\n// 调用命令\nexport auto invoke_command(CommandRegistry& registry, const std::string& id) -> bool;\n\n// 获取单个命令描述符（零拷贝，只读）\nexport auto get_command(const CommandRegistry& registry, const std::string& id)\n    -> const CommandDescriptor*;\n\n// 获取所有命令描述符（按注册顺序）\nexport auto get_all_commands(const CommandRegistry& registry) -> std::vector<CommandDescriptor>;\n\n// === RPC Types ===\n\n// 用于 RPC 传输的命令描述符（不包含 function 字段）\nexport struct CommandDescriptorData {\n  std::string id;\n  std::string i18n_key;\n  bool is_toggle;\n};\n\nexport struct GetAllCommandsParams {\n  // 空结构体，未来可扩展\n};\n\nexport struct GetAllCommandsResult {\n  std::vector<CommandDescriptorData> commands;\n};\n\nexport struct InvokeCommandParams {\n  std::string id;\n};\n\nexport struct InvokeCommandResult {\n  bool success = false;\n  std::string message;\n};\n\n// 注册所有内置命令（需要在应用初始化时调用）\nexport auto register_builtin_commands(Core::State::AppState& state, CommandRegistry& registry)\n    -> void;\n\n// 安装常驻全局键盘钩子\nexport auto install_keyboard_keepalive_hook(Core::State::AppState& state) -> void;\n\n// 卸载常驻全局键盘钩子\nexport auto uninstall_keyboard_keepalive_hook(Core::State::AppState& state) -> void;\n\n// === 热键管理 ===\n\n// 注册所有命令的热键\nexport auto register_all_hotkeys(Core::State::AppState& state, Vendor::Windows::HWND hwnd) -> void;\n\n// 注销所有热键\nexport auto unregister_all_hotkeys(Core::State::AppState& state, Vendor::Windows::HWND hwnd)\n    -> void;\n\n// 处理热键消息，返回对应的命令ID（如果找到）\nexport auto handle_hotkey(Core::State::AppState& state, int hotkey_id)\n    -> std::optional<std::string>;\n\n}  // namespace Core::Commands\n"
  },
  {
    "path": "src/core/commands/state.ixx",
    "content": "module;\n\nexport module Core.Commands.State;\n\nimport std;\nimport Core.Commands;\nimport Vendor.Windows;\n\nnamespace Core::Commands::State {\n\nexport struct CommandState {\n  CommandRegistry registry;\n\n  // 常驻低级键盘钩子。\n  // 它不负责业务按键处理，只用于维持进程级全局输入挂载。\n  Vendor::Windows::HHOOK keyboard_keepalive_hook = nullptr;\n\n  // 热键运行时状态\n  std::unordered_map<int, std::string> hotkey_to_command;  // hotkey_id -> command_id\n  int next_hotkey_id = 1;                                  // 下一个可用的热键ID\n\n  // 鼠标侧键热键运行时状态\n  Vendor::Windows::HHOOK mouse_hotkey_hook = nullptr;\n  Vendor::Windows::HWND mouse_hotkey_target_hwnd = nullptr;\n  std::unordered_map<std::uint32_t, int> mouse_combo_to_hotkey_id;  // combo -> hotkey_id\n};\n\n}  // namespace Core::Commands::State\n"
  },
  {
    "path": "src/core/database/data_mapper.ixx",
    "content": "module;\n\nexport module Core.Database.DataMapper;\n\nimport std;\nimport Core.Database.Types;\nimport <SQLiteCpp/SQLiteCpp.h>;\nimport <rfl.hpp>;\n\nnamespace Core::Database::DataMapper {\n\nexport enum class MappingErrorType {\n  field_not_found,\n  type_conversion_failed,\n  validation_failed,\n  null_value_for_required_field\n};\n\nexport struct MappingError {\n  MappingErrorType type;\n  std::string field_name;\n  std::string details;\n\n  auto to_string() const -> std::string {\n    std::string type_str;\n    switch (type) {\n      case MappingErrorType::field_not_found:\n        type_str = \"Field not found\";\n        break;\n      case MappingErrorType::type_conversion_failed:\n        type_str = \"Type conversion failed\";\n        break;\n      case MappingErrorType::validation_failed:\n        type_str = \"Validation failed\";\n        break;\n      case MappingErrorType::null_value_for_required_field:\n        type_str = \"Null value for required field\";\n        break;\n    }\n    return type_str + \" for field '\" + field_name + \"': \" + details;\n  }\n};\n\nexport using MappingResult = std::vector<MappingError>;\n\n// 类型转换器\nexport template <typename T>\nstruct SqliteTypeConverter;\n\n// 基础类型特化\ntemplate <>\nstruct SqliteTypeConverter<int> {\n  static auto from_column(const SQLite::Column& col) -> std::expected<int, std::string> {\n    if (col.isNull()) {\n      return std::unexpected(\"Column is NULL\");\n    }\n    try {\n      return col.getInt();\n    } catch (const SQLite::Exception& e) {\n      return std::unexpected(\"SQLite error: \" + std::string(e.what()));\n    }\n  }\n};\n\ntemplate <>\nstruct SqliteTypeConverter<int64_t> {\n  static auto from_column(const SQLite::Column& col) -> std::expected<int64_t, std::string> {\n    if (col.isNull()) {\n      return std::unexpected(\"Column is NULL\");\n    }\n    try {\n      return col.getInt64();\n    } catch (const SQLite::Exception& e) {\n      return std::unexpected(\"SQLite error: \" + std::string(e.what()));\n    }\n  }\n};\n\ntemplate <>\nstruct SqliteTypeConverter<double> {\n  static auto from_column(const SQLite::Column& col) -> std::expected<double, std::string> {\n    if (col.isNull()) {\n      return std::unexpected(\"Column is NULL\");\n    }\n    try {\n      return col.getDouble();\n    } catch (const SQLite::Exception& e) {\n      return std::unexpected(\"SQLite error: \" + std::string(e.what()));\n    }\n  }\n};\n\ntemplate <>\nstruct SqliteTypeConverter<std::string> {\n  static auto from_column(const SQLite::Column& col) -> std::expected<std::string, std::string> {\n    if (col.isNull()) {\n      return std::unexpected(\"Column is NULL\");\n    }\n    try {\n      return col.getString();\n    } catch (const SQLite::Exception& e) {\n      return std::unexpected(\"SQLite error: \" + std::string(e.what()));\n    }\n  }\n};\n\n// std::optional 特化 - 可以处理NULL值\ntemplate <typename T>\nstruct SqliteTypeConverter<std::optional<T>> {\n  static auto from_column(const SQLite::Column& col)\n      -> std::expected<std::optional<T>, std::string> {\n    if (col.isNull()) {\n      return std::optional<T>{};\n    }\n\n    auto result = SqliteTypeConverter<T>::from_column(col);\n    if (!result) {\n      return std::unexpected(result.error());\n    }\n    return std::optional<T>{std::move(result.value())};\n  }\n};\n\n// 提取字段值\nexport template <typename FieldType>\nauto extract_field_value(SQLite::Statement& stmt, const std::string& field_name)\n    -> std::expected<FieldType, MappingError> {\n  try {\n    // 尝试获取列\n    SQLite::Column col = stmt.getColumn(field_name.c_str());\n\n    // 使用类型转换器\n    auto result = SqliteTypeConverter<FieldType>::from_column(col);\n    if (!result) {\n      return std::unexpected(MappingError{.type = MappingErrorType::type_conversion_failed,\n                                          .field_name = field_name,\n                                          .details = result.error()});\n    }\n\n    return result.value();\n\n  } catch (const SQLite::Exception& e) {\n    // 字段不存在或其他SQLite错误\n    return std::unexpected(MappingError{.type = MappingErrorType::field_not_found,\n                                        .field_name = field_name,\n                                        .details = std::string(e.what())});\n  }\n}\n\n// 主要接口 - 对象构建器\nexport template <typename T>\nauto from_statement(SQLite::Statement& query) -> std::expected<T, std::string> {\n  T object{};\n  std::vector<MappingError> errors;\n\n  // 使用 rfl 遍历结构体的所有字段\n  rfl::to_view(object).apply([&](auto field) {\n    const std::string field_name = std::string(field.name());\n    using FieldValueType = std::remove_cvref_t<decltype(*field.value())>;\n\n    // 直接提取字段值，使用字段名\n    auto result = extract_field_value<FieldValueType>(query, field_name);\n    if (result) {\n      *field.value() = std::move(result.value());\n    } else {\n      errors.push_back(result.error());\n    }\n  });\n\n  // 如果有错误，合并错误信息\n  if (!errors.empty()) {\n    std::string error_message = \"Found \" + std::to_string(errors.size()) + \" errors:\\n\";\n    for (size_t i = 0; i < errors.size(); ++i) {\n      error_message += std::to_string(i + 1) + \") \" + errors[i].to_string() + \"\\n\";\n    }\n    return std::unexpected(error_message);\n  }\n\n  return object;\n}\n}  // namespace Core::Database::DataMapper"
  },
  {
    "path": "src/core/database/database.cpp",
    "content": "module;\n\nmodule Core.Database;\n\nimport std;\nimport Core.Database.DataMapper;\nimport Core.Database.State;\nimport Core.Database.Types;\nimport Utils.Logger;\nimport Utils.Path;\nimport <SQLiteCpp/SQLiteCpp.h>;\n\nnamespace Core::Database {\n\n// 获取当前线程的数据库连接。如果连接不存在，则创建它。\nauto get_connection(const std::filesystem::path& db_path) -> SQLite::Database& {\n  if (!thread_connection) {\n    thread_connection = std::make_unique<SQLite::Database>(\n        db_path.string(), SQLite::OPEN_READWRITE | SQLite::OPEN_CREATE);\n    // 设置 WAL 模式以提高并发性能\n    thread_connection->exec(\"PRAGMA journal_mode=WAL;\");\n    thread_connection->exec(\"PRAGMA synchronous=NORMAL;\");\n    // SQLite 外键约束默认可能关闭，需要按连接显式启用。\n    thread_connection->exec(\"PRAGMA foreign_keys=ON;\");\n  }\n  return *thread_connection;\n}\n\n// 初始化数据库，现在只负责配置路径和进行初始连接测试\nauto initialize(State::DatabaseState& state, const std::filesystem::path& db_path)\n    -> std::expected<void, std::string> {\n  try {\n    // 保存数据库路径\n    state.db_path = db_path;\n\n    // 确保父目录存在\n    if (db_path.has_parent_path()) {\n      std::filesystem::create_directories(db_path.parent_path());\n    }\n\n    // 在主线程上尝试创建一个连接，以验证数据库路径和配置\n    get_connection(db_path);\n\n    Logger().info(\"Database configured successfully: {}\", db_path.string());\n    return {};\n\n  } catch (const SQLite::Exception& e) {\n    Logger().error(\"Cannot open database: {} - Error: {}\", db_path.string(), e.what());\n    return std::unexpected(std::string(\"Cannot open database: \") + e.what());\n  } catch (const std::exception& e) {\n    Logger().error(\"Cannot open database: {} - Error: {}\", db_path.string(), e.what());\n    return std::unexpected(std::string(\"Cannot open database: \") + e.what());\n  }\n}\n\n// 关闭当前线程的数据库连接\nauto close(State::DatabaseState& state) -> void {\n  if (thread_connection) {\n    thread_connection.reset();\n    Logger().info(\"Database connection closed for the current thread: {}\", state.db_path.string());\n  }\n}\n\n// 执行非查询操作 (INSERT, UPDATE, DELETE)\nauto execute(State::DatabaseState& state, const std::string& sql)\n    -> std::expected<void, std::string> {\n  try {\n    auto& connection = get_connection(state.db_path);\n    connection.exec(sql);\n    return {};\n  } catch (const SQLite::Exception& e) {\n    Logger().error(\"Failed to execute statement: {} - Error: {}\", sql, e.what());\n    return std::unexpected(std::string(\"Failed to execute statement: \") + e.what());\n  }\n}\n\nauto execute(State::DatabaseState& state, const std::string& sql,\n             const std::vector<Types::DbParam>& params) -> std::expected<void, std::string> {\n  try {\n    auto& connection = get_connection(state.db_path);\n    SQLite::Statement query(connection, sql);\n\n    // 绑定参数\n    for (size_t i = 0; i < params.size(); ++i) {\n      const auto& param = params[i];\n      int param_index = static_cast<int>(i + 1);  // SQLite 参数是 1-based 索引\n\n      std::visit(\n          [&query, param_index](auto&& arg) {\n            using T = std::decay_t<decltype(arg)>;\n            if constexpr (std::is_same_v<T, std::monostate>) {\n              query.bind(param_index);  // 绑定 NULL\n            } else if constexpr (std::is_same_v<T, std::vector<std::uint8_t>>) {\n              query.bind(param_index, arg.data(), static_cast<int>(arg.size()));  // 绑定 BLOB\n            } else {\n              // 通过重载方法绑定 int64_t, double, std::string\n              query.bind(param_index, arg);\n            }\n          },\n          param);\n    }\n\n    query.exec();  // 对不返回结果的语句使用 exec()\n    return {};\n  } catch (const SQLite::Exception& e) {\n    Logger().error(\"Failed to execute statement: {} - Error: {}\", sql, e.what());\n    return std::unexpected(std::string(\"Failed to execute statement: \") + e.what());\n  }\n}\n\n}  // namespace Core::Database\n"
  },
  {
    "path": "src/core/database/database.ixx",
    "content": "module;\n\nexport module Core.Database;\n\nimport std;\nimport Core.Database.State;\nimport Core.Database.Types;\nimport Core.Database.DataMapper;\nimport <SQLiteCpp/SQLiteCpp.h>;\n\nnamespace Core::Database {\n\n// 线程局部存储，为每个线程维护一个独立的数据库连接\nthread_local std::unique_ptr<SQLite::Database> thread_connection;\n\n// 获取当前线程的数据库连接。如果连接不存在，则创建它。\nauto get_connection(const std::filesystem::path& db_path) -> SQLite::Database&;\n\n// 初始化数据库连接\nexport auto initialize(State::DatabaseState& state, const std::filesystem::path& db_path)\n    -> std::expected<void, std::string>;\n\n// 关闭数据库连接，理论上非必要，thread_connection 会在 thread_exit 时自动关闭\nexport auto close(State::DatabaseState& state) -> void;\n\n// 执行非查询操作 (INSERT, UPDATE, DELETE)\nexport auto execute(State::DatabaseState& state, const std::string& sql)\n    -> std::expected<void, std::string>;\nexport auto execute(State::DatabaseState& state, const std::string& sql,\n                    const std::vector<Types::DbParam>& params) -> std::expected<void, std::string>;\n\n// 查询返回多个结果 (SELECT)\nexport template <typename T>\nauto query(State::DatabaseState& state, const std::string& sql,\n           const std::vector<Types::DbParam>& params = {})\n    -> std::expected<std::vector<T>, std::string> {\n  try {\n    auto& connection = get_connection(state.db_path);\n    SQLite::Statement query(connection, sql);\n\n    // 绑定参数\n    for (size_t i = 0; i < params.size(); ++i) {\n      const auto& param = params[i];\n      int param_index = static_cast<int>(i + 1);  // SQLite 参数是 1-based 索引\n\n      std::visit(\n          [&query, param_index](auto&& arg) {\n            using T = std::decay_t<decltype(arg)>;\n            if constexpr (std::is_same_v<T, std::monostate>) {\n              query.bind(param_index);  // 绑定 NULL\n            } else if constexpr (std::is_same_v<T, std::vector<std::uint8_t>>) {\n              query.bind(param_index, arg.data(), static_cast<int>(arg.size()));  // 绑定 BLOB\n            } else {\n              // 通过重载方法绑定 int64_t, double, std::string\n              query.bind(param_index, arg);\n            }\n          },\n          param);\n    }\n\n    std::vector<T> results;\n    while (query.executeStep()) {\n      auto mapped_object = DataMapper::from_statement<T>(query);\n      if (mapped_object) {\n        results.push_back(std::move(*mapped_object));\n      } else {\n        return std::unexpected(\"Failed to map row to object: \" + mapped_object.error());\n      }\n    }\n    return results;\n  } catch (const SQLite::Exception& e) {\n    return std::unexpected(\"SQLite error: \" + std::string(e.what()));\n  } catch (const std::exception& e) {\n    return std::unexpected(\"Generic error: \" + std::string(e.what()));\n  }\n}\n\n// 查询返回单个结果 (SELECT)\nexport template <typename T>\nauto query_single(State::DatabaseState& state, const std::string& sql,\n                  const std::vector<Types::DbParam>& params = {})\n    -> std::expected<std::optional<T>, std::string> {\n  auto results = query<T>(state, sql, params);\n  if (!results) {\n    return std::unexpected(results.error());\n  }\n  if (results->empty()) {\n    return std::optional<T>{};\n  }\n  if (results->size() > 1) {\n    // 或记录警告，具体取决于所需的严格性\n    return std::unexpected(\"Query for single result returned multiple rows.\");\n  }\n  return std::move(results->front());\n}\n\n// 查询返回单个标量值\nexport template <typename T>\nauto query_scalar(State::DatabaseState& state, const std::string& sql,\n                  const std::vector<Types::DbParam>& params = {})\n    -> std::expected<std::optional<T>, std::string> {\n  try {\n    auto& connection = get_connection(state.db_path);\n    SQLite::Statement query(connection, sql);\n\n    for (size_t i = 0; i < params.size(); ++i) {\n      const auto& param = params[i];\n      int param_index = static_cast<int>(i + 1);\n      std::visit(\n          [&query, param_index](auto&& arg) {\n            using U = std::decay_t<decltype(arg)>;\n            if constexpr (std::is_same_v<U, std::monostate>) {\n              query.bind(param_index);\n            } else if constexpr (std::is_same_v<U, std::vector<std::uint8_t>>) {\n              query.bind(param_index, arg.data(), static_cast<int>(arg.size()));\n            } else {\n              query.bind(param_index, arg);\n            }\n          },\n          param);\n    }\n\n    if (query.executeStep()) {\n      SQLite::Column col = query.getColumn(0);\n      if (col.isNull()) {\n        return std::optional<T>{};\n      }\n      if constexpr (std::is_same_v<T, int>) {\n        return col.getInt();\n      } else if constexpr (std::is_same_v<T, int64_t>) {\n        return col.getInt64();\n      } else if constexpr (std::is_same_v<T, double>) {\n        return col.getDouble();\n      } else if constexpr (std::is_same_v<T, std::string>) {\n        return col.getString();\n      } else {\n        // 不支持的类型\n        static_assert(sizeof(T) == 0, \"Unsupported type for query_scalar\");\n      }\n    }\n\n    return std::optional<T>{};  // 没有结果\n  } catch (const SQLite::Exception& e) {\n    return std::unexpected(\"SQLite error: \" + std::string(e.what()));\n  } catch (const std::exception& e) {\n    return std::unexpected(\"Generic error: \" + std::string(e.what()));\n  }\n}\n\n// 批量INSERT操作（自动分批处理）\nexport template <typename T, typename ParamExtractor>\nauto execute_batch_insert(State::DatabaseState& state, const std::string& insert_prefix,\n                          const std::string& values_placeholder, const std::vector<T>& items,\n                          ParamExtractor param_extractor, size_t max_params_per_batch = 999)\n    -> std::expected<std::vector<int64_t>, std::string> {\n  if (items.empty()) {\n    return std::vector<int64_t>{};\n  }\n\n  // 计算每个item需要的参数数量\n  auto sample_params = param_extractor(items[0]);\n  size_t params_per_item = sample_params.size();\n  size_t max_items_per_batch = max_params_per_batch / params_per_item;\n\n  if (max_items_per_batch == 0) {\n    return std::unexpected(\"Single item exceeds maximum parameter limit\");\n  }\n\n  std::vector<int64_t> all_inserted_ids;\n  all_inserted_ids.reserve(items.size());\n\n  return execute_transaction(\n      state,\n      [&](State::DatabaseState& db_state) -> std::expected<std::vector<int64_t>, std::string> {\n        for (size_t batch_start = 0; batch_start < items.size();\n             batch_start += max_items_per_batch) {\n          size_t batch_end = std::min(batch_start + max_items_per_batch, items.size());\n          size_t batch_size = batch_end - batch_start;\n\n          // 构建当前批次的SQL\n          std::string batch_sql = insert_prefix;\n          std::vector<std::string> value_clauses;\n          std::vector<Types::DbParam> all_params;\n\n          value_clauses.reserve(batch_size);\n          all_params.reserve(batch_size * params_per_item);\n\n          for (size_t i = batch_start; i < batch_end; ++i) {\n            value_clauses.push_back(values_placeholder);\n            auto item_params = param_extractor(items[i]);\n            all_params.insert(all_params.end(), item_params.begin(), item_params.end());\n          }\n\n          // 合并VALUES子句\n          for (size_t i = 0; i < value_clauses.size(); ++i) {\n            if (i > 0) batch_sql += \", \";\n            batch_sql += value_clauses[i];\n          }\n\n          auto result = execute(db_state, batch_sql, all_params);\n          if (!result) {\n            return std::unexpected(\"Batch insert failed: \" + result.error());\n          }\n\n          // 获取插入的ID范围\n          auto last_id_result = query_scalar<int64_t>(db_state, \"SELECT last_insert_rowid()\");\n          if (!last_id_result || !last_id_result->has_value()) {\n            return std::unexpected(\"Failed to get last insert ID\");\n          }\n\n          int64_t last_id = last_id_result->value();\n          int64_t first_id = last_id - static_cast<int64_t>(batch_size) + 1;\n\n          // 添加当前批次的ID到结果中\n          for (int64_t id = first_id; id <= last_id; ++id) {\n            all_inserted_ids.push_back(id);\n          }\n        }\n        return all_inserted_ids;\n      });\n}\n\n// 事务管理\nexport template <typename Func>\nauto execute_transaction(State::DatabaseState& state, Func&& func) -> decltype(auto) {\n  try {\n    auto& connection = get_connection(state.db_path);\n    SQLite::Transaction transaction(connection);\n\n    // 执行用户提供的事务逻辑\n    auto result = func(state);\n    if (!result) {\n      // 如果函数返回错误，事务会在transaction析构时自动回滚\n      return result;\n    }\n\n    // 提交事务\n    transaction.commit();\n    return result;\n  } catch (const SQLite::Exception& e) {\n    // 异常发生时，transaction析构会自动回滚\n    using ReturnType = std::decay_t<decltype(func(state))>;\n    return ReturnType{std::unexpected(\"Transaction failed: \" + std::string(e.what()))};\n  } catch (const std::exception& e) {\n    using ReturnType = std::decay_t<decltype(func(state))>;\n    return ReturnType{std::unexpected(\"Transaction error: \" + std::string(e.what()))};\n  }\n}\n\n}  // namespace Core::Database"
  },
  {
    "path": "src/core/database/state.ixx",
    "content": "module;\n\nexport module Core.Database.State;\n\nimport std;\n\nexport namespace Core::Database::State {\n\nstruct DatabaseState {\n  // 存储数据库文件路径\n  std::filesystem::path db_path;\n};\n\n}  // namespace Core::Database::State"
  },
  {
    "path": "src/core/database/types.ixx",
    "content": "module;\n\nexport module Core.Database.Types;\n\nimport std;\n\nnamespace Core::Database::Types {\n// 代表数据库中的一个值，可以是NULL、整数、浮点数、字符串或二进制数据\nexport using DbValue =\n    std::variant<std::monostate, std::int64_t, double, std::string, std::vector<std::uint8_t>>;\n\n// 用于参数化查询的参数类型\nexport using DbParam = DbValue;\n\n}  // namespace Core::Database::Types"
  },
  {
    "path": "src/core/dialog_service/dialog_service.cpp",
    "content": "module;\n\n#include <wil/com.h>\n\nmodule Core.DialogService;\n\nimport std;\nimport Core.DialogService.State;\nimport Core.State;\nimport Utils.Dialog;\nimport Utils.Logger;\n\nnamespace Core::DialogService {\n\nnamespace Detail {\n\nauto run_worker_loop(Core::DialogService::State::DialogServiceState& service,\n                     std::stop_token stop_token) -> void {\n  auto com_init = wil::CoInitializeEx(COINIT_APARTMENTTHREADED | COINIT_DISABLE_OLE1DDE);\n\n  while (!stop_token.stop_requested()) {\n    std::function<void()> task;\n\n    {\n      std::unique_lock lock(service.queue_mutex);\n      service.condition.wait(lock, [&service, &stop_token] {\n        return stop_token.stop_requested() || service.shutdown_requested.load() ||\n               !service.task_queue.empty();\n      });\n\n      if ((stop_token.stop_requested() || service.shutdown_requested.load()) &&\n          service.task_queue.empty()) {\n        break;\n      }\n\n      if (!service.task_queue.empty()) {\n        task = std::move(service.task_queue.front());\n        service.task_queue.pop();\n      }\n    }\n\n    if (!task) {\n      continue;\n    }\n\n    try {\n      task();\n    } catch (const std::exception& e) {\n      Logger().error(\"DialogService task execution error: {}\", e.what());\n    } catch (...) {\n      Logger().error(\"DialogService task execution unknown error\");\n    }\n  }\n}\n\ntemplate <typename Result>\nusing DialogResult = std::expected<Result, std::string>;\n\ntemplate <typename Result>\nauto submit_dialog_task(Core::DialogService::State::DialogServiceState& service,\n                        std::function<DialogResult<Result>()> task) -> DialogResult<Result> {\n  if (!service.is_running.load()) {\n    return std::unexpected(\"DialogService is not running\");\n  }\n\n  if (service.shutdown_requested.load()) {\n    return std::unexpected(\"DialogService is shutting down\");\n  }\n\n  auto promise = std::make_shared<std::promise<DialogResult<Result>>>();\n  auto future = promise->get_future();\n\n  try {\n    {\n      std::lock_guard lock(service.queue_mutex);\n      service.task_queue.push([task = std::move(task), promise]() mutable {\n        try {\n          promise->set_value(task());\n        } catch (const std::exception& e) {\n          promise->set_value(std::unexpected(std::string(\"Dialog task failed: \") + e.what()));\n        } catch (...) {\n          promise->set_value(std::unexpected(\"Dialog task failed: unknown error\"));\n        }\n      });\n    }\n\n    service.condition.notify_one();\n    return future.get();\n  } catch (const std::exception& e) {\n    return std::unexpected(std::string(\"Failed to submit dialog task: \") + e.what());\n  }\n}\n\n}  // namespace Detail\n\nauto start(Core::DialogService::State::DialogServiceState& service)\n    -> std::expected<void, std::string> {\n  if (service.is_running.exchange(true)) {\n    Logger().warn(\"DialogService already started\");\n    return std::unexpected(\"DialogService already started\");\n  }\n\n  try {\n    service.shutdown_requested = false;\n    service.worker_thread = std::jthread(\n        [&service](std::stop_token stop_token) { Detail::run_worker_loop(service, stop_token); });\n\n    Logger().info(\"DialogService started successfully\");\n    return {};\n  } catch (const std::exception& e) {\n    service.is_running = false;\n    service.shutdown_requested = false;\n    return std::unexpected(std::string(\"Failed to start DialogService: \") + e.what());\n  }\n}\n\nauto stop(Core::DialogService::State::DialogServiceState& service) -> void {\n  if (!service.is_running.exchange(false)) {\n    return;\n  }\n\n  Logger().info(\"Stopping DialogService\");\n\n  service.shutdown_requested = true;\n  service.condition.notify_all();\n\n  if (service.worker_thread.joinable()) {\n    service.worker_thread.request_stop();\n    service.worker_thread.join();\n  }\n\n  {\n    std::lock_guard lock(service.queue_mutex);\n    std::queue<std::function<void()>> empty;\n    service.task_queue.swap(empty);\n  }\n\n  service.shutdown_requested = false;\n  Logger().info(\"DialogService stopped\");\n}\n\nauto open_file(Core::State::AppState& state, const Utils::Dialog::FileSelectorParams& params,\n               HWND hwnd) -> std::expected<Utils::Dialog::FileSelectorResult, std::string> {\n  if (!state.dialog_service) {\n    return std::unexpected(\"DialogService state is not initialized\");\n  }\n\n  return Detail::submit_dialog_task<Utils::Dialog::FileSelectorResult>(\n      *state.dialog_service, [params, hwnd]() { return Utils::Dialog::select_file(params, hwnd); });\n}\n\nauto open_folder(Core::State::AppState& state, const Utils::Dialog::FolderSelectorParams& params,\n                 HWND hwnd) -> std::expected<Utils::Dialog::FolderSelectorResult, std::string> {\n  if (!state.dialog_service) {\n    return std::unexpected(\"DialogService state is not initialized\");\n  }\n\n  return Detail::submit_dialog_task<Utils::Dialog::FolderSelectorResult>(\n      *state.dialog_service,\n      [params, hwnd]() { return Utils::Dialog::select_folder(params, hwnd); });\n}\n\n}  // namespace Core::DialogService\n"
  },
  {
    "path": "src/core/dialog_service/dialog_service.ixx",
    "content": "module;\n\nexport module Core.DialogService;\n\nimport std;\nimport Core.State;\nimport Core.DialogService.State;\nimport Utils.Dialog;\nimport Vendor.Windows;\n\nnamespace Core::DialogService {\n\nexport auto start(Core::DialogService::State::DialogServiceState& service)\n    -> std::expected<void, std::string>;\n\nexport auto stop(Core::DialogService::State::DialogServiceState& service) -> void;\n\nexport auto open_file(Core::State::AppState& state, const Utils::Dialog::FileSelectorParams& params,\n                      Vendor::Windows::HWND hwnd = nullptr)\n    -> std::expected<Utils::Dialog::FileSelectorResult, std::string>;\n\nexport auto open_folder(Core::State::AppState& state,\n                        const Utils::Dialog::FolderSelectorParams& params,\n                        Vendor::Windows::HWND hwnd = nullptr)\n    -> std::expected<Utils::Dialog::FolderSelectorResult, std::string>;\n\n}  // namespace Core::DialogService\n"
  },
  {
    "path": "src/core/dialog_service/state.ixx",
    "content": "module;\n\nexport module Core.DialogService.State;\n\nimport std;\n\nnamespace Core::DialogService::State {\n\nexport struct DialogServiceState {\n  std::jthread worker_thread;\n\n  std::queue<std::function<void()>> task_queue;\n  std::mutex queue_mutex;\n  std::condition_variable condition;\n\n  std::atomic<bool> is_running{false};\n  std::atomic<bool> shutdown_requested{false};\n};\n\n}  // namespace Core::DialogService::State\n"
  },
  {
    "path": "src/core/events/events.cpp",
    "content": "module;\r\n\r\nmodule Core.Events;\r\n\r\nimport std;\r\n\r\nnamespace Core::Events {\r\n\r\nauto process_events(State::EventsState& bus) -> void {\r\n  std::queue<std::pair<std::type_index, std::any>> events_to_process;\r\n\r\n  // 快速获取事件队列的副本，减少锁的持有时间\r\n  {\r\n    std::lock_guard<std::mutex> lock(bus.queue_mutex);\r\n    if (bus.event_queue.empty()) {\r\n      return;\r\n    }\r\n    events_to_process.swap(bus.event_queue);\r\n  }\r\n\r\n  // 处理所有事件\r\n  while (!events_to_process.empty()) {\r\n    const auto& [type_index, event_data] = events_to_process.front();\r\n\r\n    // 查找并调用对应类型的处理器\r\n    if (auto it = bus.handlers.find(type_index); it != bus.handlers.end()) {\r\n      for (const auto& handler : it->second) {\r\n        try {\r\n          handler(event_data);\r\n        } catch (const std::exception& e) {\r\n          // 异常处理暂时省略，避免循环依赖\r\n        }\r\n      }\r\n    }\r\n\r\n    events_to_process.pop();\r\n  }\r\n}\r\n\r\nauto clear_events(State::EventsState& bus) -> void {\r\n  std::lock_guard<std::mutex> lock(bus.queue_mutex);\r\n  bus.event_queue = {};  // 清空\r\n}\r\n\r\n}  // namespace Core::Events"
  },
  {
    "path": "src/core/events/events.ixx",
    "content": "module;\r\n\r\nexport module Core.Events;\r\n\r\nimport std;\r\nimport Core.Events.State;\r\nimport <windows.h>;\r\n\r\nnamespace Core::Events {\r\n\r\n// Custom message for UI thread wake-up to process async events\r\nexport constexpr UINT kWM_APP_PROCESS_EVENTS = WM_APP + 1;\r\n\r\n// 同步发送事件\r\nexport template <typename T>\r\nauto send(State::EventsState& bus, const T& event) -> void {\r\n  auto key = std::type_index(typeid(T));\r\n  if (auto it = bus.handlers.find(key); it != bus.handlers.end()) {\r\n    for (const auto& handler : it->second) {\r\n      try {\r\n        handler(std::any(event));\r\n      } catch (const std::exception& e) {\r\n        // 异常处理暂时省略，避免循环依赖\r\n      }\r\n    }\r\n  }\r\n}\r\n\r\n// 异步投递事件\r\nexport template <typename T>\r\nauto post(State::EventsState& bus, T event) -> void {\r\n  {\r\n    std::lock_guard<std::mutex> lock(bus.queue_mutex);\r\n    auto key = std::type_index(typeid(T));\r\n    bus.event_queue.emplace(key, std::any(std::move(event)));\r\n  }\r\n  // Wake up UI thread to process events\r\n  if (bus.notify_hwnd) {\r\n    ::PostMessageW(bus.notify_hwnd, kWM_APP_PROCESS_EVENTS, 0, 0);\r\n  }\r\n}\r\n\r\n// 订阅事件\r\nexport template <typename T>\r\nauto subscribe(State::EventsState& bus, std::function<void(const T&)> handler) -> void {\r\n  if (handler) {\r\n    auto key = std::type_index(typeid(T));\r\n    bus.handlers[key].emplace_back([handler = std::move(handler)](const std::any& data) {\r\n      try {\r\n        const T& event = std::any_cast<const T&>(data);\r\n        handler(event);\r\n      } catch (const std::bad_any_cast& e) {\r\n        // 类型转换错误，暂时忽略\r\n      }\r\n    });\r\n  }\r\n}\r\n\r\n// 处理队列中的事件（在消息循环中调用）\r\nexport auto process_events(State::EventsState& bus) -> void;\r\n\r\n// 清空事件队列\r\nexport auto clear_events(State::EventsState& bus) -> void;\r\n\r\n}  // namespace Core::Events"
  },
  {
    "path": "src/core/events/handlers/feature_handlers.cpp",
    "content": "module;\n\nmodule Core.Events.Handlers.Feature;\n\nimport std;\nimport Core.Events;\nimport Core.State;\nimport UI.FloatingWindow;\nimport UI.FloatingWindow.Events;\nimport Features.Screenshot.UseCase;\nimport Features.WindowControl.UseCase;\nimport Features.Notifications;\n\nnamespace Core::Events::Handlers {\n\n// 注册功能相关的事件处理器\n// 注:大部分功能已迁移至命令系统(Core.Commands)\n// 此处仅保留通过热键/系统事件触发的处理器\nauto register_feature_handlers(Core::State::AppState& app_state) -> void {\n  using namespace Core::Events;\n\n  // === 截图功能 ===\n  // 通过热键触发的截图事件\n  subscribe<UI::FloatingWindow::Events::CaptureEvent>(\n      *app_state.events, [&app_state](const UI::FloatingWindow::Events::CaptureEvent& event) {\n        Features::Screenshot::UseCase::handle_capture_event(app_state, event);\n      });\n\n  // === 窗口控制功能 ===\n  // 通过UI菜单选择触发的窗口调整事件\n  // 注：handle_xxx 会启动协程在 UI 线程执行，协程内部会在完成后请求重绘\n  subscribe<UI::FloatingWindow::Events::RatioChangeEvent>(\n      *app_state.events, [&app_state](const UI::FloatingWindow::Events::RatioChangeEvent& event) {\n        Features::WindowControl::UseCase::handle_ratio_changed(app_state, event);\n      });\n\n  subscribe<UI::FloatingWindow::Events::ResolutionChangeEvent>(\n      *app_state.events,\n      [&app_state](const UI::FloatingWindow::Events::ResolutionChangeEvent& event) {\n        Features::WindowControl::UseCase::handle_resolution_changed(app_state, event);\n      });\n\n  subscribe<UI::FloatingWindow::Events::WindowSelectionEvent>(\n      *app_state.events,\n      [&app_state](const UI::FloatingWindow::Events::WindowSelectionEvent& event) {\n        Features::WindowControl::UseCase::handle_window_selected(app_state, event);\n      });\n\n  // 录制状态由后台线程切换完成后，触发悬浮窗重绘以更新 toggle 显示\n  subscribe<UI::FloatingWindow::Events::RecordingToggleEvent>(\n      *app_state.events, [&app_state](const UI::FloatingWindow::Events::RecordingToggleEvent&) {\n        UI::FloatingWindow::request_repaint(app_state);\n      });\n\n  // === 通知功能 ===\n  // 跨线程安全的通知显示（由 WorkerPool 等工作线程 post 事件，UI 线程处理）\n  subscribe<UI::FloatingWindow::Events::NotificationEvent>(\n      *app_state.events, [&app_state](const UI::FloatingWindow::Events::NotificationEvent& event) {\n        Features::Notifications::show_notification(app_state, event.title, event.message);\n      });\n}\n\n}  // namespace Core::Events::Handlers\n"
  },
  {
    "path": "src/core/events/handlers/feature_handlers.ixx",
    "content": "module;\n\nexport module Core.Events.Handlers.Feature;\n\nimport Core.State;\n\nnamespace Core::Events::Handlers {\n\nexport auto register_feature_handlers(Core::State::AppState& app_state) -> void;\n\n}"
  },
  {
    "path": "src/core/events/handlers/settings_handlers.cpp",
    "content": "module;\n\nmodule Core.Events.Handlers.Settings;\n\nimport std;\nimport Core.Events;\nimport Core.RPC.NotificationHub;\nimport Core.State;\nimport Core.Commands;\nimport Core.I18n;\nimport Core.WebView;\nimport Features.Gallery;\nimport Features.Settings.Events;\nimport Features.Settings.Types;\nimport Extensions.InfinityNikki.PhotoService;\nimport Extensions.InfinityNikki.TaskService;\nimport UI.FloatingWindow;\nimport UI.FloatingWindow.State;\nimport UI.WebViewWindow;\nimport Utils.Logger;\n\nnamespace Core::Events::Handlers {\n\nauto has_hotkey_changes(const Features::Settings::Types::AppSettings& old_settings,\n                        const Features::Settings::Types::AppSettings& new_settings) -> bool {\n  const auto& old_hotkey = old_settings.app.hotkey;\n  const auto& new_hotkey = new_settings.app.hotkey;\n\n  return old_hotkey.floating_window.modifiers != new_hotkey.floating_window.modifiers ||\n         old_hotkey.floating_window.key != new_hotkey.floating_window.key ||\n         old_hotkey.screenshot.modifiers != new_hotkey.screenshot.modifiers ||\n         old_hotkey.screenshot.key != new_hotkey.screenshot.key ||\n         old_hotkey.recording.modifiers != new_hotkey.recording.modifiers ||\n         old_hotkey.recording.key != new_hotkey.recording.key;\n}\n\nauto refresh_global_hotkeys(Core::State::AppState& state) -> void {\n  if (!state.floating_window || !state.floating_window->window.hwnd) {\n    Logger().warn(\"Skip hotkey refresh: floating window handle is not ready\");\n    return;\n  }\n\n  auto hwnd = state.floating_window->window.hwnd;\n  Core::Commands::unregister_all_hotkeys(state, hwnd);\n  Core::Commands::register_all_hotkeys(state, hwnd);\n  Logger().info(\"Global hotkeys refreshed from latest settings\");\n}\n\nauto has_webview_host_mode_changes(const Features::Settings::Types::AppSettings& old_settings,\n                                   const Features::Settings::Types::AppSettings& new_settings)\n    -> bool {\n  return old_settings.ui.webview_window.enable_transparent_background !=\n         new_settings.ui.webview_window.enable_transparent_background;\n}\n\nauto has_webview_theme_mode_changes(const Features::Settings::Types::AppSettings& old_settings,\n                                    const Features::Settings::Types::AppSettings& new_settings)\n    -> bool {\n  return old_settings.ui.web_theme.mode != new_settings.ui.web_theme.mode;\n}\n\nauto has_language_changes(const Features::Settings::Types::AppSettings& old_settings,\n                          const Features::Settings::Types::AppSettings& new_settings) -> bool {\n  return old_settings.app.language.current != new_settings.app.language.current;\n}\n\nauto has_logger_level_changes(const Features::Settings::Types::AppSettings& old_settings,\n                              const Features::Settings::Types::AppSettings& new_settings) -> bool {\n  return old_settings.app.logger.level != new_settings.app.logger.level;\n}\n\nauto has_infinity_nikki_hardlink_setting_changes(\n    const Features::Settings::Types::AppSettings& old_settings,\n    const Features::Settings::Types::AppSettings& new_settings) -> bool {\n  const auto& old_config = old_settings.extensions.infinity_nikki;\n  const auto& new_config = new_settings.extensions.infinity_nikki;\n\n  return old_config.enable != new_config.enable || old_config.game_dir != new_config.game_dir ||\n         old_config.gallery_guide_seen != new_config.gallery_guide_seen ||\n         old_config.manage_screenshot_hardlinks != new_config.manage_screenshot_hardlinks;\n}\n\nauto should_start_infinity_nikki_hardlinks_initialization(\n    const Features::Settings::Types::AppSettings& old_settings,\n    const Features::Settings::Types::AppSettings& new_settings) -> bool {\n  const auto& old_config = old_settings.extensions.infinity_nikki;\n  const auto& new_config = new_settings.extensions.infinity_nikki;\n\n  if (!new_config.enable || new_config.game_dir.empty() || !new_config.gallery_guide_seen ||\n      !new_config.manage_screenshot_hardlinks) {\n    return false;\n  }\n\n  return (!old_config.enable && new_config.enable) || old_config.game_dir != new_config.game_dir ||\n         (!old_config.gallery_guide_seen && new_config.gallery_guide_seen) ||\n         (!old_config.manage_screenshot_hardlinks && new_config.manage_screenshot_hardlinks);\n}\n\nauto apply_runtime_language_from_settings(Core::State::AppState& state,\n                                          const Features::Settings::Types::AppSettings& settings)\n    -> void {\n  if (!state.i18n) {\n    Logger().warn(\"Skip runtime language sync: i18n state is not ready\");\n    return;\n  }\n\n  const auto& locale = settings.app.language.current;\n  if (auto result = Core::I18n::load_language_by_locale(*state.i18n, locale); !result) {\n    Logger().warn(\"Failed to apply runtime language ('{}'): {}\", locale, result.error());\n    return;\n  }\n\n  Logger().info(\"Runtime language switched to {}\", locale);\n}\n\nauto apply_runtime_logger_level_from_settings(\n    Core::State::AppState& state, const Features::Settings::Types::AppSettings& settings) -> void {\n  const auto& level = settings.app.logger.level;\n  if (auto result = Utils::Logging::set_level(level); !result) {\n    Logger().warn(\"Failed to apply runtime logger level ('{}'): {}\", level, result.error());\n    return;\n  }\n\n  Logger().debug(\"Runtime logger level switched to {}\", level);\n}\n\n// 处理设置变更事件\nauto handle_settings_changed(Core::State::AppState& state,\n                             const Features::Settings::Events::SettingsChangeEvent& event) -> void {\n  try {\n    Logger().info(\"Settings changed: {}\", event.data.change_description);\n\n    auto output_directory_changed = event.data.old_settings.features.output_dir_path !=\n                                    event.data.new_settings.features.output_dir_path;\n\n    if (has_language_changes(event.data.old_settings, event.data.new_settings)) {\n      apply_runtime_language_from_settings(state, event.data.new_settings);\n    }\n\n    if (has_logger_level_changes(event.data.old_settings, event.data.new_settings)) {\n      apply_runtime_logger_level_from_settings(state, event.data.new_settings);\n    }\n\n    // 通知浮窗刷新UI以反映设置变更\n    UI::FloatingWindow::refresh_from_settings(state);\n\n    if (!event.data.old_settings.app.onboarding.completed &&\n        event.data.new_settings.app.onboarding.completed) {\n      Logger().info(\"Onboarding completed, showing floating window and closing webview\");\n      Features::Gallery::ensure_output_directory_media_source(\n          state, event.data.new_settings.features.output_dir_path);\n      UI::FloatingWindow::show_window(state);\n      auto _ = UI::WebViewWindow::close_window(state);\n    } else if (output_directory_changed) {\n      Features::Gallery::ensure_output_directory_media_source(\n          state, event.data.new_settings.features.output_dir_path);\n    }\n\n    if (has_hotkey_changes(event.data.old_settings, event.data.new_settings)) {\n      refresh_global_hotkeys(state);\n    }\n\n    if (has_infinity_nikki_hardlink_setting_changes(event.data.old_settings,\n                                                    event.data.new_settings)) {\n      Extensions::InfinityNikki::PhotoService::refresh_from_settings(state);\n\n      if (should_start_infinity_nikki_hardlinks_initialization(event.data.old_settings,\n                                                               event.data.new_settings)) {\n        auto task_result =\n            Extensions::InfinityNikki::TaskService::start_initialize_screenshot_hardlinks_task(\n                state);\n        if (!task_result) {\n          Logger().warn(\"Failed to start Infinity Nikki screenshot hardlink task: {}\",\n                        task_result.error());\n        } else {\n          Logger().info(\"Infinity Nikki screenshot hardlink task started: {}\", task_result.value());\n        }\n      }\n    }\n\n    auto webview_host_mode_changed =\n        has_webview_host_mode_changes(event.data.old_settings, event.data.new_settings);\n    auto webview_theme_mode_changed =\n        has_webview_theme_mode_changes(event.data.old_settings, event.data.new_settings);\n\n    if (webview_host_mode_changed) {\n      if (auto recreate_result = UI::WebViewWindow::recreate_webview_host(state);\n          !recreate_result) {\n        Logger().warn(\"Failed to recreate WebView host after settings change: {}\",\n                      recreate_result.error());\n      }\n    } else if (webview_theme_mode_changed) {\n      Core::WebView::apply_background_mode_from_settings(state);\n    }\n\n    Core::RPC::NotificationHub::send_notification(state, \"settings.changed\");\n\n    Logger().debug(\"Settings change processing completed\");\n\n  } catch (const std::exception& e) {\n    Logger().error(\"Error handling settings change event: {}\", e.what());\n  }\n}\n\nauto register_settings_handlers(Core::State::AppState& app_state) -> void {\n  using namespace Core::Events;\n\n  // 注册设置变更事件处理器\n  subscribe<Features::Settings::Events::SettingsChangeEvent>(\n      *app_state.events,\n      [&app_state](const Features::Settings::Events::SettingsChangeEvent& event) {\n        handle_settings_changed(app_state, event);\n      });\n}\n\n}  // namespace Core::Events::Handlers\n"
  },
  {
    "path": "src/core/events/handlers/settings_handlers.ixx",
    "content": "module;\n\nexport module Core.Events.Handlers.Settings;\n\nimport Core.State;\n\nnamespace Core::Events::Handlers {\n\nexport auto register_settings_handlers(Core::State::AppState& app_state) -> void;\n\n}"
  },
  {
    "path": "src/core/events/handlers/system_handlers.cpp",
    "content": "module;\n\nmodule Core.Events.Handlers.System;\n\nimport std;\nimport Core.Events;\nimport Core.State;\nimport Core.WebView;\nimport Core.WebView.Events;\nimport UI.FloatingWindow;\nimport UI.FloatingWindow.Layout;\nimport UI.FloatingWindow.D2DContext;\nimport UI.FloatingWindow.State;\nimport UI.FloatingWindow.Events;\nimport UI.WebViewWindow;\nimport Utils.Logger;\nimport Vendor.Windows;\n\nnamespace Core::Events::Handlers {\n\n// 从 app_state.ixx 迁移的 DPI 更新函数\nauto update_render_dpi(Core::State::AppState& state, Vendor::Windows::UINT new_dpi,\n                       const Vendor::Windows::SIZE& window_size) -> void {\n  state.floating_window->window.dpi = new_dpi;\n  state.floating_window->d2d_context.needs_font_update = true;\n\n  // 更新布局配置（基于新的DPI）\n  UI::FloatingWindow::Layout::update_layout(state);\n\n  // 更新窗口尺寸\n  if (state.floating_window->window.hwnd) {\n    Vendor::Windows::RECT currentRect{};\n    Vendor::Windows::GetWindowRect(state.floating_window->window.hwnd, &currentRect);\n\n    Vendor::Windows::SetWindowPos(\n        state.floating_window->window.hwnd, nullptr, currentRect.left, currentRect.top,\n        window_size.cx, window_size.cy,\n        Vendor::Windows::kSWP_NOZORDER | Vendor::Windows::kSWP_NOACTIVATE);\n\n    // 如果Direct2D已初始化，调整渲染目标大小\n    if (state.floating_window->d2d_context.is_initialized) {\n      UI::FloatingWindow::D2DContext::resize_d2d(state, window_size);\n    }\n  }\n}\n\n// 处理 hide 命令\nauto handle_hide_event(Core::State::AppState& state) -> void {\n  UI::FloatingWindow::hide_window(state);\n}\n\n// 处理退出事件\nauto handle_exit_event(Core::State::AppState& state) -> void {\n  Logger().info(\"Exit event received, posting quit message\");\n  Vendor::Windows::PostQuitMessage(0);\n}\n\n// 处理 toggle_visibility 命令\nauto handle_toggle_visibility_event(Core::State::AppState& state) -> void {\n  UI::FloatingWindow::toggle_visibility(state);\n}\n\nauto register_system_handlers(Core::State::AppState& app_state) -> void {\n  using namespace Core::Events;\n\n  subscribe<UI::FloatingWindow::Events::HideEvent>(\n      *app_state.events,\n      [&app_state](const UI::FloatingWindow::Events::HideEvent&) { handle_hide_event(app_state); });\n\n  subscribe<UI::FloatingWindow::Events::ExitEvent>(\n      *app_state.events,\n      [&app_state](const UI::FloatingWindow::Events::ExitEvent&) { handle_exit_event(app_state); });\n\n  subscribe<UI::FloatingWindow::Events::ToggleVisibilityEvent>(\n      *app_state.events, [&app_state](const UI::FloatingWindow::Events::ToggleVisibilityEvent&) {\n        handle_toggle_visibility_event(app_state);\n      });\n\n  subscribe<UI::FloatingWindow::Events::DpiChangeEvent>(\n      *app_state.events, [&app_state](const UI::FloatingWindow::Events::DpiChangeEvent& event) {\n        Logger().debug(\"DPI changed to: {}, window size: {}x{}\", event.new_dpi,\n                       event.window_size.cx, event.window_size.cy);\n\n        update_render_dpi(app_state, event.new_dpi, event.window_size);\n\n        Logger().info(\"DPI update completed successfully\");\n      });\n\n  subscribe<Core::WebView::Events::WebViewResponseEvent>(\n      *app_state.events, [&app_state](const Core::WebView::Events::WebViewResponseEvent& event) {\n        try {\n          // 在UI线程上安全调用WebView API\n          Core::WebView::post_message(app_state, event.response);\n\n        } catch (const std::exception& e) {\n          Logger().error(\"Error processing WebView response event: {}\", e.what());\n        }\n      });\n}\n\n}  // namespace Core::Events::Handlers"
  },
  {
    "path": "src/core/events/handlers/system_handlers.ixx",
    "content": "module;\n\nexport module Core.Events.Handlers.System;\n\nimport Core.State;\n\nnamespace Core::Events::Handlers {\n\nexport auto register_system_handlers(Core::State::AppState& app_state) -> void;\n\n}"
  },
  {
    "path": "src/core/events/registrar.cpp",
    "content": "module;\n\nmodule Core.Events.Registrar;\n\nimport Core.State;\nimport Core.Events.Handlers.Feature;\nimport Core.Events.Handlers.Settings;\nimport Core.Events.Handlers.System;\n\nnamespace Core::Events {\n\nauto register_all_handlers(Core::State::AppState& app_state) -> void {\n  Handlers::register_feature_handlers(app_state);\n  Handlers::register_settings_handlers(app_state);\n  Handlers::register_system_handlers(app_state);\n}\n\n}  // namespace Core::Events"
  },
  {
    "path": "src/core/events/registrar.ixx",
    "content": "module;\n\nexport module Core.Events.Registrar;\n\nimport Core.State;\n\nnamespace Core::Events {\n\nexport auto register_all_handlers(Core::State::AppState& app_state) -> void;\n\n}  // namespace Core::Events"
  },
  {
    "path": "src/core/events/state.ixx",
    "content": "module;\n\n#include <windows.h>\n\nexport module Core.Events.State;\n\nimport std;\n\nnamespace Core::Events::State {\n\nexport struct EventsState {\n  std::unordered_map<std::type_index, std::vector<std::function<void(const std::any&)>>> handlers;\n  std::queue<std::pair<std::type_index, std::any>> event_queue;\n  std::mutex queue_mutex;\n\n  // Window handle for UI thread wake-up notifications\n  HWND notify_hwnd = nullptr;\n};\n\n}  // namespace Core::Events::State"
  },
  {
    "path": "src/core/http_client/http_client.cpp",
    "content": "module;\n\n#include <asio.hpp>\n\nmodule Core.HttpClient;\n\nimport std;\nimport Core.State;\nimport Core.HttpClient.State;\nimport Core.HttpClient.Types;\nimport Utils.Logger;\nimport Utils.String;\nimport Vendor.WinHttp;\nimport <windows.h>;\n\nnamespace Core::HttpClient::Detail {\n\nusing RequestOperation = Core::HttpClient::State::RequestOperation;\n\n// 延长异步操作生命周期，确保在底层 WinHTTP 回调结束前对象不被析构\nauto acquire_keepalive(RequestOperation* operation) -> std::shared_ptr<RequestOperation> {\n  if (operation == nullptr) {\n    return {};\n  }\n\n  std::lock_guard<std::mutex> lock(operation->keepalive_mutex);\n  return operation->keepalive;\n}\n\n// 清除保活引用，允许异步操作对象在后续流程中被正常析构\nauto release_keepalive(RequestOperation& operation) -> void {\n  std::lock_guard<std::mutex> lock(operation.keepalive_mutex);\n  operation.keepalive.reset();\n}\n\n// 生成带有 Windows 系统错误码的详细报告信息\nauto make_winhttp_error(std::string_view stage) -> std::string {\n  return std::format(\"{} failed (error={})\", stage, ::GetLastError());\n}\n\n// 强制使超时定时器过期，以此来唤醒由于等待响应而挂起的协程\nauto notify_waiter(RequestOperation& operation) -> void {\n  if (operation.waiter_notified.exchange(true)) {\n    return;\n  }\n  if (operation.completion_timer.has_value()) {\n    operation.completion_timer->expires_at((std::chrono::steady_clock::time_point::min)());\n  }\n}\n\nauto close_connect_handle(RequestOperation& operation) -> void {\n  if (operation.connect_handle != nullptr) {\n    Vendor::WinHttp::WinHttpCloseHandle(operation.connect_handle);\n    operation.connect_handle = nullptr;\n  }\n}\n\n// 关闭请求句柄（幂等）。若回调已注册，句柄置空延迟到 HANDLE_CLOSING 回调中处理，\n// 避免在回调仍在运行时提前释放句柄导致悬空指针。\nauto close_request_handle(RequestOperation& operation) -> void {\n  if (operation.request_handle == nullptr) {\n    return;\n  }\n  if (operation.close_requested.exchange(true)) {\n    return;\n  }\n  Vendor::WinHttp::WinHttpCloseHandle(operation.request_handle);\n  if (!operation.callback_registered) {\n    operation.request_handle = nullptr;\n  }\n}\n\n// 完成整个操作周期：保存结果、清理所有关联的 WinHTTP 句柄并唤醒等待的协程\nauto complete_operation(std::shared_ptr<RequestOperation> operation,\n                        std::expected<Types::Response, std::string> result) -> void {\n  if (operation == nullptr) {\n    return;\n  }\n  if (operation->completed.exchange(true)) {\n    return;\n  }\n\n  operation->result = std::move(result);\n  close_connect_handle(*operation);\n\n  if (operation->request_handle != nullptr) {\n    close_request_handle(*operation);\n  } else {\n    release_keepalive(*operation);\n  }\n\n  notify_waiter(*operation);\n}\n\nauto complete_with_error(std::shared_ptr<RequestOperation> operation, std::string message) -> void {\n  complete_operation(std::move(operation), std::unexpected(std::move(message)));\n}\n\n// 将 UTF-8 编码的字符串转换为 WinHTTP 接口所需的宽字符串（UTF-16）\nauto to_wide_utf8(const std::string& value, std::string_view field_name)\n    -> std::expected<std::wstring, std::string> {\n  auto wide = Utils::String::FromUtf8(value);\n  if (!value.empty() && wide.empty()) {\n    return std::unexpected(std::format(\"Invalid UTF-8 for {}\", field_name));\n  }\n  return wide;\n}\n\n// HTTP 方法缺省为 GET，并统一转为大写\nauto normalize_method(std::string method) -> std::string {\n  if (method.empty()) {\n    method = \"GET\";\n  }\n  std::ranges::transform(method, method.begin(),\n                         [](unsigned char ch) { return static_cast<char>(std::toupper(ch)); });\n  return method;\n}\n\nauto trim_wstring(std::wstring_view value) -> std::wstring_view {\n  auto is_space = [](wchar_t ch) { return std::iswspace(ch) != 0; };\n  auto begin = std::find_if_not(value.begin(), value.end(), is_space);\n  if (begin == value.end()) {\n    return {};\n  }\n  auto end = std::find_if_not(value.rbegin(), value.rend(), is_space).base();\n  return std::wstring_view(begin, end);\n}\n\n// 逐行解析 WinHTTP 返回的原始响应头字符串，过滤状态行并拆分键值对\nauto parse_raw_headers(std::wstring_view raw_headers) -> std::vector<Types::Header> {\n  std::vector<Types::Header> headers;\n\n  size_t cursor = 0;\n  bool skipped_status_line = false;\n  while (cursor < raw_headers.size()) {\n    auto line_end = raw_headers.find(L\"\\r\\n\", cursor);\n    if (line_end == std::wstring_view::npos) {\n      line_end = raw_headers.size();\n    }\n\n    auto line = raw_headers.substr(cursor, line_end - cursor);\n    cursor = line_end + 2;\n\n    if (line.empty()) {\n      continue;\n    }\n    if (!skipped_status_line) {\n      skipped_status_line = true;\n      continue;\n    }\n\n    auto separator = line.find(L':');\n    if (separator == std::wstring_view::npos) {\n      continue;\n    }\n\n    auto name = trim_wstring(line.substr(0, separator));\n    auto value = trim_wstring(line.substr(separator + 1));\n    if (name.empty()) {\n      continue;\n    }\n\n    headers.push_back(Types::Header{\n        .name = Utils::String::ToUtf8(std::wstring(name)),\n        .value = Utils::String::ToUtf8(std::wstring(value)),\n    });\n  }\n\n  return headers;\n}\n\n// 从响应头中查找 Content-Length 并解析为无符号整数；服务器不保证提供，失败时返回 nullopt\nauto find_content_length(const Types::Response& response) -> std::optional<std::uint64_t> {\n  for (const auto& header : response.headers) {\n    if (Utils::String::ToLowerAscii(header.name) != \"content-length\") {\n      continue;\n    }\n\n    try {\n      return static_cast<std::uint64_t>(std::stoull(Utils::String::TrimAscii(header.value)));\n    } catch (...) {\n      return std::nullopt;\n    }\n  }\n\n  return std::nullopt;\n}\n\n// 向调用方发送当前下载进度快照；无回调或非文件下载模式时为空操作\nauto emit_download_progress(RequestOperation& operation) -> void {\n  if (!operation.download || !operation.download->progress_callback) {\n    return;\n  }\n\n  operation.download->progress_callback(Types::DownloadProgress{\n      .downloaded_bytes = operation.download->downloaded_bytes,\n      .total_bytes = operation.download->total_bytes,\n  });\n}\n\n// 刷新并关闭输出文件；非文件下载模式时为空操作\nauto finalize_file_download(RequestOperation& operation) -> std::expected<void, std::string> {\n  if (!operation.download || !operation.download->output_file.has_value()) {\n    return {};\n  }\n\n  auto& dl = *operation.download;\n  dl.output_file->flush();\n  if (!dl.output_file->good()) {\n    return std::unexpected(\"Failed to flush output file: \" + dl.output_path.string());\n  }\n\n  dl.output_file->close();\n  if (dl.output_file->fail()) {\n    return std::unexpected(\"Failed to close output file: \" + dl.output_path.string());\n  }\n\n  dl.output_file.reset();\n  return {};\n}\n\n// 请求 URL 解析：将字符串分解为 host、path、port、scheme 等字段供后续 WinHTTP 调用使用\nauto parse_request_url(RequestOperation& operation) -> std::expected<void, std::string> {\n  auto wide_url_result = to_wide_utf8(operation.request.url, \"request.url\");\n  if (!wide_url_result) {\n    return std::unexpected(wide_url_result.error());\n  }\n  operation.wide_url = std::move(wide_url_result.value());\n\n  Vendor::WinHttp::URL_COMPONENTS components{};\n  components.dwStructSize = sizeof(components);\n  components.dwSchemeLength = static_cast<Vendor::WinHttp::DWORD>(-1);\n  components.dwHostNameLength = static_cast<Vendor::WinHttp::DWORD>(-1);\n  components.dwUrlPathLength = static_cast<Vendor::WinHttp::DWORD>(-1);\n  components.dwExtraInfoLength = static_cast<Vendor::WinHttp::DWORD>(-1);\n\n  if (!Vendor::WinHttp::WinHttpCrackUrl(\n          operation.wide_url.c_str(),\n          static_cast<Vendor::WinHttp::DWORD>(operation.wide_url.size()), 0, &components)) {\n    return std::unexpected(make_winhttp_error(\"WinHttpCrackUrl\"));\n  }\n\n  if (components.dwHostNameLength == 0 || components.lpszHostName == nullptr) {\n    return std::unexpected(\"Invalid URL host\");\n  }\n\n  operation.wide_host.assign(components.lpszHostName, components.dwHostNameLength);\n\n  operation.wide_path.clear();\n  if (components.dwUrlPathLength > 0 && components.lpszUrlPath != nullptr) {\n    operation.wide_path.append(components.lpszUrlPath, components.dwUrlPathLength);\n  }\n  if (components.dwExtraInfoLength > 0 && components.lpszExtraInfo != nullptr) {\n    operation.wide_path.append(components.lpszExtraInfo, components.dwExtraInfoLength);\n  }\n  if (operation.wide_path.empty()) {\n    operation.wide_path = L\"/\";\n  }\n\n  operation.port = components.nPort;\n  operation.secure = components.nScheme == Vendor::WinHttp::kINTERNET_SCHEME_HTTPS;\n  return {};\n}\n\n// 将请求头列表序列化为 WinHTTP 所需的宽字符串格式（\"Name: Value\\r\\n\" 逐行拼接）\nauto build_request_headers(RequestOperation& operation) -> std::expected<void, std::string> {\n  operation.wide_headers.clear();\n\n  for (const auto& header : operation.request.headers) {\n    if (header.name.empty()) {\n      continue;\n    }\n\n    auto wide_name_result = to_wide_utf8(header.name, \"request.headers.name\");\n    if (!wide_name_result) {\n      return std::unexpected(wide_name_result.error());\n    }\n    auto wide_value_result = to_wide_utf8(header.value, \"request.headers.value\");\n    if (!wide_value_result) {\n      return std::unexpected(wide_value_result.error());\n    }\n\n    operation.wide_headers += wide_name_result.value();\n    operation.wide_headers += L\": \";\n    operation.wide_headers += wide_value_result.value();\n    operation.wide_headers += L\"\\r\\n\";\n  }\n\n  return {};\n}\n\n// 从 WinHTTP 句柄中读取 HTTP 状态码（如 200、404）并写入 response\nauto query_response_status(RequestOperation& operation) -> std::expected<void, std::string> {\n  Vendor::WinHttp::DWORD status_code = 0;\n  Vendor::WinHttp::DWORD status_size = sizeof(status_code);\n\n  if (!Vendor::WinHttp::WinHttpQueryHeaders(\n          operation.request_handle,\n          Vendor::WinHttp::kWINHTTP_QUERY_STATUS_CODE | Vendor::WinHttp::kWINHTTP_QUERY_FLAG_NUMBER,\n          Vendor::WinHttp::kWINHTTP_HEADER_NAME_BY_INDEX, &status_code, &status_size, nullptr)) {\n    return std::unexpected(make_winhttp_error(\"WinHttpQueryHeaders(status)\"));\n  }\n\n  operation.response.status_code = static_cast<std::int32_t>(status_code);\n  return {};\n}\n\n// 查询并解析完整响应头列表，解析失败时静默跳过（状态码已单独提取）\nauto query_response_headers(RequestOperation& operation) -> void {\n  Vendor::WinHttp::DWORD header_size = 0;\n  (void)Vendor::WinHttp::WinHttpQueryHeaders(\n      operation.request_handle, Vendor::WinHttp::kWINHTTP_QUERY_RAW_HEADERS_CRLF,\n      Vendor::WinHttp::kWINHTTP_HEADER_NAME_BY_INDEX, nullptr, &header_size, nullptr);\n  if (header_size == 0) {\n    return;\n  }\n\n  std::wstring raw_headers(header_size / sizeof(wchar_t), L'\\0');\n  if (!Vendor::WinHttp::WinHttpQueryHeaders(operation.request_handle,\n                                            Vendor::WinHttp::kWINHTTP_QUERY_RAW_HEADERS_CRLF,\n                                            Vendor::WinHttp::kWINHTTP_HEADER_NAME_BY_INDEX,\n                                            raw_headers.data(), &header_size, nullptr)) {\n    return;\n  }\n\n  if (!raw_headers.empty() && raw_headers.back() == L'\\0') {\n    raw_headers.pop_back();\n  }\n  operation.response.headers = parse_raw_headers(raw_headers);\n}\n\n// 向 WinHTTP 查询当前可读字节数，触发后续 DATA_AVAILABLE 回调\nauto request_more_data(std::shared_ptr<RequestOperation> operation)\n    -> std::expected<void, std::string> {\n  if (!Vendor::WinHttp::WinHttpQueryDataAvailable(operation->request_handle, nullptr)) {\n    return std::unexpected(make_winhttp_error(\"WinHttpQueryDataAvailable\"));\n  }\n  return {};\n}\n\n// 下载结束收尾：刷新关闭文件、发送最终进度通知、标记操作完成\nauto complete_download(std::shared_ptr<RequestOperation> operation) -> void {\n  auto finalize_result = finalize_file_download(*operation);\n  if (!finalize_result) {\n    complete_with_error(operation, finalize_result.error());\n    return;\n  }\n  emit_download_progress(*operation);\n  complete_operation(operation, operation->response);\n}\n\n// 将回调逻辑派发到协程执行器线程，避免在 WinHTTP 系统线程上直接操作 operation 状态\ntemplate <typename F>\nauto post_status_callback(std::shared_ptr<RequestOperation> operation, F&& fn) -> void {\n  asio::post(operation->executor, [operation, fn = std::forward<F>(fn)]() mutable { fn(); });\n}\n\n// WinHTTP 核心异步回调函数：由系统底层触发，负责处理连接、收发数据等不同阶段的状态变更\nauto CALLBACK winhttp_status_callback(Vendor::WinHttp::HINTERNET h_internet,\n                                      Vendor::WinHttp::DWORD_PTR context,\n                                      Vendor::WinHttp::DWORD internet_status,\n                                      Vendor::WinHttp::LPVOID status_information,\n                                      Vendor::WinHttp::DWORD status_information_length) -> void {\n  auto* raw_operation = reinterpret_cast<RequestOperation*>(context);\n  auto operation = acquire_keepalive(raw_operation);\n  if (!operation) {\n    return;\n  }\n\n  switch (internet_status) {\n    case Vendor::WinHttp::kWINHTTP_CALLBACK_STATUS_SENDREQUEST_COMPLETE: {\n      post_status_callback(operation, [operation]() mutable {\n        if (operation->completed.load()) {\n          return;\n        }\n        // 请求发送完毕，开始等待并接收服务器的响应\n        if (!Vendor::WinHttp::WinHttpReceiveResponse(operation->request_handle, nullptr)) {\n          complete_with_error(operation, make_winhttp_error(\"WinHttpReceiveResponse\"));\n        }\n      });\n      break;\n    }\n    case Vendor::WinHttp::kWINHTTP_CALLBACK_STATUS_HEADERS_AVAILABLE: {\n      post_status_callback(operation, [operation]() mutable {\n        if (operation->completed.load()) {\n          return;\n        }\n\n        // 响应头已可用，先提取 HTTP 状态码 (例如 200, 404)\n        auto status_result = query_response_status(*operation);\n        if (!status_result) {\n          complete_with_error(operation, status_result.error());\n          return;\n        }\n\n        // 继续提取所有响应头并解析\n        query_response_headers(*operation);\n        if (operation->download) {\n          operation->download->total_bytes = find_content_length(operation->response);\n        }\n        // 尝试查询是否有可用的响应体数据\n        auto query_result = request_more_data(operation);\n        if (!query_result) {\n          complete_with_error(operation, query_result.error());\n        }\n      });\n      break;\n    }\n    case Vendor::WinHttp::kWINHTTP_CALLBACK_STATUS_DATA_AVAILABLE: {\n      auto available_bytes = *reinterpret_cast<Vendor::WinHttp::DWORD*>(status_information);\n\n      post_status_callback(operation, [operation, available_bytes]() mutable {\n        if (operation->completed.load()) {\n          return;\n        }\n        // 若可用数据为 0，说明响应体已经彻底接收完毕\n        if (available_bytes == 0) {\n          complete_download(operation);\n          return;\n        }\n\n        // 发起异步读取操作，将数据读入预分配的内部 buffer 中\n        auto bytes_to_read = static_cast<Vendor::WinHttp::DWORD>(\n            std::min<std::size_t>(available_bytes, operation->read_buffer.size()));\n\n        if (!Vendor::WinHttp::WinHttpReadData(\n                operation->request_handle, operation->read_buffer.data(), bytes_to_read, nullptr)) {\n          complete_with_error(operation, make_winhttp_error(\"WinHttpReadData\"));\n        }\n      });\n      break;\n    }\n    case Vendor::WinHttp::kWINHTTP_CALLBACK_STATUS_READ_COMPLETE: {\n      auto bytes_read = static_cast<std::size_t>(status_information_length);\n      post_status_callback(operation, [operation, bytes_read]() mutable {\n        if (operation->completed.load()) {\n          return;\n        }\n        // 如果读取完成但读取到的字节为0，可能对端提前关闭，同样视为结束\n        if (bytes_read == 0) {\n          complete_download(operation);\n          return;\n        }\n\n        if (operation->download) {\n          if (!operation->download->output_file.has_value()) {\n            complete_with_error(operation, \"Output file is not initialized: \" +\n                                               operation->download->output_path.string());\n            return;\n          }\n\n          operation->download->output_file->write(operation->read_buffer.data(),\n                                                  static_cast<std::streamsize>(bytes_read));\n          if (!operation->download->output_file->good()) {\n            complete_with_error(operation, \"Failed to write output file: \" +\n                                               operation->download->output_path.string());\n            return;\n          }\n\n          operation->download->downloaded_bytes += bytes_read;\n          emit_download_progress(*operation);\n        } else {\n          // 把刚读到的数据追加到总的 response body 里\n          operation->response.body.append(operation->read_buffer.data(), bytes_read);\n        }\n        // 循环继续询问还有没有剩余的流数据\n        auto query_result = request_more_data(operation);\n        if (!query_result) {\n          complete_with_error(operation, query_result.error());\n        }\n      });\n      break;\n    }\n    case Vendor::WinHttp::kWINHTTP_CALLBACK_STATUS_REQUEST_ERROR: {\n      auto async_result =\n          *reinterpret_cast<Vendor::WinHttp::WINHTTP_ASYNC_RESULT*>(status_information);\n\n      post_status_callback(operation, [operation, async_result]() mutable {\n        if (operation->completed.load()) {\n          return;\n        }\n        complete_with_error(operation, std::format(\"WinHTTP async request error (api={}, error={})\",\n                                                   async_result.dwResult, async_result.dwError));\n      });\n      break;\n    }\n    case Vendor::WinHttp::kWINHTTP_CALLBACK_STATUS_HANDLE_CLOSING: {\n      post_status_callback(operation, [operation, h_internet]() mutable {\n        if (operation->request_handle == h_internet) {\n          operation->request_handle = nullptr;\n          operation->callback_registered = false;\n        }\n        // 只有确信 request_handle 被完全关闭且操作收尾后，才释放保活引用\n        if (operation->completed.load()) {\n          release_keepalive(*operation);\n        }\n      });\n      break;\n    }\n    default:\n      break;\n  }\n}\n\n// 解析请求参数、创建且配置相应的 WinHTTP 连接和请求句柄，绑定异步回调后开始发送。\n// 若此函数返回错误，说明请求完全未进入系统队列，调用方需自行释放 keepalive。\nauto prepare_operation(State::HttpClientState& state, std::shared_ptr<RequestOperation> operation)\n    -> std::expected<void, std::string> {\n  operation->request.method = normalize_method(operation->request.method);\n  operation->request_body.assign(operation->request.body.begin(), operation->request.body.end());\n\n  auto method_result = to_wide_utf8(operation->request.method, \"request.method\");\n  if (!method_result) {\n    return std::unexpected(method_result.error());\n  }\n  operation->wide_method = std::move(method_result.value());\n\n  if (auto parse_result = parse_request_url(*operation); !parse_result) {\n    return std::unexpected(parse_result.error());\n  }\n  if (auto header_result = build_request_headers(*operation); !header_result) {\n    return std::unexpected(header_result.error());\n  }\n\n  // 第一步：创建一个到目标主机端口的连接 (Connect)\n  operation->connect_handle = Vendor::WinHttp::WinHttpConnect(\n      state.session.get(), operation->wide_host.c_str(), operation->port, 0);\n  if (operation->connect_handle == nullptr) {\n    return std::unexpected(make_winhttp_error(\"WinHttpConnect\"));\n  }\n\n  // 第二步：利用上面建立的连接去初始化一个特定 URI 的请求句柄 (Request)\n  Vendor::WinHttp::DWORD request_flags =\n      operation->secure ? Vendor::WinHttp::kWINHTTP_FLAG_SECURE : 0;\n  operation->request_handle = Vendor::WinHttp::WinHttpOpenRequest(\n      operation->connect_handle, operation->wide_method.c_str(), operation->wide_path.c_str(),\n      nullptr, Vendor::WinHttp::kWINHTTP_NO_REFERER, Vendor::WinHttp::kWINHTTP_DEFAULT_ACCEPT_TYPES,\n      request_flags);\n  if (operation->request_handle == nullptr) {\n    close_connect_handle(*operation);\n    return std::unexpected(make_winhttp_error(\"WinHttpOpenRequest\"));\n  }\n\n  int connect_timeout = operation->request.connect_timeout_ms.value_or(state.connect_timeout_ms);\n  int send_timeout = operation->request.send_timeout_ms.value_or(state.send_timeout_ms);\n  int receive_timeout = operation->request.receive_timeout_ms.value_or(state.receive_timeout_ms);\n  if (!Vendor::WinHttp::WinHttpSetTimeouts(operation->request_handle, state.resolve_timeout_ms,\n                                           connect_timeout, send_timeout, receive_timeout)) {\n    close_request_handle(*operation);\n    close_connect_handle(*operation);\n    return std::unexpected(make_winhttp_error(\"WinHttpSetTimeouts\"));\n  }\n\n  // 绑定上下文：非常关键，将 operation 指针与该请求句柄关联，后续 WinHTTP\n  // 回调才能拿到我们的操作上下文\n  Vendor::WinHttp::DWORD_PTR context =\n      reinterpret_cast<Vendor::WinHttp::DWORD_PTR>(operation.get());\n  if (!Vendor::WinHttp::WinHttpSetOption(operation->request_handle,\n                                         Vendor::WinHttp::kWINHTTP_OPTION_CONTEXT_VALUE, &context,\n                                         sizeof(context))) {\n    close_request_handle(*operation);\n    close_connect_handle(*operation);\n    return std::unexpected(make_winhttp_error(\"WinHttpSetOption(context)\"));\n  }\n\n  // 设置我们感兴趣的 WinHTTP 异步回调阶段，并挂载 winhttp_status_callback\n  auto callback_flags = Vendor::WinHttp::kWINHTTP_CALLBACK_STATUS_SENDREQUEST_COMPLETE |\n                        Vendor::WinHttp::kWINHTTP_CALLBACK_STATUS_HEADERS_AVAILABLE |\n                        Vendor::WinHttp::kWINHTTP_CALLBACK_STATUS_DATA_AVAILABLE |\n                        Vendor::WinHttp::kWINHTTP_CALLBACK_STATUS_READ_COMPLETE |\n                        Vendor::WinHttp::kWINHTTP_CALLBACK_STATUS_REQUEST_ERROR |\n                        Vendor::WinHttp::kWINHTTP_CALLBACK_STATUS_HANDLE_CLOSING;\n  auto callback_result = Vendor::WinHttp::WinHttpSetStatusCallback(\n      operation->request_handle, winhttp_status_callback, callback_flags, 0);\n  if (callback_result == Vendor::WinHttp::kWINHTTP_INVALID_STATUS_CALLBACK) {\n    close_request_handle(*operation);\n    close_connect_handle(*operation);\n    return std::unexpected(make_winhttp_error(\"WinHttpSetStatusCallback\"));\n  }\n  operation->callback_registered = true;\n\n  const wchar_t* header_ptr = operation->wide_headers.empty()\n                                  ? Vendor::WinHttp::kWINHTTP_NO_ADDITIONAL_HEADERS\n                                  : operation->wide_headers.c_str();\n  auto header_len = operation->wide_headers.empty()\n                        ? 0\n                        : static_cast<Vendor::WinHttp::DWORD>(operation->wide_headers.size());\n\n  auto body_size = static_cast<Vendor::WinHttp::DWORD>(operation->request_body.size());\n  void* request_data =\n      body_size == 0 ? Vendor::WinHttp::kWINHTTP_NO_REQUEST_DATA : operation->request_body.data();\n\n  // 第三步：将请求头发往服务器，由于设定了 ASYNC 标志，此函数会立刻返回，后续流程交由系统回调处理\n  if (!Vendor::WinHttp::WinHttpSendRequest(operation->request_handle, header_ptr, header_len,\n                                           request_data, body_size, body_size, 0)) {\n    complete_with_error(operation, make_winhttp_error(\"WinHttpSendRequest\"));\n  }\n\n  return {};\n}\n\n// 通用 operation 执行：设保活、投递至 WinHTTP、挂起协程直至完成或中断。\n// fetch 和 download_to_file 均通过此函数统一驱动请求生命周期。\nauto execute_operation(State::HttpClientState& client_state,\n                       std::shared_ptr<RequestOperation> operation) -> asio::awaitable<void> {\n  {\n    // 自引用保活：防止局部运行完后 shared_ptr 被回收导致在 WinHTTP 后台回调里出现空指针\n    std::lock_guard<std::mutex> lock(operation->keepalive_mutex);\n    operation->keepalive = operation;\n  }\n\n  // 投递进入 WinHTTP：如果这一步失败，说明完全没丢进系统队列，需直接移除保活引用并返回\n  if (auto prepare_result = prepare_operation(client_state, operation); !prepare_result) {\n    release_keepalive(*operation);\n    operation->result = std::unexpected(prepare_result.error());\n    co_return;\n  }\n\n  if (operation->completed.load()) {\n    co_return;\n  }\n\n  // 让当前的协程(coroutine)在此挂起，直到底层完成全部网络通讯唤醒此 timer\n  std::error_code wait_error;\n  co_await operation->completion_timer->async_wait(\n      asio::redirect_error(asio::use_awaitable, wait_error));\n\n  if (!operation->completed.load()) {\n    complete_with_error(operation, \"HTTP request interrupted before completion\");\n  }\n}\n\n}  // namespace Core::HttpClient::Detail\n\nnamespace Core::HttpClient {\n\n// 初始化 HTTP 客户端全局状态，使用系统的自适应代理配置创建 WinHTTP 会话 (Session)\nauto initialize(Core::State::AppState& state) -> std::expected<void, std::string> {\n  if (!state.http_client) {\n    return std::unexpected(\"HTTP client state is not initialized\");\n  }\n\n  if (state.http_client->is_initialized.load()) {\n    return {};\n  }\n\n  state.http_client->session = Vendor::WinHttp::UniqueHInternet{Vendor::WinHttp::WinHttpOpen(\n      state.http_client->user_agent.c_str(), Vendor::WinHttp::kWINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY,\n      Vendor::WinHttp::kWINHTTP_NO_PROXY_NAME, Vendor::WinHttp::kWINHTTP_NO_PROXY_BYPASS,\n      Vendor::WinHttp::kWINHTTP_FLAG_ASYNC)};\n  if (!state.http_client->session) {\n    return std::unexpected(\"Failed to open WinHTTP async session\");\n  }\n\n  state.http_client->is_initialized = true;\n  Logger().info(\"HTTP client initialized\");\n  return {};\n}\n\n// 关闭 HTTP 会话释放内核资源，后续请求将无法被挂起执行\nauto shutdown(Core::State::AppState& state) -> void {\n  if (!state.http_client) {\n    return;\n  }\n  state.http_client->is_initialized = false;\n  state.http_client->session = Vendor::WinHttp::UniqueHInternet{};\n  Logger().info(\"HTTP client shut down\");\n}\n\n// 核心异步 HTTP 请求：负责封装请求上下文，投递至 WinHTTP 进行处理并挂起当前协程直至完成\nauto fetch(Core::State::AppState& state, const Core::HttpClient::Types::Request& request)\n    -> asio::awaitable<std::expected<Core::HttpClient::Types::Response, std::string>> {\n  if (!state.http_client) {\n    co_return std::unexpected(\"HTTP client state is not initialized\");\n  }\n  if (!state.http_client->is_initialized.load() || !state.http_client->session) {\n    co_return std::unexpected(\"HTTP client is not initialized\");\n  }\n  if (request.url.empty()) {\n    co_return std::unexpected(\"HTTP request URL is empty\");\n  }\n\n  auto executor = co_await asio::this_coro::executor;\n\n  auto operation = std::make_shared<Core::HttpClient::State::RequestOperation>();\n  operation->executor = executor;\n  operation->completion_timer.emplace(executor);\n  operation->completion_timer->expires_at((std::chrono::steady_clock::time_point::max)());\n  operation->request = request;\n\n  co_await Detail::execute_operation(*state.http_client, operation);\n  co_return operation->result;\n}\n\n// 便捷封装：执行 HTTP 抓取请求后，将接收到的响应体直接以二进制流写入本地文件中\nauto download_to_file(Core::State::AppState& state, const Core::HttpClient::Types::Request& request,\n                      const std::filesystem::path& output_path,\n                      Core::HttpClient::Types::DownloadProgressCallback progress_callback)\n    -> asio::awaitable<std::expected<void, std::string>> {\n  if (!state.http_client) {\n    co_return std::unexpected(\"HTTP client state is not initialized\");\n  }\n  if (!state.http_client->is_initialized.load() || !state.http_client->session) {\n    co_return std::unexpected(\"HTTP client is not initialized\");\n  }\n  if (request.url.empty()) {\n    co_return std::unexpected(\"HTTP request URL is empty\");\n  }\n\n  auto executor = co_await asio::this_coro::executor;\n\n  auto operation = std::make_shared<Core::HttpClient::State::RequestOperation>();\n  operation->executor = executor;\n  operation->completion_timer.emplace(executor);\n  operation->completion_timer->expires_at((std::chrono::steady_clock::time_point::max)());\n  operation->request = request;\n\n  auto& dl = operation->download.emplace();\n  dl.output_path = output_path;\n  dl.progress_callback = std::move(progress_callback);\n  dl.output_file.emplace(output_path, std::ios::binary | std::ios::trunc);\n  if (!dl.output_file->is_open()) {\n    co_return std::unexpected(\"Failed to open output file: \" + output_path.string());\n  }\n\n  co_await Detail::execute_operation(*state.http_client, operation);\n\n  if (!operation->result) {\n    operation->download->output_file.reset();\n    co_return std::unexpected(operation->result.error());\n  }\n\n  if (operation->response.status_code != 200) {\n    co_return std::unexpected(\"HTTP error: \" + std::to_string(operation->response.status_code));\n  }\n\n  co_return std::expected<void, std::string>{};\n}\n\n}  // namespace Core::HttpClient\n"
  },
  {
    "path": "src/core/http_client/http_client.ixx",
    "content": "module;\n\n#include <asio.hpp>\n\nexport module Core.HttpClient;\n\nimport std;\nimport Core.State;\nimport Core.HttpClient.Types;\n\nnamespace Core::HttpClient {\n\nexport auto initialize(Core::State::AppState& state) -> std::expected<void, std::string>;\n\nexport auto shutdown(Core::State::AppState& state) -> void;\n\nexport auto fetch(Core::State::AppState& state, const Core::HttpClient::Types::Request& request)\n    -> asio::awaitable<std::expected<Core::HttpClient::Types::Response, std::string>>;\n\nexport auto download_to_file(\n    Core::State::AppState& state, const Core::HttpClient::Types::Request& request,\n    const std::filesystem::path& output_path,\n    Core::HttpClient::Types::DownloadProgressCallback progress_callback = nullptr)\n    -> asio::awaitable<std::expected<void, std::string>>;\n\n}  // namespace Core::HttpClient\n"
  },
  {
    "path": "src/core/http_client/state.ixx",
    "content": "module;\n\nexport module Core.HttpClient.State;\n\nimport std;\nimport Core.HttpClient.Types;\nimport Vendor.WinHttp;\nimport <asio.hpp>;\n\nexport namespace Core::HttpClient::State {\n\n// 单次异步 HTTP 请求的完整运行时上下文。对象由协程侧创建，通过 keepalive 自引用延长生命周期，\n// 确保在 WinHTTP 后台回调结束前不被析构。\nstruct RequestOperation {\n  // 自引用保活指针：在请求投递后置为自身，待操作彻底完成（包括句柄关闭回调）后清除。\n  std::shared_ptr<RequestOperation> keepalive;\n  std::mutex keepalive_mutex;\n\n  Core::HttpClient::Types::Request request;\n  Core::HttpClient::Types::Response response;\n  // 最终结果：初始为\"未完成\"错误态，由 complete_operation / complete_with_error 写入。\n  std::expected<Core::HttpClient::Types::Response, std::string> result =\n      std::unexpected(\"Request is not completed\");\n\n  // 协程执行器与完成通知定时器：timer 到期即唤醒挂起的协程。\n  asio::any_io_executor executor;\n  std::optional<asio::steady_timer> completion_timer = std::nullopt;\n\n  // WinHTTP 接口所需的 UTF-16 宽字符串，由 prepare_operation 在投递前填充。\n  std::wstring wide_url;\n  std::wstring wide_method;\n  std::wstring wide_host;\n  std::wstring wide_path;\n  std::wstring wide_headers;\n\n  std::vector<char> request_body;\n  // 固定大小的读缓冲区，每次 WinHttpReadData 将数据写入此处。\n  std::array<char, 16 * 1024> read_buffer{};\n\n  // 文件下载专用选项。有值表示当前请求为文件下载模式，响应体将流式写入文件而非内存。\n  struct DownloadOptions {\n    std::filesystem::path output_path;\n    std::optional<std::ofstream> output_file;\n    std::uint64_t downloaded_bytes = 0;\n    std::optional<std::uint64_t> total_bytes;  // 来自 Content-Length，服务器不保证提供\n    Core::HttpClient::Types::DownloadProgressCallback progress_callback;\n  };\n  std::optional<DownloadOptions> download;\n\n  Vendor::WinHttp::HINTERNET connect_handle = nullptr;\n  Vendor::WinHttp::HINTERNET request_handle = nullptr;\n\n  Vendor::WinHttp::INTERNET_PORT port = 0;\n  bool secure = false;\n  bool callback_registered = false;  // 已向 request_handle 注册状态回调\n  bool receive_started = false;\n\n  std::atomic<bool> completed{false};        // 操作是否已进入完成状态（结果已写入）\n  std::atomic<bool> waiter_notified{false};  // 协程唤醒通知是否已发出（防止重复触发）\n  std::atomic<bool> close_requested{false};  // WinHttpCloseHandle 是否已调用（防止重复关闭）\n};\n\nstruct HttpClientState {\n  Vendor::WinHttp::UniqueHInternet session;\n  std::wstring user_agent = L\"SpinningMomo/1.0\";\n\n  int resolve_timeout_ms = 0;\n  int connect_timeout_ms = 10'000;\n  int send_timeout_ms = 30'000;\n  int receive_timeout_ms = 30'000;\n\n  std::atomic<bool> is_initialized{false};\n};\n\n}  // namespace Core::HttpClient::State\n"
  },
  {
    "path": "src/core/http_client/types.ixx",
    "content": "module;\n\nexport module Core.HttpClient.Types;\n\nimport std;\n\nexport namespace Core::HttpClient::Types {\n\nstruct DownloadProgress {\n  std::uint64_t downloaded_bytes = 0;\n  std::optional<std::uint64_t> total_bytes;\n};\n\nusing DownloadProgressCallback = std::function<void(const DownloadProgress&)>;\n\nstruct Header {\n  std::string name;\n  std::string value;\n};\n\nstruct Request {\n  std::string method = \"GET\";\n  std::string url;\n  std::vector<Header> headers;\n  std::string body;\n  std::optional<std::int32_t> connect_timeout_ms = std::nullopt;\n  std::optional<std::int32_t> send_timeout_ms = std::nullopt;\n  std::optional<std::int32_t> receive_timeout_ms = std::nullopt;\n};\n\nstruct Response {\n  std::int32_t status_code = 0;\n  std::string body;\n  std::vector<Header> headers;\n};\n\n}  // namespace Core::HttpClient::Types\n"
  },
  {
    "path": "src/core/http_server/http_server.cpp",
    "content": "module;\n\n#include <uwebsockets/App.h>\n\nmodule Core.HttpServer;\n\nimport std;\nimport Core.State;\nimport Core.HttpServer.State;\nimport Core.HttpServer.Routes;\nimport Core.HttpServer.SseManager;\nimport Utils.Logger;\n\nnamespace Core::HttpServer {\n\nauto initialize(Core::State::AppState& state) -> std::expected<void, std::string> {\n  try {\n    Logger().info(\"Initializing HTTP server on port {}\", state.http_server->port);\n\n    state.http_server->server_thread = std::jthread([&state]() {\n      Logger().info(\"Starting HTTP server thread\");\n\n      // 在线程中创建uWS::App实例，生命周期由线程管理\n      uWS::App app;\n\n      Core::HttpServer::Routes::register_routes(state, app);\n\n      // 仅监听本机回环地址，避免暴露到局域网\n      app.listen(\"127.0.0.1\", state.http_server->port, [&state](auto* socket) {\n        if (socket) {\n          state.http_server->listen_socket = socket;\n          state.http_server->is_running = true;\n          Logger().info(\"HTTP server listening on 127.0.0.1:{}\", state.http_server->port);\n        } else {\n          Logger().error(\"Failed to start HTTP server on 127.0.0.1:{}\", state.http_server->port);\n          state.http_server->is_running = false;\n        }\n      });\n\n      // 运行事件循环\n      if (state.http_server->is_running) {\n        state.http_server->loop = uWS::Loop::get();\n        app.run();\n      }\n      Logger().info(\"HTTP server thread finished\");\n    });\n\n    return {};\n  } catch (const std::exception& e) {\n    return std::unexpected(std::string(\"Failed to initialize HTTP server: \") + e.what());\n  }\n}\n\nauto shutdown(Core::State::AppState& state) -> void {\n  if (!state.http_server || !state.http_server->is_running) {\n    return;\n  }\n\n  Logger().info(\"Shutting down HTTP server\");\n\n  auto active_sse = Core::HttpServer::SseManager::get_connection_count(state);\n  Logger().info(\"Active SSE connections before shutdown: {}\", active_sse);\n\n  // 提前标记停止，避免 shutdown 过程中继续广播 SSE 事件\n  state.http_server->is_running = false;\n\n  auto* loop = state.http_server->loop;\n  auto* listen_socket = state.http_server->listen_socket;\n\n  // 使用 defer 将关闭操作调度到事件循环线程\n  if (loop) {\n    Logger().info(\"Scheduling SSE close and socket close\");\n    loop->defer([&state, listen_socket]() {\n      Core::HttpServer::SseManager::close_all_connections(state);\n\n      if (listen_socket) {\n        us_listen_socket_close(0, listen_socket);\n        Logger().info(\"Listen socket closed\");\n      }\n    });\n  } else {\n    Logger().warn(\"HTTP loop is null during shutdown; listen socket close was not scheduled\");\n  }\n\n  if (state.http_server->server_thread.joinable()) {\n    state.http_server->server_thread.join();\n  }\n\n  state.http_server->listen_socket = nullptr;\n  state.http_server->loop = nullptr;\n\n  auto remaining_sse = Core::HttpServer::SseManager::get_connection_count(state);\n  Logger().info(\"Remaining SSE connections after shutdown: {}\", remaining_sse);\n  Logger().info(\"HTTP server shut down\");\n}\n\nauto get_sse_connection_count(const Core::State::AppState& state) -> size_t {\n  return Core::HttpServer::SseManager::get_connection_count(state);\n}\n}  // namespace Core::HttpServer\n"
  },
  {
    "path": "src/core/http_server/http_server.ixx",
    "content": "module;\n\nexport module Core.HttpServer;\n\nimport std;\nimport Core.State;\n\nnamespace Core::HttpServer {\n    // 初始化HTTP服务器\n    export auto initialize(Core::State::AppState& state) -> std::expected<void, std::string>;\n    \n    // 关闭服务器\n    export auto shutdown(Core::State::AppState& state) -> void;\n    \n    // 获取SSE连接数量\n    export auto get_sse_connection_count(const Core::State::AppState& state) -> size_t;\n}"
  },
  {
    "path": "src/core/http_server/routes.cpp",
    "content": "module;\n\n#include <uwebsockets/App.h>\n\n#include <asio.hpp>\n\nmodule Core.HttpServer.Routes;\n\nimport std;\nimport Core.State;\nimport Core.HttpServer.State;\nimport Core.HttpServer.SseManager;\nimport Core.HttpServer.Static;\nimport Core.Async;\nimport Core.RPC;\nimport Utils.Logger;\nimport Vendor.BuildConfig;\n\nnamespace Core::HttpServer::Routes {\n\nauto get_origin_header(auto* req) -> std::string { return std::string(req->getHeader(\"origin\")); }\n\nauto is_local_origin_allowed(std::string_view origin, int port) -> bool {\n  const auto localhost = std::format(\"http://localhost:{}\", port);\n  const auto loopback_v4 = std::format(\"http://127.0.0.1:{}\", port);\n  const auto loopback_v6 = std::format(\"http://[::1]:{}\", port);\n\n  return origin == localhost || origin == loopback_v4 || origin == loopback_v6;\n}\n\nauto is_origin_allowed(std::string_view origin, int port) -> bool {\n  if (origin.empty()) {\n    // 无 Origin 通常来自非浏览器本地请求。\n    return true;\n  }\n\n  // 开发模式放行所有 Origin，便于局域网/多设备联调。\n  if (Vendor::BuildConfig::is_debug_build()) {\n    return true;\n  }\n\n  // 发布模式仅允许本机同端口来源。\n  return is_local_origin_allowed(origin, port);\n}\n\nauto write_cors_headers(auto* res, std::string_view origin) -> void {\n  if (!origin.empty()) {\n    res->writeHeader(\"Access-Control-Allow-Origin\", origin);\n    res->writeHeader(\"Vary\", \"Origin\");\n  }\n  res->writeHeader(\"Access-Control-Allow-Methods\", \"GET, POST, OPTIONS\");\n  res->writeHeader(\"Access-Control-Allow-Headers\", \"Content-Type\");\n}\n\nauto reject_forbidden(auto* res) -> void {\n  res->writeStatus(\"403 Forbidden\");\n  res->end(\"Forbidden\");\n}\n\nauto register_routes(Core::State::AppState& state, uWS::App& app) -> void {\n  // 检查状态是否已初始化\n  if (!state.http_server) {\n    Logger().error(\"HTTP server not initialized\");\n    return;\n  }\n\n  // 注册RPC端点\n  app.post(\"/rpc\", [&state](auto* res, auto* req) {\n    auto origin = get_origin_header(req);\n    if (!is_origin_allowed(origin, state.http_server->port)) {\n      Logger().warn(\"Rejected RPC request due to disallowed origin: {}\",\n                    origin.empty() ? \"<empty>\" : origin);\n      reject_forbidden(res);\n      return;\n    }\n\n    std::string buffer;\n    res->onData([&state, buffer = std::move(buffer), origin = std::move(origin), res](\n                    std::string_view data, bool last) mutable {\n      buffer.append(data.data(), data.size());\n\n      if (last) {\n        // 使用 cork 包裹整个异步操作，延长 res 的生命周期\n        res->cork([&state, buffer = std::move(buffer), origin = std::move(origin), res]() {\n          // 获取事件循环\n          auto* loop = uWS::Loop::get();\n\n          // 在异步运行时中处理RPC请求\n          asio::co_spawn(\n              *Core::Async::get_io_context(*state.async),\n              [&state, buffer = std::move(buffer), origin = std::move(origin), res,\n               loop]() -> asio::awaitable<void> {\n                try {\n                  // 处理RPC请求\n                  auto response_json = co_await Core::RPC::process_request(state, buffer);\n\n                  // 在事件循环线程中发送响应\n                  loop->defer([res, origin, response_json = std::move(response_json)]() {\n                    write_cors_headers(res, origin);\n                    res->writeHeader(\"Content-Type\", \"application/json\");\n                    res->writeStatus(\"200 OK\");\n                    res->end(response_json);\n                  });\n                } catch (const std::exception& e) {\n                  Logger().error(\"Error processing RPC request: {}\", e.what());\n\n                  std::string error_response =\n                      std::format(R\"({{\"error\": \"Internal server error: {}\"}})\", e.what());\n\n                  loop->defer([res, origin, error_response = std::move(error_response)]() {\n                    write_cors_headers(res, origin);\n                    res->writeHeader(\"Content-Type\", \"application/json\");\n                    res->writeStatus(\"500 Internal Server Error\");\n                    res->end(error_response);\n                  });\n                }\n              },\n              asio::detached);\n        });\n      }\n    });\n\n    // 连接中止时记录日志\n    res->onAborted([]() { Logger().debug(\"RPC request aborted\"); });\n  });\n\n  // 注册SSE端点\n  app.get(\"/sse\", [&state](auto* res, auto* req) {\n    auto origin = get_origin_header(req);\n    if (!is_origin_allowed(origin, state.http_server->port)) {\n      Logger().warn(\"Rejected SSE request due to disallowed origin: {}\",\n                    origin.empty() ? \"<empty>\" : origin);\n      reject_forbidden(res);\n      return;\n    }\n\n    Logger().info(\"New SSE connection request\");\n    Core::HttpServer::SseManager::add_connection(state, res, std::move(origin));\n  });\n\n  // 配置CORS\n  app.options(\"/*\", [&state](auto* res, auto* req) {\n    auto origin = get_origin_header(req);\n    if (!is_origin_allowed(origin, state.http_server->port)) {\n      reject_forbidden(res);\n      return;\n    }\n\n    write_cors_headers(res, origin);\n    res->writeStatus(\"204 No Content\");\n    res->end();\n  });\n\n  // 静态文件服务（fallback路由）\n  Core::HttpServer::Static::register_routes(state, app);\n}\n}  // namespace Core::HttpServer::Routes\n"
  },
  {
    "path": "src/core/http_server/routes.ixx",
    "content": "module;\n\n#include <uwebsockets/App.h>\n\nexport module Core.HttpServer.Routes;\n\nimport std;\nimport Core.State;\n\nnamespace Core::HttpServer::Routes {\n    // 注册所有路由\n    export auto register_routes(Core::State::AppState& state, uWS::App& app) -> void;\n}"
  },
  {
    "path": "src/core/http_server/sse_manager.cpp",
    "content": "module;\n\n#include <uwebsockets/App.h>\n\nmodule Core.HttpServer.SseManager;\n\nimport std;\nimport Core.State;\nimport Core.HttpServer.State;\nimport Core.HttpServer.Types;\nimport Utils.Logger;\n\nnamespace Core::HttpServer::SseManager {\n\nauto format_sse_message(const std::string& event_data) -> std::string {\n  return std::format(\"data: {}\\n\\n\", event_data);\n}\n\nauto add_connection(Core::State::AppState& state, uWS::HttpResponse<false>* response,\n                    std::string allowed_origin) -> void {\n  if (!state.http_server || !response) {\n    Logger().error(\"Cannot add SSE connection: invalid state or response\");\n    return;\n  }\n\n  auto& connections = state.http_server->sse_connections;\n  auto& counter = state.http_server->client_counter;\n  auto& mtx = state.http_server->sse_connections_mutex;\n\n  auto connection = std::make_shared<Types::SseConnection>();\n  connection->response = response;\n  connection->client_id = std::to_string(++counter);\n  connection->connected_at = std::chrono::system_clock::now();\n\n  response->onAborted(\n      [&state, client_id = connection->client_id]() { remove_connection(state, client_id); });\n\n  response->writeStatus(\"200 OK\");\n  response->writeHeader(\"Content-Type\", \"text/event-stream\");\n  response->writeHeader(\"Cache-Control\", \"no-cache\");\n  response->writeHeader(\"Connection\", \"keep-alive\");\n  if (!allowed_origin.empty()) {\n    response->writeHeader(\"Access-Control-Allow-Origin\", allowed_origin);\n    response->writeHeader(\"Vary\", \"Origin\");\n  }\n  response->write(\": connected\\n\\n\");\n\n  size_t current_count = 0;\n  {\n    std::lock_guard<std::mutex> lock(mtx);\n    connections.push_back(connection);\n    current_count = connections.size();\n  }\n\n  Logger().info(\"New SSE connection established. client_id={}, total={}\", connection->client_id,\n                current_count);\n}\n\nauto remove_connection(Core::State::AppState& state, const std::string& client_id) -> void {\n  if (!state.http_server) {\n    return;\n  }\n\n  auto& connections = state.http_server->sse_connections;\n  auto& mtx = state.http_server->sse_connections_mutex;\n\n  std::lock_guard<std::mutex> lock(mtx);\n\n  auto old_size = connections.size();\n  auto it = std::remove_if(connections.begin(), connections.end(),\n                           [&client_id](const std::shared_ptr<Types::SseConnection>& conn) {\n                             if (conn && conn->client_id == client_id) {\n                               conn->is_closed = true;\n                               return true;\n                             }\n                             return false;\n                           });\n  connections.erase(it, connections.end());\n\n  if (connections.size() < old_size) {\n    Logger().info(\"SSE connection removed. client_id={}, total={}\", client_id, connections.size());\n  }\n}\n\nauto close_all_connections(Core::State::AppState& state) -> void {\n  if (!state.http_server) {\n    return;\n  }\n\n  auto& connections = state.http_server->sse_connections;\n  auto& mtx = state.http_server->sse_connections_mutex;\n\n  std::vector<std::shared_ptr<Types::SseConnection>> snapshot;\n  {\n    std::lock_guard<std::mutex> lock(mtx);\n    snapshot.reserve(connections.size());\n    for (const auto& conn : connections) {\n      if (!conn) {\n        continue;\n      }\n      conn->is_closed = true;\n      snapshot.push_back(conn);\n    }\n    connections.clear();\n  }\n\n  size_t closed_count = 0;\n  for (const auto& conn : snapshot) {\n    if (!conn || !conn->response) {\n      continue;\n    }\n    conn->response->end();\n    ++closed_count;\n  }\n\n  Logger().info(\"Closed {} SSE connections during shutdown\", closed_count);\n}\n\nauto broadcast_event(Core::State::AppState& state, const std::string& event_data) -> void {\n  if (!state.http_server || !state.http_server->is_running) {\n    return;\n  }\n\n  auto* loop = state.http_server->loop;\n  if (!loop) {\n    return;\n  }\n\n  auto sse_message = format_sse_message(event_data);\n\n  loop->defer([&state, sse_message = std::move(sse_message)]() {\n    if (!state.http_server) {\n      return;\n    }\n\n    auto& connections = state.http_server->sse_connections;\n    auto& mtx = state.http_server->sse_connections_mutex;\n\n    std::vector<std::shared_ptr<Types::SseConnection>> snapshot;\n    {\n      std::lock_guard<std::mutex> lock(mtx);\n      snapshot.reserve(connections.size());\n      for (const auto& conn : connections) {\n        if (conn && !conn->is_closed) {\n          snapshot.push_back(conn);\n        }\n      }\n    }\n\n    if (snapshot.empty()) {\n      return;\n    }\n\n    for (const auto& conn : snapshot) {\n      if (!conn || !conn->response || conn->is_closed) {\n        continue;\n      }\n      const auto ok = conn->response->write(sse_message);\n      if (!ok) {\n        Logger().warn(\"SSE write reported backpressure for client {}\", conn->client_id);\n      }\n    }\n  });\n}\n\nauto get_connection_count(const Core::State::AppState& state) -> size_t {\n  if (!state.http_server) {\n    return 0;\n  }\n\n  auto& connections = state.http_server->sse_connections;\n  auto& mtx = state.http_server->sse_connections_mutex;\n\n  std::lock_guard<std::mutex> lock(mtx);\n  return connections.size();\n}\n}  // namespace Core::HttpServer::SseManager\n"
  },
  {
    "path": "src/core/http_server/sse_manager.ixx",
    "content": "module;\n\n#include <uwebsockets/App.h>\n\nexport module Core.HttpServer.SseManager;\n\nimport std;\nimport Core.State;\n\nnamespace Core::HttpServer::SseManager {\n// 添加 SSE 连接\nexport auto add_connection(Core::State::AppState& state, uWS::HttpResponse<false>* response,\n                           std::string allowed_origin = \"\") -> void;\n\n// 移除 SSE 连接\nexport auto remove_connection(Core::State::AppState& state, const std::string& client_id) -> void;\n\n// 关闭所有 SSE 连接（应在 HTTP loop 线程调用）\nexport auto close_all_connections(Core::State::AppState& state) -> void;\n\n// 广播事件到所有 SSE 客户端（线程安全，内部会切换到 HTTP loop 线程）\nexport auto broadcast_event(Core::State::AppState& state, const std::string& event_data) -> void;\n\n// 获取 SSE 连接数量\nexport auto get_connection_count(const Core::State::AppState& state) -> size_t;\n}  // namespace Core::HttpServer::SseManager\n"
  },
  {
    "path": "src/core/http_server/state.ixx",
    "content": "module;\n\n#include <uwebsockets/App.h>\n\nexport module Core.HttpServer.State;\n\nimport std;\nimport Core.HttpServer.Types;\n\nexport namespace Core::HttpServer::State {\n\n// HTTP服务器状态\nstruct HttpServerState {\n  // 服务器核心\n  std::jthread server_thread{};\n  us_listen_socket_t* listen_socket{nullptr};\n  uWS::Loop* loop{nullptr};\n\n  // SSE连接管理\n  std::vector<std::shared_ptr<Types::SseConnection>> sse_connections;\n  std::atomic<std::uint64_t> client_counter{0};\n  std::mutex sse_connections_mutex;\n  std::atomic<bool> is_running{false};\n\n  // 服务器配置\n  int port{51206};\n\n  // 路径解析器注册表\n  Types::ResolverRegistry path_resolvers;\n};\n\n}  // namespace Core::HttpServer::State"
  },
  {
    "path": "src/core/http_server/static.cpp",
    "content": "module;\n\n#include <uwebsockets/App.h>\n\n#include <asio.hpp>\n\nmodule Core.HttpServer.Static;\n\nimport std;\nimport Core.State;\nimport Core.HttpServer.Types;\nimport Core.Async;\nimport Utils.File;\nimport Utils.File.Mime;\nimport Utils.Path;\nimport Utils.Logger;\nimport Utils.Time;\n\nnamespace Core::HttpServer::Static {\n\nauto register_path_resolver(Core::State::AppState& state, std::string prefix,\n                            Types::PathResolver resolver) -> void {\n  if (!state.http_server) {\n    Logger().error(\"HttpServer state not initialized, cannot register path resolver\");\n    return;\n  }\n\n  auto& registry = state.http_server->path_resolvers;\n  std::unique_lock lock(registry.write_mutex);\n\n  auto current = registry.resolvers.load();\n  auto new_resolvers = std::make_shared<std::vector<Types::ResolverEntry>>(*current);\n  new_resolvers->push_back({std::move(prefix), std::move(resolver)});\n  registry.resolvers.store(new_resolvers);\n\n  Logger().debug(\"Registered custom path resolver for: {}\", prefix);\n}\n\nauto unregister_path_resolver(Core::State::AppState& state, std::string_view prefix) -> void {\n  if (!state.http_server) {\n    return;\n  }\n\n  auto& registry = state.http_server->path_resolvers;\n  std::unique_lock lock(registry.write_mutex);\n\n  auto current = registry.resolvers.load();\n  auto new_resolvers = std::make_shared<std::vector<Types::ResolverEntry>>(*current);\n  std::erase_if(*new_resolvers, [prefix](const auto& entry) { return entry.prefix == prefix; });\n  registry.resolvers.store(new_resolvers);\n\n  Logger().debug(\"Unregistered path resolver for: {}\", prefix);\n}\n\nauto try_custom_resolve(Core::State::AppState& state, std::string_view url_path)\n    -> std::optional<Types::PathResolution> {\n  if (!state.http_server) {\n    return std::nullopt;\n  }\n\n  auto& registry = state.http_server->path_resolvers;\n  auto resolvers = registry.resolvers.load();\n\n  for (const auto& entry : *resolvers) {\n    if (url_path.starts_with(entry.prefix)) {\n      auto result = entry.resolver(url_path);\n      if (result.has_value()) {\n        return result;\n      }\n    }\n  }\n  return std::nullopt;\n}\n\nauto is_safe_path(const std::filesystem::path& path, const std::filesystem::path& base_path)\n    -> std::expected<bool, std::string> {\n  try {\n    std::filesystem::path normalized_path = path.lexically_normal();\n    std::filesystem::path normalized_base = base_path.lexically_normal();\n\n    std::string path_str = normalized_path.string();\n    std::string base_str = normalized_base.string();\n\n    // 检查路径是否以基础路径开头，或者路径等于基础路径\n    bool is_safe = path_str.rfind(base_str, 0) == 0 || normalized_path == normalized_base;\n    return is_safe;\n  } catch (const std::exception& e) {\n    return std::unexpected(\"Failed to check path safety: \" + std::string(e.what()));\n  }\n}\n\n// 获取针对不同文件类型的缓存时间\nauto get_cache_duration(const std::string& extension) -> std::chrono::seconds {\n  // HTML文件：短缓存，便于开发调试\n  if (extension == \".html\") return std::chrono::seconds{60};\n\n  // CSS/JS：中等缓存\n  if (extension == \".css\" || extension == \".js\") return std::chrono::seconds{300};\n\n  // 图片/字体：长缓存\n  if (extension == \".png\" || extension == \".jpg\" || extension == \".jpeg\" || extension == \".svg\" ||\n      extension == \".woff\" || extension == \".woff2\" || extension == \".webp\") {\n    return std::chrono::seconds{3600};\n  }\n\n  // 默认：短缓存\n  return std::chrono::seconds{180};\n}\n\n// 路径解析\nauto resolve_file_path(const std::string& url_path) -> std::filesystem::path {\n  auto web_root = Utils::Path::GetEmbeddedWebRootDirectory().value_or(\".\");\n\n  auto clean_path = url_path == \"/\" ? \"/index.html\" : url_path;\n  if (clean_path.ends_with(\"/\")) clean_path += \"index.html\";\n\n  return web_root / clean_path.substr(1);  // 移除开头的'/'\n}\n\n// 获取web根目录\nauto get_web_root() -> std::filesystem::path {\n  return Utils::Path::GetEmbeddedWebRootDirectory().value_or(\".\");\n}\n\n// ---- Range 请求：<video> 拖动进度、分片加载依赖 Accept-Ranges + 206 + Content-Range ----\nstruct ByteRange {\n  size_t start = 0;\n  size_t end = 0;  // inclusive\n};\n\nstruct RangeHeaderParseResult {\n  bool valid = true;\n  std::optional<ByteRange> range;\n};\n\nauto parse_range_header(std::string_view header_value, size_t file_size) -> RangeHeaderParseResult {\n  if (header_value.empty()) {\n    return {};\n  }\n\n  // V1 仅支持单一 byte range，已经足够覆盖浏览器 / <video> 的 seek 场景。\n  if (!header_value.starts_with(\"bytes=\") || file_size == 0) {\n    return {.valid = false, .range = std::nullopt};\n  }\n\n  auto range_spec = header_value.substr(6);\n  auto comma_pos = range_spec.find(',');\n  if (comma_pos != std::string_view::npos) {\n    return {.valid = false, .range = std::nullopt};\n  }\n\n  auto dash_pos = range_spec.find('-');\n  if (dash_pos == std::string_view::npos) {\n    return {.valid = false, .range = std::nullopt};\n  }\n\n  auto start_part = range_spec.substr(0, dash_pos);\n  auto end_part = range_spec.substr(dash_pos + 1);\n\n  if (start_part.empty()) {\n    size_t suffix_length = 0;\n    auto [ptr, ec] =\n        std::from_chars(end_part.data(), end_part.data() + end_part.size(), suffix_length);\n    if (ec != std::errc{} || ptr != end_part.data() + end_part.size() || suffix_length == 0) {\n      return {.valid = false, .range = std::nullopt};\n    }\n\n    size_t clamped_length = std::min(suffix_length, file_size);\n    return {.valid = true,\n            .range = ByteRange{.start = file_size - clamped_length, .end = file_size - 1}};\n  }\n\n  size_t start = 0;\n  auto [start_ptr, start_ec] =\n      std::from_chars(start_part.data(), start_part.data() + start_part.size(), start);\n  if (start_ec != std::errc{} || start_ptr != start_part.data() + start_part.size() ||\n      start >= file_size) {\n    return {.valid = false, .range = std::nullopt};\n  }\n\n  if (end_part.empty()) {\n    return {.valid = true, .range = ByteRange{.start = start, .end = file_size - 1}};\n  }\n\n  size_t end = 0;\n  auto [end_ptr, end_ec] = std::from_chars(end_part.data(), end_part.data() + end_part.size(), end);\n  if (end_ec != std::errc{} || end_ptr != end_part.data() + end_part.size() || end < start) {\n    return {.valid = false, .range = std::nullopt};\n  }\n\n  return {.valid = true, .range = ByteRange{.start = start, .end = std::min(end, file_size - 1)}};\n}\n\nauto get_response_content_type(const std::string& mime_type) -> std::string {\n  if (mime_type.starts_with(\"text/\") && !mime_type.contains(\"charset=\")) {\n    return mime_type + \"; charset=utf-8\";\n  }\n\n  return mime_type;\n}\n\nstruct CacheValidators {\n  std::string etag;\n  std::string last_modified;\n};\n\n// 去掉条件请求头两端的空白，避免匹配时受逗号分段或客户端格式影响。\nauto trim_http_header_value(std::string_view value) -> std::string_view {\n  while (!value.empty() && std::isspace(static_cast<unsigned char>(value.front()))) {\n    value.remove_prefix(1);\n  }\n  while (!value.empty() && std::isspace(static_cast<unsigned char>(value.back()))) {\n    value.remove_suffix(1);\n  }\n  return value;\n}\n\n// 为默认静态资源生成强缓存头；自定义 resolver 可再覆盖成更具体的策略。\nauto build_cache_control_header(std::chrono::seconds cache_duration) -> std::string {\n  return std::format(\"public, max-age={}\", cache_duration.count());\n}\n\n// 基于文件大小和最后修改时间构造条件缓存校验器，避免为原图额外计算内容哈希。\nauto build_cache_validators(const std::filesystem::path& file_path, size_t file_size)\n    -> std::expected<CacheValidators, std::string> {\n  std::error_code ec;\n  auto last_write_time = std::filesystem::last_write_time(file_path, ec);\n  if (ec) {\n    return std::unexpected(\"Failed to query file last write time: \" + ec.message());\n  }\n\n  auto modified_time = std::chrono::time_point_cast<std::chrono::seconds>(\n      Utils::Time::file_time_to_system_clock(last_write_time));\n  auto modified_seconds = Utils::Time::file_time_to_seconds(last_write_time);\n\n  return CacheValidators{\n      .etag = std::format(\"\\\"{:x}-{:x}\\\"\", file_size, modified_seconds),\n      .last_modified = std::format(\"{:%a, %d %b %Y %H:%M:%S GMT}\", modified_time)};\n}\n\n// HTTP 的 If-None-Match 允许逗号分隔多个 ETag；这里只要任一命中即可视为未变更。\nauto if_none_match_matches(std::string_view header_value, std::string_view etag) -> bool {\n  auto remaining = header_value;\n  while (!remaining.empty()) {\n    auto comma_pos = remaining.find(',');\n    auto candidate = trim_http_header_value(remaining.substr(0, comma_pos));\n    if (candidate == \"*\" || candidate == etag) {\n      return true;\n    }\n\n    if (comma_pos == std::string_view::npos) {\n      break;\n    }\n    remaining.remove_prefix(comma_pos + 1);\n  }\n  return false;\n}\n\n// 统一判断当前请求是否满足 304 条件；Range 请求保持走实体响应，避免和部分内容语义混淆。\nauto is_not_modified_request(auto* req, const CacheValidators& validators, bool has_range_request)\n    -> bool {\n  if (has_range_request) {\n    return false;\n  }\n\n  auto if_none_match = trim_http_header_value(std::string_view(req->getHeader(\"if-none-match\")));\n  if (!if_none_match.empty()) {\n    return if_none_match_matches(if_none_match, validators.etag);\n  }\n\n  auto if_modified_since =\n      trim_http_header_value(std::string_view(req->getHeader(\"if-modified-since\")));\n  if (!if_modified_since.empty()) {\n    return if_modified_since == validators.last_modified;\n  }\n\n  return false;\n}\n\n// 写出文件响应的公共缓存/范围头；200 与 206 响应共用这套头部逻辑。\nauto write_common_file_headers(auto* res, const std::string& mime_type,\n                               std::string_view cache_control, const CacheValidators& validators,\n                               std::optional<size_t> source_file_size = std::nullopt,\n                               std::optional<ByteRange> range = std::nullopt) -> void {\n  res->writeHeader(\"Content-Type\", get_response_content_type(mime_type));\n  res->writeHeader(\"Cache-Control\", std::string(cache_control));\n  res->writeHeader(\"X-Content-Type-Options\", \"nosniff\");\n  res->writeHeader(\"Accept-Ranges\", \"bytes\");\n  res->writeHeader(\"ETag\", validators.etag);\n  res->writeHeader(\"Last-Modified\", validators.last_modified);\n\n  if (range.has_value() && source_file_size.has_value()) {\n    res->writeHeader(\"Content-Range\", std::format(\"bytes {}-{}/{}\", range->start, range->end,\n                                                  source_file_size.value()));\n  }\n}\n\n// 304 响应不返回实体，但仍需回写缓存校验头，让浏览器更新缓存元数据。\nauto write_not_modified(auto* res, std::string_view cache_control,\n                        const CacheValidators& validators) -> void {\n  res->writeStatus(\"304 Not Modified\");\n  res->writeHeader(\"Cache-Control\", std::string(cache_control));\n  res->writeHeader(\"ETag\", validators.etag);\n  res->writeHeader(\"Last-Modified\", validators.last_modified);\n  res->end();\n}\n\nauto write_range_not_satisfiable(auto* res, size_t file_size) -> void {\n  res->writeStatus(\"416 Range Not Satisfiable\");\n  res->writeHeader(\"Accept-Ranges\", \"bytes\");\n  res->writeHeader(\"Content-Range\", std::format(\"bytes */{}\", file_size));\n  res->end();\n}\n\n// 在 uWS 线程中发送数据块\nauto send_chunk_to_uws(std::shared_ptr<Types::StreamContext> ctx,\n                       std::shared_ptr<std::string> chunk_data) -> void {\n  if (ctx->is_aborted) {\n    Logger().debug(\"Stream aborted, stopping\");\n    return;\n  }\n\n  // 记录发送前的偏移量（用于处理背压）\n  size_t chunk_start_offset = ctx->bytes_sent;\n\n  // tryEnd 的 total 必须为「整个 HTTP 响应体」长度；Range 时为片段长而非文件全长。\n  auto [ok, done] = ctx->res->tryEnd(*chunk_data, ctx->response_size);\n\n  if (!ok) {\n    // 背压：缓冲区满，需要等待可写\n    Logger().debug(\"Backpressure detected, waiting for writable\");\n\n    ctx->res->onWritable([ctx, chunk_data, chunk_start_offset](size_t) -> bool {\n      if (ctx->is_aborted) {\n        return false;  // 停止等待\n      }\n\n      // 计算已经发送的字节数\n      size_t already_sent = ctx->res->getWriteOffset() - chunk_start_offset;\n\n      if (already_sent >= chunk_data->size()) {\n        // 这个块已经全部发送完成\n        ctx->bytes_sent = ctx->res->getWriteOffset();\n        ctx->file_offset += chunk_data->size();\n\n        // 继续读下一块\n        read_and_send_next_chunk(ctx);\n        return false;  // 移除 onWritable\n      }\n\n      // 发送剩余数据\n      auto remaining = chunk_data->substr(already_sent);\n      auto [ok2, done2] = ctx->res->tryEnd(remaining, ctx->response_size);\n\n      if (ok2 || done2) {\n        // 发送成功\n        ctx->bytes_sent = ctx->res->getWriteOffset();\n        ctx->file_offset += chunk_data->size();\n\n        // 继续读下一块\n        read_and_send_next_chunk(ctx);\n        return false;  // 移除 onWritable\n      }\n\n      // 继续等待\n      return true;\n    });\n  } else {\n    // 发送成功，更新状态\n    ctx->bytes_sent = ctx->res->getWriteOffset();\n    ctx->file_offset += chunk_data->size();\n\n    if (done) {\n      // 整个响应已完成\n      Logger().debug(\"Stream completed: {}, sent {} bytes\", ctx->file_path.string(),\n                     ctx->bytes_sent);\n    } else {\n      // 继续读下一块\n      read_and_send_next_chunk(ctx);\n    }\n  }\n}\n\n// 读取并发送下一个数据块\nauto read_and_send_next_chunk(std::shared_ptr<Types::StreamContext> ctx) -> void {\n  // 检查是否完成\n  if (ctx->file_offset >= ctx->file_end_offset || ctx->is_aborted) {\n    if (!ctx->is_aborted) {\n      Logger().debug(\"Stream completed: {}, sent {} bytes\", ctx->file_path.string(),\n                     ctx->bytes_sent);\n    }\n    return;\n  }\n\n  // 计算本次读取大小\n  size_t to_read = std::min(Types::STREAM_CHUNK_SIZE, ctx->file_end_offset - ctx->file_offset);\n\n  // 异步读取文件块\n  ctx->file.async_read_some_at(\n      ctx->file_offset, asio::buffer(ctx->buffer.data(), to_read),\n      [ctx](std::error_code ec, size_t bytes_read) {\n        if (ec || bytes_read == 0) {\n          Logger().error(\"Failed to read file {}: {}\", ctx->file_path.string(),\n                         ec ? ec.message() : \"EOF\");\n          ctx->loop->defer([ctx]() {\n            ctx->res->writeStatus(\"500 Internal Server Error\");\n            ctx->res->end(\"Internal server error\");\n          });\n          return;\n        }\n\n        // 准备发送的数据（拷贝到独立的 string）\n        auto chunk_data = std::make_shared<std::string>(ctx->buffer.data(), bytes_read);\n\n        // 在 uWS 线程中发送\n        ctx->loop->defer([ctx, chunk_data]() { send_chunk_to_uws(ctx, chunk_data); });\n      });\n}\n\n// 流式传输文件\nauto handle_file_stream(Core::State::AppState& state, std::filesystem::path file_path,\n                        std::string mime_type, std::string cache_control,\n                        CacheValidators validators, size_t file_size,\n                        std::optional<ByteRange> range, auto* res) -> void {\n  auto* loop = uWS::Loop::get();\n  auto io_context = Core::Async::get_io_context(*state.async);\n\n  size_t range_start = range.has_value() ? range->start : 0;\n  size_t range_end = range.has_value() ? range->end : (file_size - 1);\n  size_t response_size = range_end >= range_start ? (range_end - range_start + 1) : 0;\n\n  // 对于大文件或分片请求，始终按偏移流式发送，避免把整段视频先读进内存。\n  // 在 ASIO 线程中打开文件并初始化\n  asio::post(*io_context, [res, file_path, mime_type, cache_control = std::move(cache_control),\n                           validators = std::move(validators), loop, io_context, file_size, range,\n                           range_start, range_end, response_size]() {\n    try {\n      // 打开文件\n      asio::random_access_file file(*io_context, file_path.string(), asio::file_base::read_only);\n\n      Logger().debug(\"Starting stream for file: {}, size: {} bytes\", file_path.string(), file_size);\n\n      // 创建流上下文\n      auto ctx = std::make_shared<Types::StreamContext>(Types::StreamContext{\n          .file = std::move(file),\n          .file_path = file_path,\n          .source_file_size = file_size,\n          .response_size = response_size,\n          .file_offset = range_start,\n          .file_end_offset = range_end + 1,\n          .mime_type = mime_type,\n          .cache_control = cache_control,\n          .etag = validators.etag,\n          .last_modified = validators.last_modified,\n          .bytes_sent = 0,\n          .status_code = range.has_value() ? 206 : 200,\n          .content_range_header = range.has_value()\n                                      ? std::optional<std::string>{std::format(\n                                            \"bytes {}-{}/{}\", range_start, range_end, file_size)}\n                                      : std::nullopt,\n          .loop = loop,\n          .res = res,\n          .buffer = std::vector<char>(Types::STREAM_CHUNK_SIZE),\n          .is_aborted = false,\n      });\n\n      // 在 uWS 线程中设置响应头并开始传输\n      loop->defer([ctx]() {\n        ctx->res->writeStatus(ctx->status_code == 206 ? \"206 Partial Content\" : \"200 OK\");\n        write_common_file_headers(\n            ctx->res, ctx->mime_type, ctx->cache_control,\n            CacheValidators{.etag = ctx->etag, .last_modified = ctx->last_modified},\n            ctx->source_file_size,\n            ctx->content_range_header.has_value()\n                ? std::optional<ByteRange>{ByteRange{.start = ctx->file_offset,\n                                                     .end = ctx->file_end_offset - 1}}\n                : std::nullopt);\n\n        // 处理中止\n        ctx->res->onAborted([ctx]() {\n          Logger().debug(\"Stream aborted for: {}\", ctx->file_path.string());\n          ctx->is_aborted = true;\n        });\n\n        // 开始读取并发送第一块\n        read_and_send_next_chunk(ctx);\n      });\n\n    } catch (const std::exception& e) {\n      Logger().error(\"Error opening file for stream {}: {}\", file_path.string(), e.what());\n      loop->defer([res]() {\n        res->writeStatus(\"500 Internal Server Error\");\n        res->end(\"Internal server error\");\n      });\n    }\n  });\n}\n\n// 图库 /static 原文件与磁盘 web 根路径共用：统一处理 Range、HEAD/GET，并择流式或整读。\nauto serve_resolved_file_request(Core::State::AppState& state,\n                                 const std::filesystem::path& file_path,\n                                 std::optional<std::chrono::seconds> cache_duration_override,\n                                 std::optional<std::string> cache_control_override, auto* res,\n                                 auto* req, bool is_head) -> void {\n  // 检查文件是否存在\n  if (!std::filesystem::exists(file_path)) {\n    Logger().warn(\"Resolved file not found: {}\", file_path.string());\n    res->writeStatus(\"404 Not Found\");\n    res->end(\"File not found\");\n    return;\n  }\n\n  // 获取文件大小\n  size_t file_size = std::filesystem::file_size(file_path);\n\n  // 决定mime类型和缓存时间\n  std::string mime_type = Utils::File::Mime::get_mime_type(file_path);\n  std::chrono::seconds cache_duration;\n  if (cache_duration_override) {\n    cache_duration = *cache_duration_override;\n  } else {\n    auto extension = file_path.extension().string();\n    cache_duration = get_cache_duration(extension);\n  }\n  auto cache_control = cache_control_override.value_or(build_cache_control_header(cache_duration));\n\n  auto validators_result = build_cache_validators(file_path, file_size);\n  if (!validators_result) {\n    Logger().error(\"Failed to build cache validators for {}: {}\", file_path.string(),\n                   validators_result.error());\n    res->writeStatus(\"500 Internal Server Error\");\n    res->end(\"Internal server error\");\n    return;\n  }\n  auto validators = std::move(validators_result.value());\n\n  auto range_parse = parse_range_header(std::string(req->getHeader(\"range\")), file_size);\n  if (!range_parse.valid) {\n    write_range_not_satisfiable(res, file_size);\n    return;\n  }\n\n  if (is_not_modified_request(req, validators, range_parse.range.has_value())) {\n    write_not_modified(res, cache_control, validators);\n    return;\n  }\n\n  size_t content_length = range_parse.range.has_value()\n                              ? (range_parse.range->end - range_parse.range->start + 1)\n                              : file_size;\n\n  if (is_head) {\n    res->writeStatus(range_parse.range.has_value() ? \"206 Partial Content\" : \"200 OK\");\n    write_common_file_headers(res, mime_type, cache_control, validators, file_size,\n                              range_parse.range);\n    res->writeHeader(\"Content-Length\", std::to_string(content_length));\n    res->end();\n    return;\n  }\n\n  // 视频任意 Range 都应流式发送，避免小 Range 却整文件读入内存（content_length 可能很小但 file\n  // 很大）。\n  if (content_length > Types::STREAM_THRESHOLD || file_size > Types::STREAM_THRESHOLD) {\n    Logger().debug(\"Using stream for resolved file: {} bytes\", file_size);\n    handle_file_stream(state, file_path, mime_type, cache_control, validators, file_size,\n                       range_parse.range, res);\n    return;\n  }\n\n  Logger().debug(\"Using single-read for small resolved file: {} bytes\", file_size);\n\n  // 获取当前的事件循环\n  auto* loop = uWS::Loop::get();\n\n  // 在异步运行时中处理文件读取\n  asio::co_spawn(\n      *Core::Async::get_io_context(*state.async),\n      [res, file_path, mime_type, cache_control = std::move(cache_control),\n       validators = std::move(validators), loop, file_size,\n       range = range_parse.range]() -> asio::awaitable<void> {\n        try {\n          // 异步读取文件\n          auto file_result = co_await Utils::File::read_file(file_path);\n          if (!file_result) {\n            Logger().error(\"Failed to read custom file: {}\", file_result.error());\n            loop->defer([res]() {\n              res->writeStatus(\"500 Internal Server Error\");\n              res->end(\"Internal server error\");\n            });\n            co_return;\n          }\n\n          auto file_data = file_result.value();\n          size_t range_start = range.has_value() ? range->start : 0;\n          size_t range_end = range.has_value() ? range->end : (file_size - 1);\n          size_t content_length = range_end >= range_start ? (range_end - range_start + 1) : 0;\n\n          std::string response_body(\n              reinterpret_cast<const char*>(file_data.data.data() + range_start), content_length);\n\n          // 在事件循环线程中发送响应\n          loop->defer([res, file_path, mime_type, cache_control, validators, file_size, range,\n                       response_body = std::move(response_body)]() mutable {\n            res->writeStatus(range.has_value() ? \"206 Partial Content\" : \"200 OK\");\n            write_common_file_headers(res, mime_type, cache_control, validators, file_size, range);\n            res->end(response_body);\n\n            Logger().debug(\"Served resolved file: {}, size: {} bytes\", file_path.string(),\n                           response_body.size());\n          });\n\n        } catch (const std::exception& e) {\n          Logger().error(\"Error serving resolved file {}: {}\", file_path.string(), e.what());\n          loop->defer([res]() {\n            res->writeStatus(\"500 Internal Server Error\");\n            res->end(\"Internal server error\");\n          });\n        }\n      },\n      asio::detached);\n}\n\n// 处理静态文件请求\nauto handle_static_request(Core::State::AppState& state, const std::string& url_path, auto* res,\n                           auto* req, bool is_head = false) -> void {\n  // 1. 先尝试自定义解析器\n  if (auto custom_result = try_custom_resolve(state, url_path)) {\n    if (custom_result->has_value()) {\n      Logger().debug(\"Using custom resolver for: {}\", url_path);\n      serve_resolved_file_request(state, custom_result->value().file_path,\n                                  custom_result->value().cache_duration,\n                                  custom_result->value().cache_control_header, res, req, is_head);\n      return;\n    }\n  }\n\n  // 2. 否则使用默认的 web 资源解析\n  auto file_path = resolve_file_path(url_path);\n  auto web_root = get_web_root();\n\n  // 路径安全检查\n  auto safety_check = is_safe_path(file_path, web_root);\n  if (!safety_check.has_value() || !safety_check.value()) {\n    Logger().warn(\"Unsafe path requested: {}\", file_path.string());\n    res->writeStatus(\"403 Forbidden\");\n    res->end(\"Forbidden\");\n    return;\n  }\n\n  // 检查文件是否存在\n  if (!std::filesystem::exists(file_path)) {\n    Logger().warn(\"File not found: {}\", file_path.string());\n    res->writeStatus(\"404 Not Found\");\n    res->end(\"File not found\");\n    return;\n  }\n\n  serve_resolved_file_request(state, file_path, std::nullopt, std::nullopt, res, req, is_head);\n}\n\n// 注册静态文件路由\nauto register_routes(Core::State::AppState& state, uWS::App& app) -> void {\n  Logger().info(\"Registering static file routes\");\n\n  // 注册通用的GET路由处理所有静态文件请求\n  app.get(\"/*\", [&state](auto* res, auto* req) {\n    std::string url = std::string(req->getUrl());\n    Logger().debug(\"Static file request: {}\", url);\n\n    // 使用 cork 包裹整个异步操作，延长 res 的生命周期\n    res->cork([&state, res, req, url]() {\n      Logger().debug(\"Corking static file request: {}\", url);\n      // 处理静态文件请求\n      handle_static_request(state, url, res, req, false);\n    });\n\n    // 连接中止时记录日志\n    res->onAborted([]() { Logger().debug(\"Static file request aborted\"); });\n  });\n\n  // 也处理HEAD请求（用于文件存在性检查）\n  app.head(\"/*\", [&state](auto* res, auto* req) {\n    std::string url = std::string(req->getUrl());\n    Logger().debug(\"Static file HEAD request: {}\", url);\n    handle_static_request(state, url, res, req, true);\n  });\n}\n\n}  // namespace Core::HttpServer::Static\n"
  },
  {
    "path": "src/core/http_server/static.ixx",
    "content": "module;\n\n#include <uwebsockets/App.h>\n\nexport module Core.HttpServer.Static;\n\nimport std;\nimport Core.State;\nimport Core.HttpServer.State;\nimport Core.HttpServer.Types;\n\nnamespace Core::HttpServer::Static {\n\n// 读取并发送下一个数据块\nauto read_and_send_next_chunk(std::shared_ptr<Types::StreamContext> ctx) -> void;\n\n// 注册自定义路径解析器（接受 AppState）\nexport auto register_path_resolver(Core::State::AppState& state, std::string prefix,\n                                   Types::PathResolver resolver) -> void;\n\n// 注销路径解析器\nexport auto unregister_path_resolver(Core::State::AppState& state, std::string_view prefix) -> void;\n\n// 注册静态文件路由（作为fallback）\nexport auto register_routes(Core::State::AppState& state, uWS::App& app) -> void;\n\n}  // namespace Core::HttpServer::Static\n"
  },
  {
    "path": "src/core/http_server/types.ixx",
    "content": "module;\n\n#include <uwebsockets/App.h>\n#include <asio.hpp>\n\nexport module Core.HttpServer.Types;\n\nimport std;\n\nexport namespace Core::HttpServer::Types {\n\n// ============= 流式传输配置 =============\n\n// 流式传输阈值：超过此大小使用流式传输\nconstexpr size_t STREAM_THRESHOLD = 1024 * 1024;  // 1MB\n\n// 流式传输块大小\nconstexpr size_t STREAM_CHUNK_SIZE = 65536;  // 64KB\n\n// 流式传输上下文（完整状态）\nstruct StreamContext {\n  // 文件相关\n  asio::random_access_file file;\n  std::filesystem::path file_path;\n  size_t source_file_size;  // 完整文件大小（Content-Range 里的总长）\n  size_t response_size;     // 本次 HTTP 体长度（uWS tryEnd 的 total；Range 时为片段字节数）\n  size_t file_offset;       // 下一次 async_read 的起始绝对偏移\n  size_t file_end_offset;   // 读到该偏移前停止（exclusive；即「尾字节 + 1」）\n\n  // 响应相关\n  std::string mime_type;\n  std::string cache_control;\n  std::string etag;\n  std::string last_modified;\n  size_t bytes_sent;\n  int status_code = 200;\n  std::optional<std::string> content_range_header;\n\n  // 运行时\n  uWS::Loop* loop;\n  uWS::HttpResponse<false>* res;\n  std::vector<char> buffer;\n\n  // 状态\n  bool is_aborted;\n};\n\n// 路径解析结果：成功时包含文件信息和缓存配置\nstruct PathResolutionData {\n  std::filesystem::path file_path;\n  std::optional<std::chrono::seconds> cache_duration;\n  std::optional<std::string> cache_control_header;\n};\n\nusing PathResolution = std::expected<PathResolutionData, std::string>;\nusing PathResolver = std::function<PathResolution(std::string_view)>;\n\nstruct ResolverEntry {\n  std::string prefix;\n  PathResolver resolver;\n};\n\n// 路径解析器注册表\nstruct ResolverRegistry {\n  // 使用 atomic shared_ptr 实现无锁读取（RCU 模式）\n  // 读取时无需加锁，写入时复制整个 vector\n  std::atomic<std::shared_ptr<const std::vector<ResolverEntry>>> resolvers{\n      std::make_shared<const std::vector<ResolverEntry>>()};\n\n  // 写锁：仅用于保护写操作之间的竞争\n  std::mutex write_mutex;\n};\n\n// SSE连接信息结构\nstruct SseConnection {\n  uWS::HttpResponse<false>* response = nullptr;\n  std::string client_id;\n  std::chrono::system_clock::time_point connected_at;\n  bool is_closed = false;\n};\n\n}  // namespace Core::HttpServer::Types\n"
  },
  {
    "path": "src/core/i18n/embedded/en_us.ixx",
    "content": "// Auto-generated embedded English locale module\n// DO NOT EDIT - This file contains embedded locale data\n//\n// Source: src/locales/en-US.json\n// Module: Core.I18n.Embedded.EnUS\n// Variable: en_us_json\n\nmodule;\n\nexport module Core.I18n.Embedded.EnUS;\n\nimport std;\n\nexport namespace EmbeddedLocales {\n// Embedded English JSON content as string_view\n// Size: 3962 bytes\nconstexpr std::string_view en_us_json = R\"EmbeddedJson({\n  \"version\": \"1.0\",\n\n  \"menu.app_main\": \"Main\",\n  \"menu.app_float\": \"Floating Window\",\n  \"menu.app_exit\": \"Exit\",\n  \"menu.app_user_guide\": \"User Guide\",\n  \"menu.float_show\": \"Show Floating Window\",\n  \"menu.float_hide\": \"Hide Floating Window\",\n  \"menu.float_toggle\": \"Show/Hide Floating Window\",\n\n  \"menu.window_select\": \"Select Window\",\n  \"menu.window_no_available\": \"(No Available Windows)\",\n  \"menu.window_ratio\": \"Window Ratio\",\n  \"menu.window_resolution\": \"Resolution\",\n  \"menu.window_reset\": \"Reset\",\n  \"menu.window_toggle_borderless\": \"Toggle Window Border\",\n\n  \"menu.screenshot_capture\": \"Capture\",\n  \"menu.output_open_folder\": \"Output Folder\",\n  \"menu.external_album_open_folder\": \"Game Album\",\n  \"menu.overlay_toggle\": \"Overlay\",\n  \"menu.preview_toggle\": \"Preview\",\n  \"menu.recording_toggle\": \"Record\",\n  \"menu.motion_photo_toggle\": \"Motion Photo\",\n  \"menu.replay_buffer_toggle\": \"Instant Replay\",\n  \"menu.replay_buffer_save\": \"Save Replay\",\n  \"menu.letterbox_toggle\": \"Letterbox\",\n\n\n  \"menu.settings_config\": \"Open Config\",\n  \"menu.settings_language\": \"Language\",\n\n  \"message.app_startup\": \"Window ratio adjustment tool is running in background.\\nPress [\",\n  \"message.app_startup_suffix\": \"] to show/hide the adjustment window\",\n  \"message.app_feature_not_supported\": \"This feature requires Windows 10 1803 or higher and has been disabled.\",\n\n  \"message.window_selected\": \"Window Selected\",\n  \"message.window_adjust_success\": \"Window adjusted successfully!\",\n  \"message.window_adjust_failed\": \"Failed to adjust window. May need administrator privileges, or window doesn't support resizing.\",\n  \"message.window_not_found\": \"Target window not found. Please ensure the window is running.\",\n  \"message.window_reset_success\": \"Window has been reset to screen size.\",\n  \"message.window_reset_failed\": \"Failed to reset window size.\",\n\n  \"message.screenshot_success\": \"Screenshot saved to: \",\n  \"message.screenshot_failed\": \"Screenshot failed\",\n  \"message.preview_overlay_conflict\": \"Preview Window and Overlay Window cannot be used simultaneously, and one of the functions has been automatically disabled.\",\n  \"message.preview_start_failed\": \"Failed to start preview window: \",\n  \"message.overlay_start_failed\": \"Failed to start overlay window: \",\n  \"message.recording_started\": \"Recording started.\",\n  \"message.recording_saved\": \"Recording saved to: \",\n  \"message.recording_start_failed\": \"Failed to start recording: \",\n  \"message.recording_stop_failed\": \"Failed to stop recording: \",\n  \"message.motion_photo_success\": \"Motion Photo saved: \",\n  \"message.replay_saved\": \"Replay saved: \",\n  \"message.motion_photo_start_failed\": \"Failed to start Motion Photo: \",\n  \"message.replay_buffer_start_failed\": \"Failed to start Instant Replay: \",\n\n  \"message.settings_hotkey_prompt\": \"Please press new hotkey combination...\\nSupports Ctrl, Shift, Alt with other keys\",\n  \"message.settings_hotkey_success\": \"Hotkey set to: \",\n  \"message.settings_hotkey_failed\": \"Hotkey setting failed, restored to default.\",\n  \"message.settings_hotkey_register_failed\": \"Failed to register hotkey. The program can still be used, but the shortcut will not be available.\",\n  \"message.settings_config_help\": \"Config File Help:\\n1. [AspectRatioItems] section for custom ratios\\n2. [ResolutionItems] section for custom resolutions\\n3. Restart app after saving\",\n  \"message.settings_load_failed\": \"Failed to load config, please check the config file.\",\n  \"message.settings_format_error\": \"Format error: \",\n  \"message.settings_ratio_format_example\": \"Please use correct format, e.g.: 16:10,17:10\",\n  \"message.settings_resolution_format_example\": \"Please use correct format, e.g.: 3840x2160,7680x4320\",\n  \"message.update_available_about_prefix\": \"A new version is available. Install it from the About page: \",\n  \"message.app_updated_to_prefix\": \"Successfully updated to version \",\n\n  \"label.app_name\": \"SpinningMomo\",\n  \"label.language_zh_cn\": \"中文\",\n  \"label.language_en_us\": \"English\"\n}\n)EmbeddedJson\";\n}  // namespace EmbeddedLocales\n"
  },
  {
    "path": "src/core/i18n/embedded/zh_cn.ixx",
    "content": "// Auto-generated embedded Chinese locale module\n// DO NOT EDIT - This file contains embedded locale data\n//\n// Source: src/locales/zh-CN.json\n// Module: Core.I18n.Embedded.ZhCN\n// Variable: zh_cn_json\n\nmodule;\n\nexport module Core.I18n.Embedded.ZhCN;\n\nimport std;\n\nexport namespace EmbeddedLocales {\n// Embedded Chinese JSON content as string_view\n// Size: 3800 bytes\nconstexpr std::string_view zh_cn_json = R\"EmbeddedJson({\n  \"version\": \"1.0\",\n\n  \"menu.app_main\": \"主界面\",\n  \"menu.app_float\": \"悬浮窗\",\n  \"menu.app_exit\": \"退出\",\n  \"menu.app_user_guide\": \"使用指南\",\n  \"menu.float_show\": \"显示悬浮窗\",\n  \"menu.float_hide\": \"隐藏悬浮窗\",\n  \"menu.float_toggle\": \"显示/隐藏悬浮窗\",\n\n  \"menu.window_select\": \"选择窗口\",\n  \"menu.window_no_available\": \"(无可用窗口)\",\n  \"menu.window_ratio\": \"窗口比例\",\n  \"menu.window_resolution\": \"分辨率\",\n  \"menu.window_reset\": \"重置窗口\",\n  \"menu.window_toggle_borderless\": \"切换窗口边框\",\n\n  \"menu.screenshot_capture\": \"截图\",\n  \"menu.output_open_folder\": \"输出目录\",\n  \"menu.external_album_open_folder\": \"游戏相册\",\n  \"menu.overlay_toggle\": \"叠加层\",\n  \"menu.preview_toggle\": \"预览窗\",\n  \"menu.recording_toggle\": \"录制\",\n  \"menu.motion_photo_toggle\": \"动态照片\",\n  \"menu.replay_buffer_toggle\": \"即时回放\",\n  \"menu.replay_buffer_save\": \"保存回放\",\n  \"menu.letterbox_toggle\": \"黑边模式\",\n\n\n  \"menu.settings_config\": \"打开配置文件\",\n  \"menu.settings_language\": \"语言\",\n\n  \"message.app_startup\": \"窗口比例调整工具已在后台运行。\\n按 [\",\n  \"message.app_startup_suffix\": \"] 可以显示/隐藏调整窗口\",\n  \"message.app_feature_not_supported\": \"此功能需要 Windows 10 1803 或更高版本，已自动禁用。\",\n\n  \"message.window_selected\": \"已选择窗口\",\n  \"message.window_adjust_success\": \"窗口调整成功！\",\n  \"message.window_adjust_failed\": \"窗口调整失败。可能需要管理员权限，或窗口不支持调整大小。\",\n  \"message.window_not_found\": \"未找到目标窗口，请确保窗口已启动。\",\n  \"message.window_reset_success\": \"窗口已重置为屏幕大小。\",\n  \"message.window_reset_failed\": \"重置窗口尺寸失败。\",\n\n  \"message.screenshot_success\": \"截图成功，已保存至: \",\n  \"message.screenshot_failed\": \"截图失败\",\n  \"message.preview_overlay_conflict\": \"预览窗和叠加层功能冲突，已自动关闭另一功能\",\n  \"message.preview_start_failed\": \"预览窗启动失败: \",\n  \"message.overlay_start_failed\": \"叠加层启动失败: \",\n  \"message.recording_started\": \"录制已开始。\",\n  \"message.recording_saved\": \"录制已保存，输出文件: \",\n  \"message.recording_start_failed\": \"录制启动失败: \",\n  \"message.recording_stop_failed\": \"录制停止失败: \",\n  \"message.motion_photo_success\": \"动态照片已保存: \",\n  \"message.replay_saved\": \"回放已保存: \",\n  \"message.motion_photo_start_failed\": \"动态照片启动失败: \",\n  \"message.replay_buffer_start_failed\": \"即时回放启动失败: \",\n\n  \"message.settings_hotkey_prompt\": \"请按下新的热键组合...\\n支持 Ctrl、Shift、Alt 组合其他按键\",\n  \"message.settings_hotkey_success\": \"热键已设置为：\",\n  \"message.settings_hotkey_failed\": \"热键设置失败，已恢复默认热键。\",\n  \"message.settings_hotkey_register_failed\": \"热键注册失败。程序仍可使用，但快捷键将不可用。\",\n  \"message.settings_config_help\": \"配置文件说明：\\n1. [AspectRatioItems] 节用于添加自定义比例\\n2. [ResolutionItems] 节用于添加自定义分辨率\\n3. 保存后重启软件生效\",\n  \"message.settings_load_failed\": \"加载配置失败，请检查配置文件。\",\n  \"message.settings_format_error\": \"格式错误：\",\n  \"message.settings_ratio_format_example\": \"请使用正确格式，如：16:10,17:10\",\n  \"message.settings_resolution_format_example\": \"请使用正确格式，如：3840x2160,7680x4320\",\n  \"message.update_available_about_prefix\": \"发现新版本，可前往“关于”页面安装：\",\n  \"message.app_updated_to_prefix\": \"已成功更新到版本 \",\n\n  \"label.app_name\": \"旋转吧大喵\",\n  \"label.language_zh_cn\": \"中文\",\n  \"label.language_en_us\": \"English\"\n}\n)EmbeddedJson\";\n}  // namespace EmbeddedLocales\n"
  },
  {
    "path": "src/core/i18n/i18n.cpp",
    "content": "module;\n\n#include <rfl/json.hpp>\n\nmodule Core.I18n;\n\nimport std;\nimport Core.I18n.Types;\nimport Core.I18n.State;\nimport Utils.Logger;\n\n// 导入生成的嵌入模块\nimport Core.I18n.Embedded.ZhCN;\nimport Core.I18n.Embedded.EnUS;\n\nnamespace Core::I18n {\n\nauto load_embedded_language_data(Types::Language lang)\n    -> std::expected<std::string_view, std::string> {\n  switch (lang) {\n    case Types::Language::ZhCN:\n      if (EmbeddedLocales::zh_cn_json.empty()) {\n        return std::unexpected(\"Chinese language data is empty\");\n      }\n      return EmbeddedLocales::zh_cn_json;\n\n    case Types::Language::EnUS:\n      if (EmbeddedLocales::en_us_json.empty()) {\n        return std::unexpected(\"English language data is empty\");\n      }\n      return EmbeddedLocales::en_us_json;\n\n    default:\n      return std::unexpected(\"Unsupported language\");\n  }\n}\n\nauto initialize(State::I18nState& i18n_state, Types::Language default_lang)\n    -> std::expected<void, std::string> {\n  try {\n    // 加载默认语言到传入的状态\n    auto load_result = load_language(i18n_state, default_lang);\n    if (!load_result) {\n      return std::unexpected(\"Failed to load default language: \" + load_result.error());\n    }\n\n    i18n_state.is_initialized = true;\n\n    return {};\n  } catch (const std::exception& e) {\n    return std::unexpected(\"Exception during I18n initialization: \" + std::string(e.what()));\n  }\n}\n\nauto load_language(State::I18nState& i18n_state, Types::Language lang)\n    -> std::expected<void, std::string> {\n  try {\n    // 获取嵌入的语言数据\n    auto data_result = load_embedded_language_data(lang);\n    if (!data_result) {\n      return std::unexpected(data_result.error());\n    }\n\n    // 解析JSON\n    auto config_result = rfl::json::read<Types::TextData>(data_result.value());\n    if (!config_result) {\n      return std::unexpected(\"Failed to parse text data: \" + config_result.error().what());\n    }\n\n    // 更新传入的状态\n    i18n_state.current_language = lang;\n    i18n_state.texts = std::move(config_result.value());\n\n    return {};\n  } catch (const std::exception& e) {\n    return std::unexpected(\"Exception during language loading: \" + std::string(e.what()));\n  }\n}\n\nauto load_language_by_locale(State::I18nState& i18n_state, std::string_view locale)\n    -> std::expected<void, std::string> {\n  if (locale == \"zh-CN\") {\n    return load_language(i18n_state, Types::Language::ZhCN);\n  }\n  if (locale == \"en-US\") {\n    return load_language(i18n_state, Types::Language::EnUS);\n  }\n  return std::unexpected(\"Unsupported locale: \" + std::string(locale));\n}\n\nauto get_current_language(const State::I18nState& i18n_state) -> Types::Language {\n  return i18n_state.current_language;\n}\n\nauto is_initialized(const State::I18nState& i18n_state) -> bool {\n  return i18n_state.is_initialized;\n}\n\n}  // namespace Core::I18n\n"
  },
  {
    "path": "src/core/i18n/i18n.ixx",
    "content": "module;\n\nexport module Core.I18n;\n\nimport std;\nimport Core.I18n.Types;\nimport Core.I18n.State;\n\n// 导入生成的嵌入模块\nimport Core.I18n.Embedded.ZhCN;\nimport Core.I18n.Embedded.EnUS;\n\nnamespace Core::I18n {\n\nexport auto initialize(State::I18nState& i18n_state,\n                       Types::Language default_lang = Types::Language::EnUS)\n    -> std::expected<void, std::string>;\n\nexport auto load_language(State::I18nState& i18n_state, Types::Language lang)\n    -> std::expected<void, std::string>;\n\n// 使用 locale 字符串加载语言（例如 \"zh-CN\" / \"en-US\"）\nexport auto load_language_by_locale(State::I18nState& i18n_state, std::string_view locale)\n    -> std::expected<void, std::string>;\n\nexport auto get_current_language(const State::I18nState& i18n_state) -> Types::Language;\n\nexport auto is_initialized(const State::I18nState& i18n_state) -> bool;\n\n}  // namespace Core::I18n\n"
  },
  {
    "path": "src/core/i18n/state.ixx",
    "content": "module;\r\n\r\nexport module Core.I18n.State;\r\n\r\nimport std;\r\nimport Core.I18n.Types;\r\n\r\nexport namespace Core::I18n::State {\r\n\r\nstruct I18nState {\r\n  Types::Language current_language = Types::Language::EnUS;\r\n  Types::TextData texts;\r\n  bool is_initialized = false;\r\n\r\n  I18nState() = default;\r\n};\r\n\r\n}  // namespace Core::I18n::State"
  },
  {
    "path": "src/core/i18n/types.ixx",
    "content": "module;\n\nexport module Core.I18n.Types;\n\nimport std;\n\nexport namespace Core::I18n::Types {\n\nenum class Language { ZhCN, EnUS };\n\n// 扁平化文本数据 - 使用 key-value 映射\n// key 格式: \"category.item\" (例如: \"menu.app_main\", \"message.window_not_found\")\nusing TextData = std::unordered_map<std::string, std::string>;\n\n// 辅助函数：安全获取文本，如果不存在返回key本身\ninline auto get_text(const TextData& texts, const std::string& key) -> std::string {\n  auto it = texts.find(key);\n  return (it != texts.end()) ? it->second : key;\n}\n\n// 辅助函数：创建默认文本数据（空map）\ninline auto create_default_text_data() -> TextData { return TextData{}; }\n\n}  // namespace Core::I18n::Types"
  },
  {
    "path": "src/core/initializer/database.cpp",
    "content": "module;\n\nmodule Core.Initializer.Database;\n\nimport std;\nimport Core.State;\nimport Core.Database;\nimport Core.Database.State;\nimport Utils.Logger;\nimport Utils.Path;\n\nnamespace Core::Initializer::Database {\n\nauto initialize_database(Core::State::AppState& state) -> std::expected<void, std::string> {\n  try {\n    // 初始化数据库连接\n    auto path_result = Utils::Path::GetAppDataFilePath(\"database.db\");\n    if (!path_result) {\n      return std::unexpected(\"Failed to get database path: \" + path_result.error());\n    }\n    const auto db_path = path_result.value();\n    if (auto result = Core::Database::initialize(*state.database, db_path); !result) {\n      Logger().error(\"Failed to initialize database: {}\", result.error());\n      return std::unexpected(\"Failed to initialize database: \" + result.error());\n    }\n\n    Logger().info(\"Database initialized successfully at {}\", db_path.string());\n    return {};\n  } catch (const std::exception& e) {\n    return std::unexpected(\"Exception during database initialization: \" + std::string(e.what()));\n  }\n}\n\n}  // namespace Core::Initializer::Database\n"
  },
  {
    "path": "src/core/initializer/database.ixx",
    "content": "module;\n\nexport module Core.Initializer.Database;\n\nimport std;\nimport Core.State;\nimport Core.Database.State;\n\nnamespace Core::Initializer::Database {\n\n// 初始化数据库\nexport auto initialize_database(Core::State::AppState& state) -> std::expected<void, std::string>;\n\n}  // namespace Core::Initializer::Database"
  },
  {
    "path": "src/core/initializer/initializer.cpp",
    "content": "module;\n\nmodule Core.Initializer;\n\nimport std;\nimport Core.Async;\nimport Core.Commands;\nimport Core.Commands.State;\nimport Core.DialogService;\nimport Core.WorkerPool;\nimport Core.State;\nimport Core.State.RuntimeInfo;\nimport Core.I18n;\nimport Core.I18n.State;\nimport Core.I18n.Types;\nimport Core.HttpServer;\nimport Core.HttpClient;\nimport Core.Events;\nimport Core.Events.State;\nimport Core.Events.Registrar;\nimport Core.RPC.Registry;\nimport Core.Initializer.Database;\nimport Core.Migration;\nimport Features.Gallery;\nimport Features.Gallery.Watcher;\nimport Features.Settings;\nimport Features.Settings.State;\nimport Features.Recording;\nimport Features.ReplayBuffer;\nimport Features.ReplayBuffer.State;\nimport Features.ReplayBuffer.UseCase;\nimport Features.Update;\nimport Features.Letterbox.State;\nimport Features.WindowControl;\nimport Extensions.InfinityNikki.MapService;\nimport Extensions.InfinityNikki.PhotoService;\nimport UI.FloatingWindow;\nimport UI.FloatingWindow.Events;\nimport UI.FloatingWindow.State;\nimport UI.WebViewWindow;\nimport UI.TrayIcon;\nimport UI.ContextMenu;\nimport Utils.Logger;\nimport Vendor.Windows;\n\nnamespace Core::Initializer {\n\nauto post_startup_notification(Core::State::AppState& state, const std::string& message) -> void {\n  if (!state.events || !state.i18n) {\n    Logger().warn(\"Skip startup notification: state is not ready\");\n    return;\n  }\n\n  auto app_name_it = state.i18n->texts.find(\"label.app_name\");\n  if (app_name_it == state.i18n->texts.end()) {\n    Logger().warn(\"Skip startup notification: app name text is missing\");\n    return;\n  }\n\n  Core::Events::post(*state.events, UI::FloatingWindow::Events::NotificationEvent{\n                                        .title = app_name_it->second,\n                                        .message = message,\n                                    });\n}\n\nauto apply_language_from_settings(Core::State::AppState& state) -> void {\n  if (!state.settings || !state.i18n) {\n    Logger().warn(\"Skip language sync from settings: state is not ready\");\n    return;\n  }\n\n  const auto& locale = state.settings->raw.app.language.current;\n  if (auto result = Core::I18n::load_language_by_locale(*state.i18n, locale); !result) {\n    Logger().warn(\"Failed to load runtime language from settings ('{}'): {}\", locale,\n                  result.error());\n    return;\n  }\n\n  Logger().info(\"Runtime language loaded from settings: {}\", locale);\n}\n\nauto apply_logger_level_from_settings(Core::State::AppState& state) -> void {\n  if (!state.settings) {\n    Logger().warn(\"Skip logger level sync from settings: state is not ready\");\n    return;\n  }\n\n  const auto& level = state.settings->raw.app.logger.level;\n  if (auto result = Utils::Logging::set_level(level); !result) {\n    Logger().warn(\"Failed to apply logger level from settings ('{}'): {}\", level, result.error());\n    return;\n  }\n\n  Logger().debug(\"Runtime logger level loaded from settings: {}\", level);\n}\n\nauto initialize_application(Core::State::AppState& state, Vendor::Windows::HINSTANCE instance)\n    -> std::expected<void, std::string> {\n  try {\n    Logger().info(\"==================================================\");\n    Logger().info(\"SpinningMomo startup begin\");\n    Logger().info(\"==================================================\");\n\n    Core::Events::register_all_handlers(state);\n\n    if (auto result = Core::I18n::initialize(*state.i18n, Core::I18n::Types::Language::EnUS);\n        !result) {\n      return std::unexpected(\"Failed to initialize i18n: \" + result.error());\n    }\n\n    auto last_version_result = Core::Migration::get_last_version();\n    if (!last_version_result) {\n      return std::unexpected(\"Failed to get last version: \" + last_version_result.error());\n    }\n\n    const auto last_version = last_version_result.value();\n    const auto current_version = state.runtime_info ? state.runtime_info->version : std::string{};\n    const bool should_notify_upgrade =\n        !current_version.empty() && last_version != \"0.0.0.0\" &&\n        Core::Migration::compare_versions(last_version, current_version) < 0;\n\n    if (auto result = Core::Async::start(*state.async); !result) {\n      return std::unexpected(\"Failed to start async runtime: \" + result.error());\n    }\n\n    if (auto result = Core::HttpClient::initialize(state); !result) {\n      return std::unexpected(\"Failed to initialize HTTP client: \" + result.error());\n    }\n\n    if (auto result = Core::WorkerPool::start(*state.worker_pool); !result) {\n      return std::unexpected(\"Failed to start worker pool: \" + result.error());\n    }\n\n    if (auto result = Core::DialogService::start(*state.dialog_service); !result) {\n      return std::unexpected(\"Failed to start dialog service: \" + result.error());\n    }\n\n    Core::RPC::Registry::register_all_endpoints(state);\n\n    if (auto result = Core::HttpServer::initialize(state); !result) {\n      return std::unexpected(\"Failed to initialize HTTP server: \" + result.error());\n    }\n\n    if (auto db_result = Core::Initializer::Database::initialize_database(state); !db_result) {\n      return std::unexpected(\"Failed to initialize database: \" + db_result.error());\n    }\n\n    if (!Core::Migration::run_migration_if_needed(state)) {\n      return std::unexpected(\"Application migration failed. Please check logs for details.\");\n    }\n\n    if (auto settings_result = Features::Settings::initialize(state); !settings_result) {\n      return std::unexpected(\"Failed to initialize settings: \" + settings_result.error());\n    }\n\n    // 将后端 i18n 语言与 settings 对齐，确保原生浮窗/通知文案一致\n    apply_language_from_settings(state);\n    apply_logger_level_from_settings(state);\n\n    // 从 settings 同步 letterbox 启用状态\n    state.letterbox->enabled = state.settings->raw.features.letterbox.enabled;\n\n    if (auto result = Features::WindowControl::start_center_lock_monitor(state); !result) {\n      return std::unexpected(\"Failed to start window control monitor: \" + result.error());\n    }\n\n    if (auto update_result = Features::Update::initialize(state); !update_result) {\n      return std::unexpected(\"Failed to initialize update: \" + update_result.error());\n    }\n\n    // 初始化命令注册表\n    Core::Commands::register_builtin_commands(state, state.commands->registry);\n    Logger().info(\"Command registry initialized with {} commands\",\n                  state.commands->registry.descriptors.size());\n\n    if (auto result = UI::FloatingWindow::create_window(state); !result) {\n      return std::unexpected(\"Failed to create app window: \" + result.error());\n    }\n\n    // Set up notify_hwnd for event system wake-up\n    state.events->notify_hwnd = state.floating_window->window.hwnd;\n\n    if (auto result = UI::TrayIcon::create(state); !result) {\n      return std::unexpected(\"Failed to create tray icon: \" + result.error());\n    }\n\n    if (auto result = UI::ContextMenu::initialize(state); !result) {\n      return std::unexpected(\"Failed to initialize tray menu: \" + result.error());\n    }\n\n    if (auto result = Features::Recording::initialize(*state.recording); !result) {\n      return std::unexpected(result.error());\n    }\n\n    if (auto result = Features::ReplayBuffer::initialize(*state.replay_buffer); !result) {\n      return std::unexpected(\"Failed to initialize replay buffer: \" + result.error());\n    }\n\n    if (auto gallery_result = Features::Gallery::initialize(state); !gallery_result) {\n      return std::unexpected(\"Failed to initialize gallery: \" + gallery_result.error());\n    }\n\n    if (auto watcher_restore_result = Features::Gallery::Watcher::restore_watchers_from_db(state);\n        !watcher_restore_result) {\n      Logger().warn(\"Gallery watcher registration restore failed: {}\",\n                    watcher_restore_result.error());\n    }\n\n    // Gallery 初始化完成后，先注册无限暖暖目录监听，统一在末尾启动\n    Extensions::InfinityNikki::MapService::register_from_settings(state);\n    Extensions::InfinityNikki::PhotoService::register_from_settings(state);\n\n    const bool should_open_onboarding =\n        Features::Settings::should_show_onboarding(state.settings->raw);\n    if (should_open_onboarding) {\n      Logger().info(\"Onboarding required, attempting to open main UI window\");\n      UI::WebViewWindow::activate_window(state);\n    } else {\n      // 默认显示悬浮窗\n      UI::FloatingWindow::show_window(state);\n    }\n\n    if (should_notify_upgrade) {\n      auto text_it = state.i18n->texts.find(\"message.app_updated_to_prefix\");\n      if (text_it != state.i18n->texts.end()) {\n        post_startup_notification(state, text_it->second + current_version);\n      } else {\n        Logger().warn(\"Skip upgrade notification: i18n text is missing\");\n      }\n    }\n\n    Core::Commands::install_keyboard_keepalive_hook(state);\n\n    // 注册所有命令的热键\n    Core::Commands::register_all_hotkeys(state, state.floating_window->window.hwnd);\n\n    Logger().info(\"==================================================\");\n    Logger().info(\"SpinningMomo startup ready\");\n    Logger().info(\"==================================================\");\n\n    // 启动后的后台恢复任务不应阻塞 UI 首屏显示。\n    Features::Gallery::Watcher::schedule_start_registered_watchers(state);\n\n    // 按设置自动检查更新（异步，不阻塞启动）\n    Features::Update::schedule_startup_auto_update_check(state);\n\n    return {};\n\n  } catch (const std::exception& e) {\n    return std::unexpected(\"Exception during initialization: \" + std::string(e.what()));\n  }\n}\n\n}  // namespace Core::Initializer\n"
  },
  {
    "path": "src/core/initializer/initializer.ixx",
    "content": "module;\r\n\r\nexport module Core.Initializer;\r\n\r\nimport std;\r\nimport Core.State;\r\nimport Vendor.Windows;\r\n\r\nnamespace Core::Initializer {\r\n\r\nexport auto initialize_application(Core::State::AppState& state,\r\n                                   Vendor::Windows::HINSTANCE instance)\r\n    -> std::expected<void, std::string>;\r\n\r\n}"
  },
  {
    "path": "src/core/migration/generated/schema.ixx",
    "content": "// Auto-generated schema index\n// DO NOT EDIT - This file imports all generated schema modules\n\nmodule;\n\nexport module Core.Migration.Schema;\n\n// Import all schema modules\nexport import Core.Migration.Schema.V001;\nexport import Core.Migration.Schema.V002;\nexport import Core.Migration.Schema.V003;\n"
  },
  {
    "path": "src/core/migration/generated/schema_001.ixx",
    "content": "// Auto-generated SQL schema module\n// DO NOT EDIT - This file is generated from src/migrations/001_initial_schema.sql\n\nmodule;\n\nexport module Core.Migration.Schema.V001;\n\nimport std;\n\nexport namespace Core::Migration::Schema {\n\nstruct V001 {\n  static constexpr std::array<std::string_view, 39> statements = {\n      R\"SQL(\nCREATE TABLE assets (\n    id INTEGER PRIMARY KEY AUTOINCREMENT,\n    name TEXT NOT NULL,\n    path TEXT NOT NULL UNIQUE,\n    type TEXT NOT NULL CHECK (\n        type IN ('photo', 'video', 'live_photo', 'unknown')\n    ),\n    description TEXT,\n    width INTEGER,\n    height INTEGER,\n    size INTEGER,\n    extension TEXT,\n    mime_type TEXT,\n    hash TEXT,\n    rating INTEGER NOT NULL DEFAULT 0 CHECK (\n        rating BETWEEN 0\n        AND 5\n    ),\n    review_flag TEXT NOT NULL DEFAULT 'none' CHECK (\n        review_flag IN ('none', 'picked', 'rejected')\n    ),\n    folder_id INTEGER REFERENCES folders(id) ON DELETE\n    SET\n        NULL,\n        file_created_at INTEGER,\n        file_modified_at INTEGER,\n        created_at INTEGER DEFAULT (unixepoch('subsec') * 1000),\n        updated_at INTEGER DEFAULT (unixepoch('subsec') * 1000)\n)\n        )SQL\",\n      R\"SQL(\nCREATE INDEX idx_assets_path ON assets(path)\n        )SQL\",\n      R\"SQL(\nCREATE INDEX idx_assets_type ON assets(type)\n        )SQL\",\n      R\"SQL(\nCREATE INDEX idx_assets_extension ON assets(extension)\n        )SQL\",\n      R\"SQL(\nCREATE INDEX idx_assets_created_at ON assets(created_at)\n        )SQL\",\n      R\"SQL(\nCREATE INDEX idx_assets_hash ON assets(hash)\n        )SQL\",\n      R\"SQL(\nCREATE INDEX idx_assets_rating ON assets(rating)\n        )SQL\",\n      R\"SQL(\nCREATE INDEX idx_assets_review_flag ON assets(review_flag)\n        )SQL\",\n      R\"SQL(\nCREATE INDEX idx_assets_folder_id ON assets(folder_id)\n        )SQL\",\n      R\"SQL(\nCREATE INDEX idx_assets_file_created_at ON assets(file_created_at)\n        )SQL\",\n      R\"SQL(\nCREATE INDEX idx_assets_file_modified_at ON assets(file_modified_at)\n        )SQL\",\n      R\"SQL(\nCREATE INDEX idx_assets_folder_time ON assets(folder_id, file_created_at)\n        )SQL\",\n      R\"SQL(\nCREATE TRIGGER update_assets_updated_at\nAFTER\nUPDATE\n    ON assets FOR EACH ROW BEGIN\nUPDATE\n    assets\nSET\n    updated_at = (unixepoch('subsec') * 1000)\nWHERE\n    id = NEW.id;\nEND\n        )SQL\",\n      R\"SQL(\nCREATE TABLE folders (\n    id INTEGER PRIMARY KEY AUTOINCREMENT,\n    path TEXT NOT NULL UNIQUE,\n    parent_id INTEGER REFERENCES folders(id) ON DELETE CASCADE,\n    name TEXT NOT NULL,\n    display_name TEXT,\n    cover_asset_id INTEGER,\n    sort_order INTEGER DEFAULT 0,\n    is_hidden BOOLEAN DEFAULT 0,\n    created_at INTEGER DEFAULT (unixepoch('subsec') * 1000),\n    updated_at INTEGER DEFAULT (unixepoch('subsec') * 1000),\n    FOREIGN KEY (cover_asset_id) REFERENCES assets(id) ON DELETE\n    SET\n        NULL\n)\n        )SQL\",\n      R\"SQL(\nCREATE INDEX idx_folders_parent_sort ON folders(parent_id, sort_order)\n        )SQL\",\n      R\"SQL(\nCREATE INDEX idx_folders_path ON folders(path)\n        )SQL\",\n      R\"SQL(\nCREATE TRIGGER update_folders_updated_at\nAFTER\nUPDATE\n    ON folders FOR EACH ROW BEGIN\nUPDATE\n    folders\nSET\n    updated_at = (unixepoch('subsec') * 1000)\nWHERE\n    id = NEW.id;\nEND\n        )SQL\",\n      R\"SQL(\nCREATE TABLE tags (\n    id INTEGER PRIMARY KEY AUTOINCREMENT,\n    name TEXT NOT NULL,\n    parent_id INTEGER,\n    sort_order INTEGER DEFAULT 0,\n    created_at INTEGER DEFAULT (unixepoch('subsec') * 1000),\n    updated_at INTEGER DEFAULT (unixepoch('subsec') * 1000),\n    FOREIGN KEY (parent_id) REFERENCES tags(id) ON DELETE CASCADE\n)\n        )SQL\",\n      R\"SQL(\nCREATE TABLE asset_tags (\n    asset_id INTEGER NOT NULL,\n    tag_id INTEGER NOT NULL,\n    created_at INTEGER DEFAULT (unixepoch('subsec') * 1000),\n    PRIMARY KEY (asset_id, tag_id),\n    FOREIGN KEY (asset_id) REFERENCES assets(id) ON DELETE CASCADE,\n    FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE\n)\n        )SQL\",\n      R\"SQL(\nCREATE INDEX idx_tags_parent_sort ON tags(parent_id, sort_order)\n        )SQL\",\n      R\"SQL(\nCREATE TRIGGER update_tags_updated_at\nAFTER\nUPDATE\n    ON tags FOR EACH ROW BEGIN\nUPDATE\n    tags\nSET\n    updated_at = (unixepoch('subsec') * 1000)\nWHERE\n    id = NEW.id;\nEND\n        )SQL\",\n      R\"SQL(\nCREATE INDEX idx_asset_tags_tag ON asset_tags(tag_id)\n        )SQL\",\n      R\"SQL(\nCREATE TABLE asset_colors (\n    id INTEGER PRIMARY KEY AUTOINCREMENT,\n    asset_id INTEGER NOT NULL REFERENCES assets(id) ON DELETE CASCADE,\n    r INTEGER NOT NULL CHECK (\n        r BETWEEN 0\n        AND 255\n    ),\n    g INTEGER NOT NULL CHECK (\n        g BETWEEN 0\n        AND 255\n    ),\n    b INTEGER NOT NULL CHECK (\n        b BETWEEN 0\n        AND 255\n    ),\n    lab_l REAL NOT NULL,\n    lab_a REAL NOT NULL,\n    lab_b REAL NOT NULL,\n    weight REAL NOT NULL CHECK (\n        weight > 0\n        AND weight <= 1\n    ),\n    l_bin INTEGER NOT NULL,\n    a_bin INTEGER NOT NULL,\n    b_bin INTEGER NOT NULL\n)\n        )SQL\",\n      R\"SQL(\nCREATE INDEX idx_asset_colors_asset_id ON asset_colors(asset_id)\n        )SQL\",\n      R\"SQL(\nCREATE INDEX idx_asset_colors_asset_weight ON asset_colors(asset_id, weight DESC)\n        )SQL\",\n      R\"SQL(\nCREATE INDEX idx_asset_colors_lab_bin ON asset_colors(l_bin, a_bin, b_bin)\n        )SQL\",\n      R\"SQL(\nCREATE TABLE asset_infinity_nikki_params (\n    asset_id INTEGER PRIMARY KEY REFERENCES assets(id) ON DELETE CASCADE,\n    uid TEXT NOT NULL,\n    camera_params TEXT,\n    time_day INTEGER,\n    time_hour INTEGER,\n    time_min INTEGER,\n    time_sec REAL,\n    camera_focal_length REAL,\n    aperture_section INTEGER,\n    filter_id TEXT,\n    filter_strength REAL,\n    vignette_intensity REAL,\n    light_id TEXT,\n    light_strength REAL,\n    nikki_loc_x REAL,\n    nikki_loc_y REAL,\n    nikki_loc_z REAL,\n    nikki_hidden INTEGER,\n    pose_id INTEGER,\n    nikki_diy_json TEXT\n)\n        )SQL\",\n      R\"SQL(\nCREATE INDEX idx_infinity_nikki_params_uid ON asset_infinity_nikki_params(uid)\n        )SQL\",\n      R\"SQL(\nCREATE INDEX idx_infinity_nikki_params_pose_id ON asset_infinity_nikki_params(pose_id)\n        )SQL\",\n      R\"SQL(\nCREATE TABLE asset_infinity_nikki_user_record (\n    asset_id INTEGER PRIMARY KEY REFERENCES assets(id) ON DELETE CASCADE,\n    code_type TEXT NOT NULL CHECK (\n        code_type IN ('dye', 'home_building')\n    ),\n    code_value TEXT NOT NULL,\n    created_at INTEGER DEFAULT (unixepoch('subsec') * 1000),\n    updated_at INTEGER DEFAULT (unixepoch('subsec') * 1000)\n)\n        )SQL\",\n      R\"SQL(\nCREATE TRIGGER update_asset_infinity_nikki_user_record_updated_at\nAFTER\nUPDATE\n    ON asset_infinity_nikki_user_record FOR EACH ROW BEGIN\nUPDATE\n    asset_infinity_nikki_user_record\nSET\n    updated_at = (unixepoch('subsec') * 1000)\nWHERE\n    asset_id = NEW.asset_id;\nEND\n        )SQL\",\n      R\"SQL(\nCREATE TABLE asset_infinity_nikki_clothes (\n    asset_id INTEGER NOT NULL REFERENCES assets(id) ON DELETE CASCADE,\n    cloth_id INTEGER NOT NULL,\n    PRIMARY KEY (asset_id, cloth_id)\n)\n        )SQL\",\n      R\"SQL(\nCREATE INDEX idx_infinity_nikki_clothes_cloth_id ON asset_infinity_nikki_clothes(cloth_id)\n        )SQL\",\n      R\"SQL(\nCREATE TABLE ignore_rules (\n    id INTEGER PRIMARY KEY AUTOINCREMENT,\n    folder_id INTEGER REFERENCES folders(id) ON DELETE CASCADE,\n    rule_pattern TEXT NOT NULL,\n    pattern_type TEXT NOT NULL CHECK (pattern_type IN ('glob', 'regex')) DEFAULT 'glob',\n    rule_type TEXT NOT NULL CHECK (rule_type IN ('exclude', 'include')) DEFAULT 'exclude',\n    is_enabled BOOLEAN NOT NULL DEFAULT 1,\n    description TEXT,\n    created_at INTEGER DEFAULT (unixepoch('subsec') * 1000),\n    updated_at INTEGER DEFAULT (unixepoch('subsec') * 1000),\n    UNIQUE(folder_id, rule_pattern)\n)\n        )SQL\",\n      R\"SQL(\nCREATE INDEX idx_ignore_rules_folder_id ON ignore_rules(folder_id)\n        )SQL\",\n      R\"SQL(\nCREATE INDEX idx_ignore_rules_enabled ON ignore_rules(is_enabled)\n        )SQL\",\n      R\"SQL(\nCREATE INDEX idx_ignore_rules_pattern_type ON ignore_rules(pattern_type)\n        )SQL\",\n      R\"SQL(\nCREATE UNIQUE INDEX idx_ignore_rules_global_pattern_unique ON ignore_rules(rule_pattern)\nWHERE folder_id IS NULL\n        )SQL\",\n      R\"SQL(\nCREATE TRIGGER update_ignore_rules_updated_at\nAFTER\nUPDATE\n    ON ignore_rules FOR EACH ROW BEGIN\nUPDATE\n    ignore_rules\nSET\n    updated_at = (unixepoch('subsec') * 1000)\nWHERE\n    id = NEW.id;\nEND\n        )SQL\"};\n};\n\n}  // namespace Core::Migration::Schema\n"
  },
  {
    "path": "src/core/migration/generated/schema_002.ixx",
    "content": "// Auto-generated SQL schema module\n// DO NOT EDIT - This file is generated from src/migrations/002_watch_root_recovery_state.sql\n\nmodule;\n\nexport module Core.Migration.Schema.V002;\n\nimport std;\n\nexport namespace Core::Migration::Schema {\n\nstruct V002 {\n  static constexpr std::array<std::string_view, 1> statements = {\n      R\"SQL(\nCREATE TABLE watch_root_recovery_state (\n    root_path TEXT PRIMARY KEY,\n    volume_identity TEXT NOT NULL,\n    journal_id INTEGER,\n    checkpoint_usn INTEGER,\n    rule_fingerprint TEXT NOT NULL,\n    updated_at INTEGER DEFAULT (unixepoch('subsec') * 1000)\n)\n        )SQL\"};\n};\n\n}  // namespace Core::Migration::Schema\n"
  },
  {
    "path": "src/core/migration/generated/schema_003.ixx",
    "content": "// Auto-generated SQL schema module\n// DO NOT EDIT - This file is generated from\n// src/migrations/003_infinity_nikki_params_nuan5_columns.sql\n\nmodule;\n\nexport module Core.Migration.Schema.V003;\n\nimport std;\n\nexport namespace Core::Migration::Schema {\n\nstruct V003 {\n  static constexpr std::array<std::string_view, 8> statements = {\n      R\"SQL(\nDROP TABLE IF EXISTS asset_infinity_nikki_params_v2\n        )SQL\",\n      R\"SQL(\nDROP INDEX IF EXISTS idx_infinity_nikki_params_uid\n        )SQL\",\n      R\"SQL(\nDROP INDEX IF EXISTS idx_infinity_nikki_params_pose_id\n        )SQL\",\n      R\"SQL(\nCREATE TABLE asset_infinity_nikki_params_v2 (\n    asset_id INTEGER PRIMARY KEY REFERENCES assets(id) ON DELETE CASCADE,\n    uid TEXT NOT NULL,\n    camera_params TEXT,\n    time_hour INTEGER,\n    time_min INTEGER,\n    camera_focal_length REAL,\n    rotation REAL,\n    aperture_value REAL,\n    filter_id INTEGER,\n    filter_strength REAL,\n    vignette_intensity REAL,\n    light_id INTEGER,\n    light_strength REAL,\n    vertical INTEGER,\n    bloom_intensity REAL,\n    bloom_threshold REAL,\n    brightness REAL,\n    exposure REAL,\n    contrast REAL,\n    saturation REAL,\n    vibrance REAL,\n    highlights REAL,\n    shadow REAL,\n    nikki_loc_x REAL,\n    nikki_loc_y REAL,\n    nikki_loc_z REAL,\n    nikki_hidden INTEGER,\n    pose_id INTEGER,\n    nikki_diy_json TEXT\n)\n        )SQL\",\n      R\"SQL(\nINSERT INTO asset_infinity_nikki_params_v2 (\n    asset_id, uid, camera_params,\n    time_hour, time_min,\n    camera_focal_length, rotation, aperture_value,\n    filter_id, filter_strength, vignette_intensity,\n    light_id, light_strength,\n    nikki_loc_x, nikki_loc_y, nikki_loc_z,\n    nikki_hidden, pose_id,\n    nikki_diy_json\n)\nSELECT\n    asset_id,\n    uid,\n    camera_params,\n    time_hour,\n    time_min,\n    camera_focal_length,\n    NULL AS rotation,\n    NULL AS aperture_value,\n    CASE\n        WHEN filter_id IS NULL THEN NULL\n        WHEN trim(filter_id) <> '' AND (trim(filter_id) GLOB '[0-9]*' OR trim(filter_id) GLOB '-[0-9]*')\n            THEN CAST(trim(filter_id) AS INTEGER)\n        ELSE NULL\n    END AS filter_id,\n    filter_strength,\n    vignette_intensity,\n    CASE\n        WHEN light_id IS NULL THEN NULL\n        WHEN trim(light_id) <> '' AND (trim(light_id) GLOB '[0-9]*' OR trim(light_id) GLOB '-[0-9]*')\n            THEN CAST(trim(light_id) AS INTEGER)\n        ELSE NULL\n    END AS light_id,\n    light_strength,\n    nikki_loc_x,\n    nikki_loc_y,\n    nikki_loc_z,\n    nikki_hidden,\n    pose_id,\n    NULL AS nikki_diy_json\nFROM asset_infinity_nikki_params\n        )SQL\",\n      R\"SQL(\nDROP TABLE asset_infinity_nikki_params\n        )SQL\",\n      R\"SQL(\nALTER TABLE asset_infinity_nikki_params_v2\nRENAME TO asset_infinity_nikki_params\n        )SQL\",\n      R\"SQL(\nCREATE INDEX IF NOT EXISTS idx_infinity_nikki_params_uid ON asset_infinity_nikki_params(uid)\n        )SQL\"};\n};\n\n}  // namespace Core::Migration::Schema\n"
  },
  {
    "path": "src/core/migration/migration.cpp",
    "content": "module;\n\nmodule Core.Migration;\n\nimport std;\nimport Core.State;\nimport Core.Migration.Scripts;\nimport Utils.Logger;\nimport Utils.Path;\nimport Vendor.Version;\n\nnamespace Core::Migration {\n\nauto get_version_file_path() -> std::expected<std::filesystem::path, std::string> {\n  return Utils::Path::GetAppDataFilePath(\"app_version.txt\");\n}\n\nauto get_last_version() -> std::expected<std::string, std::string> {\n  auto path_result = get_version_file_path();\n  if (!path_result) {\n    return std::unexpected(\"Failed to get version file path: \" + path_result.error());\n  }\n\n  auto path = path_result.value();\n\n  // 首次启动，版本文件不存在\n  if (!std::filesystem::exists(path)) {\n    Logger().info(\"Version file not found, assuming first launch\");\n    return \"0.0.0.0\";\n  }\n\n  try {\n    std::ifstream file(path);\n    if (!file) {\n      return std::unexpected(\"Failed to open version file: \" + path.string());\n    }\n\n    std::string version;\n    std::getline(file, version);\n\n    if (version.empty()) {\n      return std::unexpected(\"Version file is empty\");\n    }\n\n    Logger().info(\"Last version: {}\", version);\n    return version;\n\n  } catch (const std::exception& e) {\n    return std::unexpected(\"Error reading version file: \" + std::string(e.what()));\n  }\n}\n\nauto save_current_version(const std::string& version) -> std::expected<void, std::string> {\n  auto path_result = get_version_file_path();\n  if (!path_result) {\n    return std::unexpected(\"Failed to get version file path: \" + path_result.error());\n  }\n\n  auto path = path_result.value();\n\n  try {\n    std::ofstream file(path);\n    if (!file) {\n      return std::unexpected(\"Failed to open version file for writing: \" + path.string());\n    }\n\n    file << version;\n\n    if (!file) {\n      return std::unexpected(\"Failed to write version to file\");\n    }\n\n    Logger().info(\"Version saved: {}\", version);\n    return {};\n\n  } catch (const std::exception& e) {\n    return std::unexpected(\"Error saving version file: \" + std::string(e.what()));\n  }\n}\n\n// 简单的版本号比较（支持 x.y.z.w 格式，4段版本号）\nauto compare_versions(const std::string& v1, const std::string& v2) -> int {\n  auto parse_version = [](const std::string& v) -> std::vector<int> {\n    std::vector<int> parts;\n    std::istringstream ss(v);\n    std::string part;\n\n    while (std::getline(ss, part, '.')) {\n      try {\n        parts.push_back(std::stoi(part));\n      } catch (const std::invalid_argument&) {\n        parts.push_back(0);\n      } catch (const std::out_of_range&) {\n        parts.push_back(0);\n      }\n    }\n\n    // 补齐到4位（与应用版本字符串格式一致）\n    while (parts.size() < 4) {\n      parts.push_back(0);\n    }\n\n    return parts;\n  };\n\n  auto parts1 = parse_version(v1);\n  auto parts2 = parse_version(v2);\n\n  for (size_t i = 0; i < 4; ++i) {\n    if (parts1[i] < parts2[i]) return -1;\n    if (parts1[i] > parts2[i]) return 1;\n  }\n\n  return 0;\n}\n\nauto run_migration_if_needed(Core::State::AppState& app_state) -> bool {\n  Logger().info(\"=== Migration Check Started ===\");\n\n  auto current_version = Vendor::Version::get_app_version();\n  auto last_version_result = get_last_version();\n  if (!last_version_result) {\n    Logger().error(\"Failed to get last version: {}\", last_version_result.error());\n    return false;\n  }\n\n  std::string last_version = last_version_result.value();\n  const bool is_fresh_install = compare_versions(last_version, \"0.0.0.0\") == 0;\n\n  Logger().info(\"Version comparison: {} -> {}\", last_version, current_version);\n\n  // 如果版本相同，无需迁移\n  if (compare_versions(last_version, current_version) == 0) {\n    Logger().info(\"Already at version {}, no migration needed\", current_version);\n    return true;\n  }\n\n  // 收集需要执行的迁移脚本\n  const auto& all_migrations = Scripts::get_all_migrations();\n  std::vector<const Scripts::MigrationScript*> scripts_to_run;\n\n  for (const auto& script : all_migrations) {\n    if (is_fresh_install && !script.run_on_fresh_install) {\n      continue;\n    }\n\n    // 选择版本号在 (last_version, current_version] 区间的脚本\n    if (compare_versions(script.target_version, last_version) > 0 &&\n        compare_versions(script.target_version, current_version) <= 0) {\n      scripts_to_run.push_back(&script);\n    }\n  }\n\n  // 按版本号排序（确保按顺序执行）\n  std::ranges::sort(scripts_to_run, [](const auto* a, const auto* b) {\n    return compare_versions(a->target_version, b->target_version) < 0;\n  });\n\n  if (scripts_to_run.empty()) {\n    Logger().warn(\"No migration scripts found between {} and {}\", last_version, current_version);\n    Logger().warn(\"This might indicate a version downgrade or missing migration scripts\");\n\n    // 仍然保存当前版本号\n    auto save_result = save_current_version(current_version);\n    if (!save_result) {\n      Logger().error(\"Failed to save version number: {}\", save_result.error());\n      return false;\n    }\n\n    return true;\n  }\n\n  // 执行迁移脚本\n  Logger().info(\"Found {} migration script(s) to execute\", scripts_to_run.size());\n\n  for (const auto* script : scripts_to_run) {\n    Logger().info(\"--- Executing migration to {} ---\", script->target_version);\n    Logger().info(\"Description: {}\", script->description);\n\n    auto result = script->migration_fn(app_state);\n\n    if (!result) {\n      Logger().error(\"Migration to {} failed: {}\", script->target_version, result.error());\n      Logger().error(\"=== Migration Failed ===\");\n      return false;\n    }\n\n    Logger().info(\"Migration to {} completed successfully\", script->target_version);\n  }\n\n  // 保存新版本号\n  auto save_result = save_current_version(current_version);\n  if (!save_result) {\n    Logger().error(\"Failed to save version number after successful migration: {}\",\n                   save_result.error());\n    return false;\n  }\n\n  Logger().info(\"=== All Migrations Completed Successfully ===\");\n  return true;\n}\n\n}  // namespace Core::Migration\n"
  },
  {
    "path": "src/core/migration/migration.ixx",
    "content": "module;\n\nexport module Core.Migration;\n\nimport std;\nimport Core.State;\n\nnamespace Core::Migration {\n\nexport auto get_last_version() -> std::expected<std::string, std::string>;\n\nexport auto save_current_version(const std::string& version) -> std::expected<void, std::string>;\n\nexport auto compare_versions(const std::string& v1, const std::string& v2) -> int;\n\nexport auto run_migration_if_needed(Core::State::AppState& app_state) -> bool;\n\n}  // namespace Core::Migration\n"
  },
  {
    "path": "src/core/migration/scripts/scripts.cpp",
    "content": "module;\n\nmodule Core.Migration.Scripts;\n\nimport std;\nimport Core.State;\nimport Core.Database;\nimport Core.Migration.Schema;\nimport Features.Settings;\nimport Features.Settings.Types;\nimport Utils.Logger;\nimport <rfl/json.hpp>;\n\nnamespace Core::Migration::Scripts {\n\n// 执行 SQL Schema 迁移的辅助函数\ntemplate <typename SchemaModule>\nauto execute_sql_schema(Core::State::AppState& app_state) -> std::expected<void, std::string> {\n  for (const auto& sql : SchemaModule::statements) {\n    auto result = Core::Database::execute(*app_state.database, std::string(sql));\n    if (!result) {\n      return std::unexpected(std::format(\"SQL execution failed: {}\", result.error()));\n    }\n  }\n  return {};\n}\n\n// Migration: 2.0.0.0 - Initialize database schema\nauto migrate_v2_0_0_0(Core::State::AppState& app_state) -> std::expected<void, std::string> {\n  Logger().info(\"Executing migration to 2.0.0.0: Initialize database schema\");\n\n  // 首次启动，初始化数据库Schema\n  // Settings会在后续initialize时自动创建最新配置\n  auto result = execute_sql_schema<Core::Migration::Schema::V001>(app_state);\n\n  if (!result) {\n    return std::unexpected(\"Failed to initialize database schema: \" + result.error());\n  }\n\n  Logger().info(\"Database schema initialized successfully\");\n  return {};\n}\n\nauto migrate_v2_0_1_0(Core::State::AppState& app_state) -> std::expected<void, std::string> {\n  Logger().info(\"Executing migration to 2.0.1.0: Add gallery watch root recovery state\");\n\n  auto result = execute_sql_schema<Core::Migration::Schema::V002>(app_state);\n  if (!result) {\n    return std::unexpected(\"Failed to add watch root recovery state schema: \" + result.error());\n  }\n\n  return {};\n}\n\nauto migrate_v2_0_2_0(Core::State::AppState& app_state) -> std::expected<void, std::string> {\n  Logger().info(\"Executing migration to 2.0.2.0: Update version check URL\");\n\n  auto settings_path_result = Features::Settings::get_settings_path();\n  if (!settings_path_result) {\n    return std::unexpected(\"Failed to get settings path: \" + settings_path_result.error());\n  }\n\n  const auto& settings_path = settings_path_result.value();\n  if (!std::filesystem::exists(settings_path)) {\n    Logger().info(\"Settings file not found, skip version URL migration\");\n    return {};\n  }\n\n  std::ifstream file(settings_path);\n  if (!file) {\n    return std::unexpected(\"Failed to open settings file: \" + settings_path.string());\n  }\n\n  std::string json_str((std::istreambuf_iterator<char>(file)), std::istreambuf_iterator<char>());\n\n  auto settings_result =\n      rfl::json::read<Features::Settings::Types::AppSettings, rfl::DefaultIfMissing>(json_str);\n  if (!settings_result) {\n    return std::unexpected(\"Failed to parse settings: \" + settings_result.error().what());\n  }\n\n  auto settings = settings_result.value();\n  settings.update.version_url = \"https://spin.infinitymomo.com/version.txt\";\n\n  auto save_result = Features::Settings::save_settings_to_file(settings_path, settings);\n  if (!save_result) {\n    return std::unexpected(\"Failed to save settings: \" + save_result.error());\n  }\n\n  Logger().info(\"Settings version URL migrated successfully\");\n  return {};\n}\n\nauto migrate_v2_0_8_0(Core::State::AppState& app_state) -> std::expected<void, std::string> {\n  Logger().info(\"Executing migration to 2.0.8.0: Add nuan5 extended Infinity Nikki columns\");\n\n  auto result = execute_sql_schema<Core::Migration::Schema::V003>(app_state);\n  if (!result) {\n    return std::unexpected(\"Failed to add nuan5 Infinity Nikki columns: \" + result.error());\n  }\n  return {};\n}\n\nauto get_all_migrations() -> const std::vector<MigrationScript>& {\n  static const std::vector<MigrationScript> migrations = {\n      {\"2.0.0.0\", \"Initialize database schema\", true, migrate_v2_0_0_0},\n      {\"2.0.1.0\", \"Add gallery watch root recovery state\", true, migrate_v2_0_1_0},\n      {\"2.0.2.0\", \"Update version check URL\", false, migrate_v2_0_2_0},\n      {\"2.0.8.0\", \"Add nuan5 Infinity Nikki extract columns\", true, migrate_v2_0_8_0},\n\n      // 未来版本的迁移脚本在此添加\n      // {\"2.0.2.0\", \"Add user preferences\", migrate_v2_0_2_0},\n  };\n  return migrations;\n}\n\n}  // namespace Core::Migration::Scripts\n"
  },
  {
    "path": "src/core/migration/scripts/scripts.ixx",
    "content": "module;\n\nexport module Core.Migration.Scripts;\n\nimport std;\nimport Core.State;\n\nnamespace Core::Migration::Scripts {\n\n// MigrationScript 定义\nexport struct MigrationScript {\n  std::string target_version;\n  std::string description;\n  bool run_on_fresh_install = true;\n  std::function<std::expected<void, std::string>(Core::State::AppState&)> migration_fn;\n};\n\n// 获取所有迁移脚本\nexport auto get_all_migrations() -> const std::vector<MigrationScript>&;\n\n}  // namespace Core::Migration::Scripts\n"
  },
  {
    "path": "src/core/rpc/endpoints/clipboard/clipboard.cpp",
    "content": "module;\n\nmodule Core.RPC.Endpoints.Clipboard;\n\nimport Core.State;\nimport Core.RPC;\nimport Core.RPC.State;\nimport Core.RPC.Types;\nimport Utils.System;\nimport <asio.hpp>;\n\nnamespace Core::RPC::Endpoints::Clipboard {\n\nauto handle_read_text([[maybe_unused]] Core::State::AppState& app_state,\n                      [[maybe_unused]] const EmptyParams& params)\n    -> RpcAwaitable<std::optional<std::string>> {\n  auto result = Utils::System::read_clipboard_text();\n  if (!result) {\n    co_return std::unexpected(\n        RpcError{.code = static_cast<int>(ErrorCode::ServerError),\n                 .message = \"Failed to read clipboard text: \" + result.error()});\n  }\n\n  co_return result.value();\n}\n\nauto register_all(Core::State::AppState& app_state) -> void {\n  register_method<EmptyParams, std::optional<std::string>>(\n      app_state, app_state.rpc->registry, \"clipboard.readText\", handle_read_text,\n      \"Read plain text from the system clipboard\");\n}\n\n}  // namespace Core::RPC::Endpoints::Clipboard\n"
  },
  {
    "path": "src/core/rpc/endpoints/clipboard/clipboard.ixx",
    "content": "module;\n\nexport module Core.RPC.Endpoints.Clipboard;\n\nimport Core.State;\n\nnamespace Core::RPC::Endpoints::Clipboard {\n\nexport auto register_all(Core::State::AppState& state) -> void;\n\n}  // namespace Core::RPC::Endpoints::Clipboard\n"
  },
  {
    "path": "src/core/rpc/endpoints/dialog/dialog.cpp",
    "content": "module;\n\n#include <rfl/json.hpp>\n\nmodule Core.RPC.Endpoints.Dialog;\n\nimport std;\nimport Core.DialogService;\nimport Core.State;\nimport Core.WebView.State;\nimport Core.RPC;\nimport Core.RPC.State;\nimport Core.RPC.Types;\nimport Utils.Dialog;\nimport Vendor.Windows;\nimport <asio.hpp>;\n\nnamespace Core::RPC::Endpoints::Dialog {\n\n// 获取父窗口句柄的辅助函数\nauto get_parent_window(Core::State::AppState& app_state, int8_t mode) -> Vendor::Windows::HWND {\n  switch (mode) {\n    case 0:  // 无父窗口\n      return nullptr;\n    case 1:  // webview2\n      return app_state.webview->window.webview_hwnd;\n    case 2:  // 激活窗口\n      return Vendor::Windows::GetForegroundWindow();\n    default:\n      return nullptr;  // 默认无父窗口\n  }\n}\n\nauto handle_select_file([[maybe_unused]] Core::State::AppState& app_state,\n                        const Utils::Dialog::FileSelectorParams& params)\n    -> asio::awaitable<Core::RPC::RpcResult<Utils::Dialog::FileSelectorResult>> {\n  Vendor::Windows::HWND hwnd = get_parent_window(app_state, params.parent_window_mode);\n  auto result = Core::DialogService::open_file(app_state, params, hwnd);\n  if (!result) {\n    co_return std::unexpected(Core::RPC::RpcError{\n        .code = static_cast<int>(Core::RPC::ErrorCode::ServerError),\n        .message = \"Service error: \" + result.error(),\n    });\n  }\n  co_return result.value();\n}\n\nauto handle_select_folder([[maybe_unused]] Core::State::AppState& app_state,\n                          const Utils::Dialog::FolderSelectorParams& params)\n    -> asio::awaitable<Core::RPC::RpcResult<Utils::Dialog::FolderSelectorResult>> {\n  Vendor::Windows::HWND hwnd = get_parent_window(app_state, params.parent_window_mode);\n  auto result = Core::DialogService::open_folder(app_state, params, hwnd);\n  if (!result) {\n    co_return std::unexpected(Core::RPC::RpcError{\n        .code = static_cast<int>(Core::RPC::ErrorCode::ServerError),\n        .message = \"Service error: \" + result.error(),\n    });\n  }\n  co_return result.value();\n}\n\nauto register_all(Core::State::AppState& app_state) -> void {\n  Core::RPC::register_method<Utils::Dialog::FileSelectorParams, Utils::Dialog::FileSelectorResult>(\n      app_state, app_state.rpc->registry, \"dialog.openFile\", handle_select_file,\n      \"Open a file picker and return selected file paths\");\n\n  Core::RPC::register_method<Utils::Dialog::FolderSelectorParams,\n                             Utils::Dialog::FolderSelectorResult>(\n      app_state, app_state.rpc->registry, \"dialog.openDirectory\", handle_select_folder,\n      \"Open a folder picker and return selected path\");\n}\n\n}  // namespace Core::RPC::Endpoints::Dialog\n"
  },
  {
    "path": "src/core/rpc/endpoints/dialog/dialog.ixx",
    "content": "module;\n\nexport module Core.RPC.Endpoints.Dialog;\n\nimport Core.State;\n\nnamespace Core::RPC::Endpoints::Dialog {\n\nexport auto register_all(Core::State::AppState& app_state) -> void;\n\n}  // namespace Core::RPC::Endpoints::Dialog"
  },
  {
    "path": "src/core/rpc/endpoints/extensions/extensions.cpp",
    "content": "module;\n\n#include <asio.hpp>\n\nmodule Core.RPC.Endpoints.Extensions;\n\nimport std;\nimport Core.State;\nimport Core.RPC;\nimport Core.RPC.State;\nimport Core.RPC.Types;\nimport Extensions.InfinityNikki.TaskService;\nimport Extensions.InfinityNikki.Types;\nimport Extensions.InfinityNikki.GameDirectory;\nimport Utils.Logger;\nimport <rfl/json.hpp>;\n\nnamespace Core::RPC::Endpoints::Extensions {\n\nstruct StartExtensionTaskResult {\n  std::string task_id;\n};\n\nauto handle_infinity_nikki_get_game_directory([[maybe_unused]] Core::State::AppState& app_state,\n                                              [[maybe_unused]] const rfl::Generic& params)\n    -> Core::RPC::RpcAwaitable<::Extensions::InfinityNikki::InfinityNikkiGameDirResult> {\n  auto result = ::Extensions::InfinityNikki::GameDirectory::get_game_directory();\n  if (!result) {\n    co_return std::unexpected(Core::RPC::RpcError{\n        .code = static_cast<int>(Core::RPC::ErrorCode::ServerError),\n        .message = \"Failed to get Infinity Nikki game directory: \" + result.error(),\n    });\n  }\n\n  co_return result.value();\n}\n\nauto handle_infinity_nikki_start_extract_photo_params(\n    Core::State::AppState& app_state,\n    const ::Extensions::InfinityNikki::InfinityNikkiExtractPhotoParamsRequest& params)\n    -> Core::RPC::RpcAwaitable<StartExtensionTaskResult> {\n  auto task_result =\n      ::Extensions::InfinityNikki::TaskService::start_extract_photo_params_task(app_state, params);\n  if (!task_result) {\n    co_return std::unexpected(Core::RPC::RpcError{\n        .code = static_cast<int>(Core::RPC::ErrorCode::InvalidRequest),\n        .message = task_result.error(),\n    });\n  }\n  co_return StartExtensionTaskResult{.task_id = task_result.value()};\n}\n\nauto handle_infinity_nikki_start_extract_photo_params_for_folder(\n    Core::State::AppState& app_state,\n    const ::Extensions::InfinityNikki::InfinityNikkiExtractPhotoParamsForFolderRequest& params)\n    -> Core::RPC::RpcAwaitable<StartExtensionTaskResult> {\n  auto task_result =\n      ::Extensions::InfinityNikki::TaskService::start_extract_photo_params_for_folder_task(\n          app_state, params);\n  if (!task_result) {\n    co_return std::unexpected(Core::RPC::RpcError{\n        .code = static_cast<int>(Core::RPC::ErrorCode::InvalidRequest),\n        .message = task_result.error(),\n    });\n  }\n  co_return StartExtensionTaskResult{.task_id = task_result.value()};\n}\n\nauto handle_infinity_nikki_start_initialize_screenshot_hardlinks(\n    Core::State::AppState& app_state, [[maybe_unused]] const rfl::Generic& params)\n    -> Core::RPC::RpcAwaitable<StartExtensionTaskResult> {\n  auto task_result =\n      ::Extensions::InfinityNikki::TaskService::start_initialize_screenshot_hardlinks_task(\n          app_state);\n  if (!task_result) {\n    co_return std::unexpected(Core::RPC::RpcError{\n        .code = static_cast<int>(Core::RPC::ErrorCode::InvalidRequest),\n        .message = task_result.error(),\n    });\n  }\n  co_return StartExtensionTaskResult{.task_id = task_result.value()};\n}\n\nauto register_all(Core::State::AppState& app_state) -> void {\n  Core::RPC::register_method<rfl::Generic, ::Extensions::InfinityNikki::InfinityNikkiGameDirResult>(\n      app_state, app_state.rpc->registry, \"extensions.infinityNikki.getGameDirectory\",\n      handle_infinity_nikki_get_game_directory,\n      \"Get Infinity Nikki game installation directory from launcher config\");\n\n  Core::RPC::register_method<::Extensions::InfinityNikki::InfinityNikkiExtractPhotoParamsRequest,\n                             StartExtensionTaskResult>(\n      app_state, app_state.rpc->registry, \"extensions.infinityNikki.startExtractPhotoParams\",\n      handle_infinity_nikki_start_extract_photo_params,\n      \"Create a background task to extract and index Infinity Nikki photo params\");\n\n  Core::RPC::register_method<\n      ::Extensions::InfinityNikki::InfinityNikkiExtractPhotoParamsForFolderRequest,\n      StartExtensionTaskResult>(\n      app_state, app_state.rpc->registry,\n      \"extensions.infinityNikki.startExtractPhotoParamsForFolder\",\n      handle_infinity_nikki_start_extract_photo_params_for_folder,\n      \"Create a background task to extract Infinity Nikki photo params for a gallery folder\");\n\n  Core::RPC::register_method<rfl::Generic, StartExtensionTaskResult>(\n      app_state, app_state.rpc->registry,\n      \"extensions.infinityNikki.startInitializeScreenshotHardlinks\",\n      handle_infinity_nikki_start_initialize_screenshot_hardlinks,\n      \"Create a background task to initialize Infinity Nikki ScreenShot hardlinks\");\n\n  Logger().info(\"Extensions RPC endpoints registered\");\n}\n\n}  // namespace Core::RPC::Endpoints::Extensions\n"
  },
  {
    "path": "src/core/rpc/endpoints/extensions/extensions.ixx",
    "content": "module;\n\nexport module Core.RPC.Endpoints.Extensions;\n\nimport Core.State;\n\nnamespace Core::RPC::Endpoints::Extensions {\n\nexport auto register_all(Core::State::AppState& app_state) -> void;\n\n}  // namespace Core::RPC::Endpoints::Extensions\n"
  },
  {
    "path": "src/core/rpc/endpoints/file/file.cpp",
    "content": "module;\n\n#include <rfl/json.hpp>\n\nmodule Core.RPC.Endpoints.File;\n\nimport std;\nimport Core.State;\nimport Core.RPC;\nimport Core.RPC.State;\nimport Core.RPC.Types;\nimport Utils.File;\nimport Utils.Path;\nimport Utils.System;\nimport <asio.hpp>;\n\nnamespace Core::RPC::Endpoints::File {\n\nstruct ReadFileParams {\n  std::string path;\n};\n\nstruct WriteFileParams {\n  std::string path;\n  std::string content;\n  bool is_binary{false};\n  bool overwrite{true};\n};\n\nstruct ListDirectoryParams {\n  std::string path;\n  std::vector<std::string> extensions{};\n};\n\nstruct GetFileInfoParams {\n  std::string path;\n};\n\nstruct DeletePathParams {\n  std::string path;\n  bool recursive{false};\n};\n\nstruct MovePathParams {\n  std::string source_path;\n  std::string destination_path;\n  bool overwrite{false};\n};\n\nstruct CopyPathParams {\n  std::string source_path;\n  std::string destination_path;\n  bool recursive{false};\n  bool overwrite{false};\n};\n\nstruct OpenAppDataDirectoryResult {\n  bool success;\n  std::string message;\n};\n\nstruct OpenAppDataDirectoryParams {};\n\nstruct OpenLogDirectoryResult {\n  bool success;\n  std::string message;\n};\n\nstruct OpenLogDirectoryParams {};\n\nauto handle_read_file([[maybe_unused]] Core::State::AppState& app_state,\n                      const ReadFileParams& params)\n    -> RpcAwaitable<Utils::File::EncodedFileReadResult> {\n  auto result = co_await Utils::File::read_file_and_encode(params.path);\n  if (!result) {\n    co_return std::unexpected(RpcError{.code = static_cast<int>(ErrorCode::ServerError),\n                                       .message = \"Failed to read file: \" + result.error()});\n  }\n\n  co_return result.value();\n}\n\nauto handle_write_file([[maybe_unused]] Core::State::AppState& app_state,\n                       const WriteFileParams& params)\n    -> RpcAwaitable<Utils::File::FileWriteResult> {\n  auto result = co_await Utils::File::write_file(params.path, params.content, params.is_binary,\n                                                 params.overwrite);\n  if (!result) {\n    co_return std::unexpected(RpcError{.code = static_cast<int>(ErrorCode::ServerError),\n                                       .message = \"Failed to write file: \" + result.error()});\n  }\n\n  co_return result.value();\n}\n\nauto handle_list_directory([[maybe_unused]] Core::State::AppState& app_state,\n                           const ListDirectoryParams& params)\n    -> RpcAwaitable<Utils::File::DirectoryListResult> {\n  auto result = co_await Utils::File::list_directory(params.path, params.extensions);\n  if (!result) {\n    co_return std::unexpected(RpcError{.code = static_cast<int>(ErrorCode::ServerError),\n                                       .message = \"Failed to list directory: \" + result.error()});\n  }\n\n  co_return result.value();\n}\n\nauto handle_get_file_info([[maybe_unused]] Core::State::AppState& app_state,\n                          const GetFileInfoParams& params)\n    -> RpcAwaitable<Utils::File::FileInfoResult> {\n  auto result = co_await Utils::File::get_file_info(params.path);\n  if (!result) {\n    co_return std::unexpected(RpcError{.code = static_cast<int>(ErrorCode::ServerError),\n                                       .message = \"Failed to get file info: \" + result.error()});\n  }\n\n  co_return result.value();\n}\n\nauto handle_delete_path([[maybe_unused]] Core::State::AppState& app_state,\n                        const DeletePathParams& params) -> RpcAwaitable<Utils::File::DeleteResult> {\n  auto result = co_await Utils::File::delete_path(params.path, params.recursive);\n  if (!result) {\n    co_return std::unexpected(RpcError{.code = static_cast<int>(ErrorCode::ServerError),\n                                       .message = \"Failed to delete path: \" + result.error()});\n  }\n\n  co_return result.value();\n}\n\nauto handle_move_path([[maybe_unused]] Core::State::AppState& app_state,\n                      const MovePathParams& params) -> RpcAwaitable<Utils::File::MoveResult> {\n  auto result = co_await Utils::File::move_path(params.source_path, params.destination_path,\n                                                params.overwrite);\n  if (!result) {\n    co_return std::unexpected(RpcError{.code = static_cast<int>(ErrorCode::ServerError),\n                                       .message = \"Failed to move path: \" + result.error()});\n  }\n\n  co_return result.value();\n}\n\nauto handle_copy_path([[maybe_unused]] Core::State::AppState& app_state,\n                      const CopyPathParams& params) -> RpcAwaitable<Utils::File::CopyResult> {\n  auto result = co_await Utils::File::copy_path(params.source_path, params.destination_path,\n                                                params.recursive, params.overwrite);\n  if (!result) {\n    co_return std::unexpected(RpcError{.code = static_cast<int>(ErrorCode::ServerError),\n                                       .message = \"Failed to copy path: \" + result.error()});\n  }\n\n  co_return result.value();\n}\n\nauto handle_open_app_data_directory([[maybe_unused]] Core::State::AppState& app_state,\n                                    [[maybe_unused]] const OpenAppDataDirectoryParams& params)\n    -> RpcAwaitable<OpenAppDataDirectoryResult> {\n  auto app_data_directory = Utils::Path::GetAppDataDirectory();\n  if (!app_data_directory) {\n    co_return std::unexpected(\n        RpcError{.code = static_cast<int>(ErrorCode::ServerError),\n                 .message = \"Failed to get app data directory: \" + app_data_directory.error()});\n  }\n\n  auto open_result = Utils::System::open_directory(app_data_directory.value());\n  if (!open_result) {\n    co_return std::unexpected(\n        RpcError{.code = static_cast<int>(ErrorCode::ServerError),\n                 .message = \"Failed to open app data directory: \" + open_result.error()});\n  }\n\n  co_return OpenAppDataDirectoryResult{\n      .success = true,\n      .message = \"App data directory opened successfully.\",\n  };\n}\n\nauto handle_open_log_directory([[maybe_unused]] Core::State::AppState& app_state,\n                               [[maybe_unused]] const OpenLogDirectoryParams& params)\n    -> RpcAwaitable<OpenLogDirectoryResult> {\n  auto log_directory = Utils::Path::GetAppDataSubdirectory(\"logs\");\n  if (!log_directory) {\n    co_return std::unexpected(\n        RpcError{.code = static_cast<int>(ErrorCode::ServerError),\n                 .message = \"Failed to get log directory: \" + log_directory.error()});\n  }\n\n  auto open_result = Utils::System::open_directory(log_directory.value());\n  if (!open_result) {\n    co_return std::unexpected(\n        RpcError{.code = static_cast<int>(ErrorCode::ServerError),\n                 .message = \"Failed to open log directory: \" + open_result.error()});\n  }\n\n  co_return OpenLogDirectoryResult{\n      .success = true,\n      .message = \"Log directory opened successfully.\",\n  };\n}\n\nauto register_all(Core::State::AppState& app_state) -> void {\n  register_method<ReadFileParams, Utils::File::EncodedFileReadResult>(\n      app_state, app_state.rpc->registry, \"file.read\", handle_read_file,\n      \"Read file content with automatic text/binary detection and encoding\");\n\n  register_method<WriteFileParams, Utils::File::FileWriteResult>(\n      app_state, app_state.rpc->registry, \"file.write\", handle_write_file,\n      \"Write content to file with text/binary support and optional overwrite protection\");\n\n  register_method<ListDirectoryParams, Utils::File::DirectoryListResult>(\n      app_state, app_state.rpc->registry, \"file.listDirectory\", handle_list_directory,\n      \"List directory contents with optional file extension filtering\");\n\n  register_method<GetFileInfoParams, Utils::File::FileInfoResult>(\n      app_state, app_state.rpc->registry, \"file.getInfo\", handle_get_file_info,\n      \"Get detailed information about a file or directory\");\n\n  register_method<DeletePathParams, Utils::File::DeleteResult>(\n      app_state, app_state.rpc->registry, \"file.delete\", handle_delete_path,\n      \"Delete file or directory with optional recursive deletion\");\n\n  register_method<MovePathParams, Utils::File::MoveResult>(\n      app_state, app_state.rpc->registry, \"file.move\", handle_move_path,\n      \"Move or rename file/directory with optional overwrite protection\");\n\n  register_method<CopyPathParams, Utils::File::CopyResult>(\n      app_state, app_state.rpc->registry, \"file.copy\", handle_copy_path,\n      \"Copy file or directory with optional recursive copy and overwrite protection\");\n\n  register_method<OpenAppDataDirectoryParams, OpenAppDataDirectoryResult>(\n      app_state, app_state.rpc->registry, \"file.openAppDataDirectory\",\n      handle_open_app_data_directory, \"Open app data directory in file explorer\");\n\n  register_method<OpenLogDirectoryParams, OpenLogDirectoryResult>(\n      app_state, app_state.rpc->registry, \"file.openLogDirectory\", handle_open_log_directory,\n      \"Open log directory in file explorer\");\n}\n\n}  // namespace Core::RPC::Endpoints::File\n"
  },
  {
    "path": "src/core/rpc/endpoints/file/file.ixx",
    "content": "module;\n\nexport module Core.RPC.Endpoints.File;\n\nimport Core.State;\n\nnamespace Core::RPC::Endpoints::File {\n\nexport auto register_all(Core::State::AppState& state) -> void;\n\n}  // namespace Core::RPC::Endpoints::File\n"
  },
  {
    "path": "src/core/rpc/endpoints/gallery/asset.cpp",
    "content": "module;\n\nmodule Core.RPC.Endpoints.Gallery.Asset;\n\nimport std;\nimport Core.State;\nimport Core.RPC;\nimport Core.RPC.State;\nimport Core.RPC.Types;\nimport Core.RPC.NotificationHub;\nimport Features.Gallery;\nimport Features.Gallery.Types;\nimport Features.Gallery.Asset.Service;\nimport Features.Gallery.Asset.Repository;\nimport <asio.hpp>;\nimport <rfl/json.hpp>;\n\nnamespace Core::RPC::Endpoints::Gallery::Asset {\n\nstruct CheckAssetReachableParams {\n  std::int64_t asset_id = 0;\n};\n\nstruct CheckAssetReachableResult {\n  bool exists = false;\n  bool readable = false;\n  std::optional<std::string> path;\n  std::optional<std::string> reason;\n};\n\n// ============= 时间线视图 RPC 处理函数 =============\n\nauto handle_get_timeline_buckets(Core::State::AppState& app_state,\n                                 const Features::Gallery::Types::TimelineBucketsParams& params)\n    -> RpcAwaitable<Features::Gallery::Types::TimelineBucketsResponse> {\n  auto result = Features::Gallery::Asset::Service::get_timeline_buckets(app_state, params);\n\n  if (!result) {\n    co_return std::unexpected(RpcError{.code = static_cast<int>(ErrorCode::ServerError),\n                                       .message = \"Service error: \" + result.error()});\n  }\n\n  co_return result.value();\n}\n\nauto handle_get_assets_by_month(Core::State::AppState& app_state,\n                                const Features::Gallery::Types::GetAssetsByMonthParams& params)\n    -> RpcAwaitable<Features::Gallery::Types::GetAssetsByMonthResponse> {\n  auto result = Features::Gallery::Asset::Service::get_assets_by_month(app_state, params);\n\n  if (!result) {\n    co_return std::unexpected(RpcError{.code = static_cast<int>(ErrorCode::ServerError),\n                                       .message = \"Service error: \" + result.error()});\n  }\n\n  co_return result.value();\n}\n\n// ============= 统一查询 RPC 处理函数 =============\n\nauto handle_query_assets(Core::State::AppState& app_state,\n                         const Features::Gallery::Types::QueryAssetsParams& params)\n    -> RpcAwaitable<Features::Gallery::Types::ListResponse> {\n  auto result = Features::Gallery::Asset::Service::query_assets(app_state, params);\n\n  if (!result) {\n    co_return std::unexpected(RpcError{.code = static_cast<int>(ErrorCode::ServerError),\n                                       .message = \"Service error: \" + result.error()});\n  }\n\n  co_return result.value();\n}\n\nauto handle_query_asset_layout_meta(\n    Core::State::AppState& app_state,\n    const Features::Gallery::Types::QueryAssetLayoutMetaParams& params)\n    -> RpcAwaitable<Features::Gallery::Types::QueryAssetLayoutMetaResponse> {\n  auto result = Features::Gallery::Asset::Service::query_asset_layout_meta(app_state, params);\n\n  if (!result) {\n    co_return std::unexpected(RpcError{.code = static_cast<int>(ErrorCode::ServerError),\n                                       .message = \"Service error: \" + result.error()});\n  }\n\n  co_return result.value();\n}\n\nauto handle_query_photo_map_points(\n    Core::State::AppState& app_state,\n    const Features::Gallery::Types::QueryPhotoMapPointsParams& params)\n    -> RpcAwaitable<std::vector<Features::Gallery::Types::PhotoMapPoint>> {\n  auto result = Features::Gallery::Asset::Service::query_photo_map_points(app_state, params);\n\n  if (!result) {\n    co_return std::unexpected(RpcError{.code = static_cast<int>(ErrorCode::ServerError),\n                                       .message = \"Service error: \" + result.error()});\n  }\n\n  co_return result.value();\n}\n\nauto handle_get_infinity_nikki_details(\n    Core::State::AppState& app_state,\n    const Features::Gallery::Types::GetInfinityNikkiDetailsParams& params)\n    -> RpcAwaitable<Features::Gallery::Types::InfinityNikkiDetails> {\n  auto result = Features::Gallery::Asset::Service::get_infinity_nikki_details(app_state, params);\n\n  if (!result) {\n    co_return std::unexpected(RpcError{.code = static_cast<int>(ErrorCode::ServerError),\n                                       .message = \"Service error: \" + result.error()});\n  }\n\n  co_return result.value();\n}\n\nauto handle_get_infinity_nikki_metadata_names(\n    Core::State::AppState& app_state,\n    const Features::Gallery::Types::GetInfinityNikkiMetadataNamesParams& params)\n    -> RpcAwaitable<Features::Gallery::Types::InfinityNikkiMetadataNames> {\n  auto result = co_await Features::Gallery::Asset::Service::get_infinity_nikki_metadata_names(\n      app_state, params);\n\n  if (!result) {\n    co_return std::unexpected(RpcError{.code = static_cast<int>(ErrorCode::ServerError),\n                                       .message = \"Service error: \" + result.error()});\n  }\n\n  co_return result.value();\n}\n\nauto handle_get_asset_main_colors(Core::State::AppState& app_state,\n                                  const Features::Gallery::Types::GetAssetMainColorsParams& params)\n    -> RpcAwaitable<std::vector<Features::Gallery::Types::AssetMainColor>> {\n  auto result = Features::Gallery::Asset::Service::get_asset_main_colors(app_state, params);\n\n  if (!result) {\n    co_return std::unexpected(RpcError{.code = static_cast<int>(ErrorCode::ServerError),\n                                       .message = \"Service error: \" + result.error()});\n  }\n\n  co_return result.value();\n}\n\nauto handle_get_home_stats(Core::State::AppState& app_state,\n                           [[maybe_unused]] const EmptyParams& params)\n    -> RpcAwaitable<Features::Gallery::Types::HomeStats> {\n  auto result = Features::Gallery::Asset::Service::get_home_stats(app_state);\n\n  if (!result) {\n    co_return std::unexpected(RpcError{.code = static_cast<int>(ErrorCode::ServerError),\n                                       .message = \"Service error: \" + result.error()});\n  }\n\n  co_return result.value();\n}\n\n// ============= 资产动作 RPC 处理函数 =============\n\nauto handle_open_asset_default(Core::State::AppState& app_state,\n                               const Features::Gallery::Types::GetParams& params)\n    -> RpcAwaitable<Features::Gallery::Types::OperationResult> {\n  auto result = Features::Gallery::open_asset_with_default_app(app_state, params.id);\n\n  if (!result) {\n    co_return std::unexpected(RpcError{.code = static_cast<int>(ErrorCode::ServerError),\n                                       .message = \"Service error: \" + result.error()});\n  }\n\n  co_return result.value();\n}\n\nauto handle_reveal_asset_in_explorer(Core::State::AppState& app_state,\n                                     const Features::Gallery::Types::GetParams& params)\n    -> RpcAwaitable<Features::Gallery::Types::OperationResult> {\n  auto result = Features::Gallery::reveal_asset_in_explorer(app_state, params.id);\n\n  if (!result) {\n    co_return std::unexpected(RpcError{.code = static_cast<int>(ErrorCode::ServerError),\n                                       .message = \"Service error: \" + result.error()});\n  }\n\n  co_return result.value();\n}\n\nauto handle_copy_assets_to_clipboard(Core::State::AppState& app_state,\n                                     const Features::Gallery::Types::AssetIdsParams& params)\n    -> RpcAwaitable<Features::Gallery::Types::OperationResult> {\n  auto result = Features::Gallery::copy_assets_to_clipboard(app_state, params.ids);\n\n  if (!result) {\n    co_return std::unexpected(RpcError{.code = static_cast<int>(ErrorCode::ServerError),\n                                       .message = \"Service error: \" + result.error()});\n  }\n\n  co_return result.value();\n}\n\nauto handle_move_assets_to_trash(Core::State::AppState& app_state,\n                                 const Features::Gallery::Types::AssetIdsParams& params)\n    -> RpcAwaitable<Features::Gallery::Types::OperationResult> {\n  auto result = Features::Gallery::move_assets_to_trash(app_state, params.ids);\n\n  if (!result) {\n    co_return std::unexpected(RpcError{.code = static_cast<int>(ErrorCode::ServerError),\n                                       .message = \"Service error: \" + result.error()});\n  }\n\n  if (result->affected_count.value_or(0) > 0) {\n    Core::RPC::NotificationHub::send_notification(app_state, \"gallery.changed\");\n  }\n\n  co_return result.value();\n}\n\nauto handle_move_assets_to_folder(Core::State::AppState& app_state,\n                                  const Features::Gallery::Types::MoveAssetsToFolderParams& params)\n    -> RpcAwaitable<Features::Gallery::Types::OperationResult> {\n  auto result = Features::Gallery::move_assets_to_folder(app_state, params);\n\n  if (!result) {\n    co_return std::unexpected(RpcError{.code = static_cast<int>(ErrorCode::ServerError),\n                                       .message = \"Service error: \" + result.error()});\n  }\n\n  if (result->affected_count.value_or(0) > 0) {\n    Core::RPC::NotificationHub::send_notification(app_state, \"gallery.changed\");\n  }\n\n  co_return result.value();\n}\n\nauto handle_update_assets_review_state(\n    Core::State::AppState& app_state,\n    const Features::Gallery::Types::UpdateAssetsReviewStateParams& params)\n    -> RpcAwaitable<Features::Gallery::Types::OperationResult> {\n  auto result = Features::Gallery::Asset::Service::update_assets_review_state(app_state, params);\n\n  if (!result) {\n    co_return std::unexpected(RpcError{.code = static_cast<int>(ErrorCode::ServerError),\n                                       .message = \"Service error: \" + result.error()});\n  }\n\n  co_return result.value();\n}\n\nauto handle_update_asset_description(\n    Core::State::AppState& app_state,\n    const Features::Gallery::Types::UpdateAssetDescriptionParams& params)\n    -> RpcAwaitable<Features::Gallery::Types::OperationResult> {\n  auto result = Features::Gallery::Asset::Service::update_asset_description(app_state, params);\n\n  if (!result) {\n    co_return std::unexpected(RpcError{.code = static_cast<int>(ErrorCode::ServerError),\n                                       .message = \"Service error: \" + result.error()});\n  }\n\n  if (result->affected_count.value_or(0) > 0) {\n    Core::RPC::NotificationHub::send_notification(app_state, \"gallery.changed\");\n  }\n\n  co_return result.value();\n}\n\nauto handle_set_infinity_nikki_user_record(\n    Core::State::AppState& app_state,\n    const Features::Gallery::Types::SetInfinityNikkiUserRecordParams& params)\n    -> RpcAwaitable<Features::Gallery::Types::OperationResult> {\n  auto result =\n      Features::Gallery::Asset::Service::set_infinity_nikki_user_record(app_state, params);\n\n  if (!result) {\n    co_return std::unexpected(RpcError{.code = static_cast<int>(ErrorCode::ServerError),\n                                       .message = \"Service error: \" + result.error()});\n  }\n\n  if (result->affected_count.value_or(0) > 0) {\n    Core::RPC::NotificationHub::send_notification(app_state, \"gallery.changed\");\n  }\n\n  co_return result.value();\n}\n\nauto handle_check_asset_reachable(Core::State::AppState& app_state,\n                                  const CheckAssetReachableParams& params)\n    -> RpcAwaitable<CheckAssetReachableResult> {\n  if (params.asset_id <= 0) {\n    co_return std::unexpected(RpcError{\n        .code = static_cast<int>(ErrorCode::InvalidParams),\n        .message = \"assetId must be greater than 0\",\n    });\n  }\n\n  auto asset_result =\n      Features::Gallery::Asset::Repository::get_asset_by_id(app_state, params.asset_id);\n  if (!asset_result) {\n    co_return std::unexpected(RpcError{\n        .code = static_cast<int>(ErrorCode::ServerError),\n        .message = \"Failed to query asset: \" + asset_result.error(),\n    });\n  }\n\n  if (!asset_result->has_value()) {\n    co_return CheckAssetReachableResult{\n        .exists = false,\n        .readable = false,\n        .path = std::nullopt,\n        .reason = std::string(\"Asset not found in index\"),\n    };\n  }\n\n  const auto& asset = asset_result->value();\n  std::filesystem::path file_path(asset.path);\n  std::error_code ec;\n  const bool exists = std::filesystem::exists(file_path, ec) && !ec;\n\n  if (!exists) {\n    co_return CheckAssetReachableResult{\n        .exists = false,\n        .readable = false,\n        .path = asset.path,\n        .reason = std::string(\"File not found on disk\"),\n    };\n  }\n\n  std::ifstream stream(file_path, std::ios::binary);\n  if (!stream.is_open()) {\n    co_return CheckAssetReachableResult{\n        .exists = true,\n        .readable = false,\n        .path = asset.path,\n        .reason = std::string(\"Failed to open file for reading\"),\n    };\n  }\n\n  co_return CheckAssetReachableResult{\n      .exists = true,\n      .readable = true,\n      .path = asset.path,\n      .reason = std::nullopt,\n  };\n}\n\n// ============= RPC 方法注册 =============\n\nauto register_all(Core::State::AppState& app_state) -> void {\n  // 时间线视图\n  register_method<Features::Gallery::Types::TimelineBucketsParams,\n                  Features::Gallery::Types::TimelineBucketsResponse>(\n      app_state, app_state.rpc->registry, \"gallery.getTimelineBuckets\", handle_get_timeline_buckets,\n      \"Get timeline buckets (months) with asset counts for timeline view\");\n\n  register_method<Features::Gallery::Types::GetAssetsByMonthParams,\n                  Features::Gallery::Types::GetAssetsByMonthResponse>(\n      app_state, app_state.rpc->registry, \"gallery.getAssetsByMonth\", handle_get_assets_by_month,\n      \"Get all assets for a specific month in timeline view\");\n\n  // 统一资产查询接口\n  register_method<Features::Gallery::Types::QueryAssetsParams,\n                  Features::Gallery::Types::ListResponse>(\n      app_state, app_state.rpc->registry, \"gallery.queryAssets\", handle_query_assets,\n      \"Unified asset query interface with flexible filters (folder, month, year, type, search) \"\n      \"and optional pagination\");\n\n  register_method<Features::Gallery::Types::QueryAssetLayoutMetaParams,\n                  Features::Gallery::Types::QueryAssetLayoutMetaResponse>(\n      app_state, app_state.rpc->registry, \"gallery.queryAssetLayoutMeta\",\n      handle_query_asset_layout_meta,\n      \"Query lightweight asset layout metadata for adaptive gallery layout calculation\");\n\n  register_method<Features::Gallery::Types::QueryPhotoMapPointsParams,\n                  std::vector<Features::Gallery::Types::PhotoMapPoint>>(\n      app_state, app_state.rpc->registry, \"gallery.queryPhotoMapPoints\",\n      handle_query_photo_map_points,\n      \"Query Infinity Nikki photo map points using the current gallery filters\");\n\n  register_method<Features::Gallery::Types::GetInfinityNikkiDetailsParams,\n                  Features::Gallery::Types::InfinityNikkiDetails>(\n      app_state, app_state.rpc->registry, \"gallery.getInfinityNikkiDetails\",\n      handle_get_infinity_nikki_details,\n      \"Get Infinity Nikki extracted data and user record for the specified asset\");\n\n  register_method<Features::Gallery::Types::GetInfinityNikkiMetadataNamesParams,\n                  Features::Gallery::Types::InfinityNikkiMetadataNames>(\n      app_state, app_state.rpc->registry, \"gallery.getInfinityNikkiMetadataNames\",\n      handle_get_infinity_nikki_metadata_names,\n      \"Resolve localized names for Infinity Nikki metadata ids such as pose/filter/light\");\n\n  register_method<Features::Gallery::Types::GetAssetMainColorsParams,\n                  std::vector<Features::Gallery::Types::AssetMainColor>>(\n      app_state, app_state.rpc->registry, \"gallery.getAssetMainColors\",\n      handle_get_asset_main_colors, \"Get extracted main colors for the specified asset\");\n\n  register_method<EmptyParams, Features::Gallery::Types::HomeStats>(\n      app_state, app_state.rpc->registry, \"gallery.getHomeStats\", handle_get_home_stats,\n      \"Get home page gallery stats summary\");\n\n  register_method<Features::Gallery::Types::GetParams, Features::Gallery::Types::OperationResult>(\n      app_state, app_state.rpc->registry, \"gallery.openAssetDefault\", handle_open_asset_default,\n      \"Open the selected asset file with the default system application\");\n\n  register_method<Features::Gallery::Types::GetParams, Features::Gallery::Types::OperationResult>(\n      app_state, app_state.rpc->registry, \"gallery.revealAssetInExplorer\",\n      handle_reveal_asset_in_explorer, \"Reveal and select the asset file in explorer\");\n\n  register_method<Features::Gallery::Types::AssetIdsParams,\n                  Features::Gallery::Types::OperationResult>(\n      app_state, app_state.rpc->registry, \"gallery.copyAssetsToClipboard\",\n      handle_copy_assets_to_clipboard,\n      \"Copy selected asset files to the system clipboard as files\");\n\n  register_method<Features::Gallery::Types::AssetIdsParams,\n                  Features::Gallery::Types::OperationResult>(\n      app_state, app_state.rpc->registry, \"gallery.moveAssetsToTrash\", handle_move_assets_to_trash,\n      \"Move selected asset files to system recycle bin and remove them from gallery index\");\n\n  register_method<Features::Gallery::Types::MoveAssetsToFolderParams,\n                  Features::Gallery::Types::OperationResult>(\n      app_state, app_state.rpc->registry, \"gallery.moveAssetsToFolder\",\n      handle_move_assets_to_folder,\n      \"Move selected assets to an indexed target folder and update gallery index paths\");\n\n  register_method<Features::Gallery::Types::UpdateAssetsReviewStateParams,\n                  Features::Gallery::Types::OperationResult>(\n      app_state, app_state.rpc->registry, \"gallery.updateAssetsReviewState\",\n      handle_update_assets_review_state,\n      \"Batch update Lightroom-style review metadata such as rating and pick/reject state\");\n\n  register_method<Features::Gallery::Types::UpdateAssetDescriptionParams,\n                  Features::Gallery::Types::OperationResult>(\n      app_state, app_state.rpc->registry, \"gallery.updateAssetDescription\",\n      handle_update_asset_description,\n      \"Update a single asset description in the gallery details panel\");\n\n  register_method<Features::Gallery::Types::SetInfinityNikkiUserRecordParams,\n                  Features::Gallery::Types::OperationResult>(\n      app_state, app_state.rpc->registry, \"gallery.setInfinityNikkiUserRecord\",\n      handle_set_infinity_nikki_user_record,\n      \"Set or clear a single Infinity Nikki user record in the gallery details panel\");\n\n  register_method<CheckAssetReachableParams, CheckAssetReachableResult>(\n      app_state, app_state.rpc->registry, \"gallery.checkAssetReachable\",\n      handle_check_asset_reachable,\n      \"Check whether an indexed asset file still exists and is readable on disk\");\n}\n\n}  // namespace Core::RPC::Endpoints::Gallery::Asset\n"
  },
  {
    "path": "src/core/rpc/endpoints/gallery/asset.ixx",
    "content": "module;\n\nexport module Core.RPC.Endpoints.Gallery.Asset;\n\nimport Core.State;\n\nnamespace Core::RPC::Endpoints::Gallery::Asset {\n\nexport auto register_all(Core::State::AppState& app_state) -> void;\n\n}  // namespace Core::RPC::Endpoints::Gallery::Asset\n"
  },
  {
    "path": "src/core/rpc/endpoints/gallery/folder.cpp",
    "content": "module;\n\nmodule Core.RPC.Endpoints.Gallery.Folder;\n\nimport std;\nimport Core.State;\nimport Core.RPC;\nimport Core.RPC.State;\nimport Core.RPC.Types;\nimport Core.RPC.NotificationHub;\nimport Features.Gallery.Types;\nimport Features.Gallery.Folder.Repository;\nimport Features.Gallery.Folder.Service;\nimport <asio.hpp>;\nimport <rfl/json.hpp>;\n\nnamespace Core::RPC::Endpoints::Gallery::Folder {\n\nstruct UpdateFolderDisplayNameParams {\n  std::int64_t id;\n  std::optional<std::string> display_name;\n};\n\n// ============= 文件夹树 RPC 处理函数 =============\n\nauto handle_get_folder_tree(Core::State::AppState& app_state,\n                            [[maybe_unused]] const EmptyParams& params)\n    -> RpcAwaitable<std::vector<Features::Gallery::Types::FolderTreeNode>> {\n  auto result = Features::Gallery::Folder::Repository::get_folder_tree(app_state);\n\n  if (!result) {\n    co_return std::unexpected(RpcError{.code = static_cast<int>(ErrorCode::ServerError),\n                                       .message = \"Service error: \" + result.error()});\n  }\n\n  co_return result.value();\n}\n\nauto handle_update_folder_display_name(Core::State::AppState& app_state,\n                                       const UpdateFolderDisplayNameParams& params)\n    -> RpcAwaitable<Features::Gallery::Types::OperationResult> {\n  auto update_result = Features::Gallery::Folder::Service::update_folder_display_name(\n      app_state, params.id, params.display_name);\n  if (!update_result) {\n    co_return std::unexpected(RpcError{.code = static_cast<int>(ErrorCode::ServerError),\n                                       .message = \"Service error: \" + update_result.error()});\n  }\n\n  co_return update_result.value();\n}\n\nauto handle_open_folder_in_explorer(Core::State::AppState& app_state,\n                                    const Features::Gallery::Types::GetParams& params)\n    -> RpcAwaitable<Features::Gallery::Types::OperationResult> {\n  auto open_result =\n      Features::Gallery::Folder::Service::open_folder_in_explorer(app_state, params.id);\n  if (!open_result) {\n    co_return std::unexpected(RpcError{.code = static_cast<int>(ErrorCode::ServerError),\n                                       .message = \"Service error: \" + open_result.error()});\n  }\n\n  co_return open_result.value();\n}\n\nauto handle_remove_folder_watch(Core::State::AppState& app_state,\n                                const Features::Gallery::Types::GetParams& params)\n    -> RpcAwaitable<Features::Gallery::Types::OperationResult> {\n  auto remove_result =\n      Features::Gallery::Folder::Service::remove_root_folder_watch(app_state, params.id);\n  if (!remove_result) {\n    co_return std::unexpected(RpcError{.code = static_cast<int>(ErrorCode::ServerError),\n                                       .message = \"Service error: \" + remove_result.error()});\n  }\n\n  Core::RPC::NotificationHub::send_notification(app_state, \"gallery.changed\");\n  co_return remove_result.value();\n}\n\n// ============= RPC 方法注册 =============\n\nauto register_all(Core::State::AppState& app_state) -> void {\n  // 文件夹树\n  register_method<EmptyParams, std::vector<Features::Gallery::Types::FolderTreeNode>>(\n      app_state, app_state.rpc->registry, \"gallery.getFolderTree\", handle_get_folder_tree,\n      \"Get folder tree structure for navigation\");\n\n  register_method<UpdateFolderDisplayNameParams, Features::Gallery::Types::OperationResult>(\n      app_state, app_state.rpc->registry, \"gallery.updateFolderDisplayName\",\n      handle_update_folder_display_name, \"Update folder display name\");\n\n  register_method<Features::Gallery::Types::GetParams, Features::Gallery::Types::OperationResult>(\n      app_state, app_state.rpc->registry, \"gallery.openFolderInExplorer\",\n      handle_open_folder_in_explorer, \"Open folder in explorer\");\n\n  register_method<Features::Gallery::Types::GetParams, Features::Gallery::Types::OperationResult>(\n      app_state, app_state.rpc->registry, \"gallery.removeFolderWatch\", handle_remove_folder_watch,\n      \"Remove root folder watch and clean gallery index\");\n}\n\n}  // namespace Core::RPC::Endpoints::Gallery::Folder\n"
  },
  {
    "path": "src/core/rpc/endpoints/gallery/folder.ixx",
    "content": "module;\n\nexport module Core.RPC.Endpoints.Gallery.Folder;\n\nimport Core.State;\n\nnamespace Core::RPC::Endpoints::Gallery::Folder {\n\nexport auto register_all(Core::State::AppState& app_state) -> void;\n\n}  // namespace Core::RPC::Endpoints::Gallery::Folder\n"
  },
  {
    "path": "src/core/rpc/endpoints/gallery/gallery.cpp",
    "content": "module;\n\n#include <asio.hpp>\n\nmodule Core.RPC.Endpoints.Gallery;\n\nimport std;\nimport Core.State;\nimport Core.RPC;\nimport Core.RPC.State;\nimport Core.RPC.Types;\nimport Core.RPC.NotificationHub;\nimport Core.Async;\nimport Core.Tasks;\nimport Features.Gallery;\nimport Features.Gallery.Types;\nimport Core.RPC.Endpoints.Gallery.Asset;\nimport Core.RPC.Endpoints.Gallery.Tag;\nimport Core.RPC.Endpoints.Gallery.Folder;\nimport Utils.Logger;\nimport <rfl/json.hpp>;\n\nnamespace Core::RPC::Endpoints::Gallery {\n\nstruct StartScanDirectoryResult {\n  std::string task_id;\n};\n\nauto launch_scan_directory_task(Core::State::AppState& app_state,\n                                const Features::Gallery::Types::ScanOptions& options,\n                                const std::string& task_id) -> void {\n  auto* io_context = Core::Async::get_io_context(*app_state.async);\n  if (!io_context) {\n    Core::Tasks::complete_task_failed(app_state, task_id, \"Async runtime is not available\");\n    return;\n  }\n\n  asio::co_spawn(\n      *io_context,\n      [&app_state, options, task_id]() -> asio::awaitable<void> {\n        // 确保此协程先立即让出执行权，这样 RPC 可以先返回 task_id，\n        // 而不会被后续同步的扫描流水线阻塞。\n        co_await asio::post(asio::use_awaitable);\n\n        Core::Tasks::mark_task_running(app_state, task_id);\n\n        auto progress_callback =\n            [&app_state, &task_id](const Features::Gallery::Types::ScanProgress& progress) {\n              Core::Tasks::TaskProgress task_progress{\n                  .stage = progress.stage,\n                  .current = progress.current,\n                  .total = progress.total,\n                  .percent = progress.percent,\n                  .message = progress.message,\n              };\n              Core::Tasks::update_task_progress(app_state, task_id, task_progress);\n            };\n\n        auto scan_result = Features::Gallery::scan_directory(app_state, options, progress_callback);\n        if (!scan_result) {\n          auto error_message = \"Asset scan failed: \" + scan_result.error();\n          Logger().error(\"{}\", error_message);\n          Core::Tasks::complete_task_failed(app_state, task_id, error_message);\n          co_return;\n        }\n\n        const auto& result = scan_result.value();\n        Core::Tasks::update_task_progress(\n            app_state, task_id,\n            Core::Tasks::TaskProgress{\n                .stage = \"completed\",\n                .current = result.total_files,\n                .total = result.total_files,\n                .percent = 100.0,\n                .message =\n                    std::format(\"Scanned {}, new {}, updated {}, deleted {}\", result.total_files,\n                                result.new_items, result.updated_items, result.deleted_items),\n            });\n        Core::Tasks::complete_task_success(app_state, task_id);\n        Core::RPC::NotificationHub::send_notification(app_state, \"gallery.changed\");\n      },\n      asio::detached);\n}\n\n// ============= 扫描和索引 RPC 处理函数 =============\n\nauto handle_scan_directory(Core::State::AppState& app_state,\n                           const Features::Gallery::Types::ScanOptions& options)\n    -> RpcAwaitable<Features::Gallery::Types::ScanResult> {\n  auto result = Features::Gallery::scan_directory(app_state, options);\n\n  if (!result) {\n    co_return std::unexpected(RpcError{.code = static_cast<int>(ErrorCode::ServerError),\n                                       .message = \"Service error: \" + result.error()});\n  }\n\n  co_return result.value();\n}\n\nauto handle_start_scan_directory(Core::State::AppState& app_state,\n                                 const Features::Gallery::Types::ScanOptions& options)\n    -> RpcAwaitable<StartScanDirectoryResult> {\n  if (Core::Tasks::has_active_task_of_type(app_state, \"gallery.scanDirectory\")) {\n    co_return std::unexpected(RpcError{.code = static_cast<int>(ErrorCode::InvalidRequest),\n                                       .message = \"Another gallery scan task is already running\"});\n  }\n\n  auto task_id = Core::Tasks::create_task(app_state, \"gallery.scanDirectory\", options.directory);\n  if (task_id.empty()) {\n    co_return std::unexpected(RpcError{.code = static_cast<int>(ErrorCode::ServerError),\n                                       .message = \"Failed to create gallery scan task\"});\n  }\n\n  launch_scan_directory_task(app_state, options, task_id);\n\n  co_return StartScanDirectoryResult{.task_id = task_id};\n}\n\n// ============= 缩略图 RPC 处理函数 =============\n\nauto handle_cleanup_thumbnails(Core::State::AppState& app_state,\n                               [[maybe_unused]] const EmptyParams& params)\n    -> RpcAwaitable<Features::Gallery::Types::OperationResult> {\n  auto result = Features::Gallery::cleanup_thumbnails(app_state);\n\n  if (!result) {\n    co_return std::unexpected(RpcError{.code = static_cast<int>(ErrorCode::ServerError),\n                                       .message = \"Service error: \" + result.error()});\n  }\n\n  co_return result.value();\n}\n\n// ============= 缩略图统计 RPC 处理函数 =============\n\nauto handle_get_thumbnail_stats(Core::State::AppState& app_state,\n                                [[maybe_unused]] const EmptyParams& params)\n    -> RpcAwaitable<std::string> {\n  auto result = Features::Gallery::get_thumbnail_stats(app_state);\n\n  if (!result) {\n    co_return std::unexpected(RpcError{.code = static_cast<int>(ErrorCode::ServerError),\n                                       .message = \"Service error: \" + result.error()});\n  }\n\n  co_return result.value();\n}\n\n// ============= RPC 方法注册 =============\n\nauto register_all(Core::State::AppState& app_state) -> void {\n  // 注册子模块的 RPC 方法\n  Asset::register_all(app_state);\n  Tag::register_all(app_state);\n  Folder::register_all(app_state);\n\n  // 扫描和索引\n  register_method<Features::Gallery::Types::ScanOptions, Features::Gallery::Types::ScanResult>(\n      app_state, app_state.rpc->registry, \"gallery.scanDirectory\", handle_scan_directory,\n      \"Scan directory for asset files and add them to the library. Supports ignore rules and \"\n      \"folder management.\");\n\n  register_method<Features::Gallery::Types::ScanOptions, StartScanDirectoryResult>(\n      app_state, app_state.rpc->registry, \"gallery.startScanDirectory\", handle_start_scan_directory,\n      \"Create a background scan task for the gallery and return task id immediately.\");\n\n  // 缩略图操作\n  register_method<EmptyParams, Features::Gallery::Types::OperationResult>(\n      app_state, app_state.rpc->registry, \"gallery.cleanupThumbnails\", handle_cleanup_thumbnails,\n      \"Clean up orphaned thumbnail files\");\n\n  register_method<EmptyParams, std::string>(app_state, app_state.rpc->registry,\n                                            \"gallery.thumbnailStats\", handle_get_thumbnail_stats,\n                                            \"Get thumbnail storage statistics\");\n}\n\n}  // namespace Core::RPC::Endpoints::Gallery\n"
  },
  {
    "path": "src/core/rpc/endpoints/gallery/gallery.ixx",
    "content": "module;\n\nexport module Core.RPC.Endpoints.Gallery;\n\nimport Core.State;\n\nnamespace Core::RPC::Endpoints::Gallery {\n\nexport auto register_all(Core::State::AppState& app_state) -> void;\n\n}  // namespace Core::RPC::Endpoints::Gallery\n"
  },
  {
    "path": "src/core/rpc/endpoints/gallery/tag.cpp",
    "content": "module;\n\nmodule Core.RPC.Endpoints.Gallery.Tag;\n\nimport std;\nimport Core.State;\nimport Core.RPC;\nimport Core.RPC.State;\nimport Core.RPC.Types;\nimport Core.RPC.NotificationHub;\nimport Features.Gallery.Types;\nimport Features.Gallery.Tag.Repository;\nimport <asio.hpp>;\nimport <rfl/json.hpp>;\n\nnamespace Core::RPC::Endpoints::Gallery::Tag {\n\nstruct GetTagsByAssetIdsParams {\n  std::vector<std::int64_t> asset_ids;\n};\n\n// ============= 标签管理 RPC 处理函数 =============\n\nauto handle_get_tag_tree(Core::State::AppState& app_state,\n                         [[maybe_unused]] const EmptyParams& params)\n    -> RpcAwaitable<std::vector<Features::Gallery::Types::TagTreeNode>> {\n  auto result = Features::Gallery::Tag::Repository::get_tag_tree(app_state);\n\n  if (!result) {\n    co_return std::unexpected(RpcError{.code = static_cast<int>(ErrorCode::ServerError),\n                                       .message = \"Service error: \" + result.error()});\n  }\n\n  co_return result.value();\n}\n\nauto handle_create_tag(Core::State::AppState& app_state,\n                       const Features::Gallery::Types::CreateTagParams& params)\n    -> RpcAwaitable<std::int64_t> {\n  auto result = Features::Gallery::Tag::Repository::create_tag(app_state, params);\n\n  if (!result) {\n    co_return std::unexpected(RpcError{.code = static_cast<int>(ErrorCode::ServerError),\n                                       .message = \"Service error: \" + result.error()});\n  }\n\n  co_return result.value();\n}\n\nauto handle_update_tag(Core::State::AppState& app_state,\n                       const Features::Gallery::Types::UpdateTagParams& params)\n    -> RpcAwaitable<Features::Gallery::Types::OperationResult> {\n  auto result = Features::Gallery::Tag::Repository::update_tag(app_state, params);\n\n  if (!result) {\n    co_return std::unexpected(RpcError{.code = static_cast<int>(ErrorCode::ServerError),\n                                       .message = \"Service error: \" + result.error()});\n  }\n\n  co_return Features::Gallery::Types::OperationResult{.success = true,\n                                                      .message = \"Tag updated successfully\"};\n}\n\nauto handle_delete_tag(Core::State::AppState& app_state,\n                       const Features::Gallery::Types::GetParams& params)\n    -> RpcAwaitable<Features::Gallery::Types::OperationResult> {\n  auto result = Features::Gallery::Tag::Repository::delete_tag(app_state, params.id);\n\n  if (!result) {\n    co_return std::unexpected(RpcError{.code = static_cast<int>(ErrorCode::ServerError),\n                                       .message = \"Service error: \" + result.error()});\n  }\n\n  co_return Features::Gallery::Types::OperationResult{.success = true,\n                                                      .message = \"Tag deleted successfully\"};\n}\n\nauto handle_get_tag_stats(Core::State::AppState& app_state,\n                          [[maybe_unused]] const EmptyParams& params)\n    -> RpcAwaitable<std::vector<Features::Gallery::Types::TagStats>> {\n  auto result = Features::Gallery::Tag::Repository::get_tag_stats(app_state);\n\n  if (!result) {\n    co_return std::unexpected(RpcError{.code = static_cast<int>(ErrorCode::ServerError),\n                                       .message = \"Service error: \" + result.error()});\n  }\n\n  co_return result.value();\n}\n\n// ============= 资产-标签关联 RPC 处理函数 =============\n\nauto handle_add_tags_to_asset(Core::State::AppState& app_state,\n                              const Features::Gallery::Types::AddTagsToAssetParams& params)\n    -> RpcAwaitable<Features::Gallery::Types::OperationResult> {\n  auto result = Features::Gallery::Tag::Repository::add_tags_to_asset(app_state, params);\n\n  if (!result) {\n    co_return std::unexpected(RpcError{.code = static_cast<int>(ErrorCode::ServerError),\n                                       .message = \"Service error: \" + result.error()});\n  }\n\n  co_return Features::Gallery::Types::OperationResult{\n      .success = true, .message = \"Tags added to asset successfully\"};\n}\n\nauto handle_add_tag_to_assets(Core::State::AppState& app_state,\n                              const Features::Gallery::Types::AddTagToAssetsParams& params)\n    -> RpcAwaitable<Features::Gallery::Types::OperationResult> {\n  auto result = Features::Gallery::Tag::Repository::add_tag_to_assets(app_state, params);\n\n  if (!result) {\n    co_return std::unexpected(RpcError{.code = static_cast<int>(ErrorCode::ServerError),\n                                       .message = \"Service error: \" + result.error()});\n  }\n\n  if (result->affected_count.value_or(0) > 0) {\n    Core::RPC::NotificationHub::send_notification(app_state, \"gallery.changed\");\n  }\n\n  co_return result.value();\n}\n\nauto handle_remove_tags_from_asset(\n    Core::State::AppState& app_state,\n    const Features::Gallery::Types::RemoveTagsFromAssetParams& params)\n    -> RpcAwaitable<Features::Gallery::Types::OperationResult> {\n  auto result = Features::Gallery::Tag::Repository::remove_tags_from_asset(app_state, params);\n\n  if (!result) {\n    co_return std::unexpected(RpcError{.code = static_cast<int>(ErrorCode::ServerError),\n                                       .message = \"Service error: \" + result.error()});\n  }\n\n  co_return Features::Gallery::Types::OperationResult{\n      .success = true, .message = \"Tags removed from asset successfully\"};\n}\n\nauto handle_get_asset_tags(Core::State::AppState& app_state,\n                           const Features::Gallery::Types::GetAssetTagsParams& params)\n    -> RpcAwaitable<std::vector<Features::Gallery::Types::Tag>> {\n  auto result = Features::Gallery::Tag::Repository::get_asset_tags(app_state, params.asset_id);\n\n  if (!result) {\n    co_return std::unexpected(RpcError{.code = static_cast<int>(ErrorCode::ServerError),\n                                       .message = \"Service error: \" + result.error()});\n  }\n\n  co_return result.value();\n}\n\nauto handle_get_tags_by_asset_ids(Core::State::AppState& app_state,\n                                  const GetTagsByAssetIdsParams& params)\n    -> RpcAwaitable<std::unordered_map<std::int64_t, std::vector<Features::Gallery::Types::Tag>>> {\n  auto result =\n      Features::Gallery::Tag::Repository::get_tags_by_asset_ids(app_state, params.asset_ids);\n\n  if (!result) {\n    co_return std::unexpected(RpcError{.code = static_cast<int>(ErrorCode::ServerError),\n                                       .message = \"Service error: \" + result.error()});\n  }\n\n  co_return result.value();\n}\n\n// ============= RPC 方法注册 =============\n\nauto register_all(Core::State::AppState& app_state) -> void {\n  // 标签管理\n  register_method<EmptyParams, std::vector<Features::Gallery::Types::TagTreeNode>>(\n      app_state, app_state.rpc->registry, \"gallery.getTagTree\", handle_get_tag_tree,\n      \"Get tag tree structure for navigation\");\n\n  register_method<Features::Gallery::Types::CreateTagParams, std::int64_t>(\n      app_state, app_state.rpc->registry, \"gallery.createTag\", handle_create_tag,\n      \"Create a new tag\");\n\n  register_method<Features::Gallery::Types::UpdateTagParams,\n                  Features::Gallery::Types::OperationResult>(app_state, app_state.rpc->registry,\n                                                             \"gallery.updateTag\", handle_update_tag,\n                                                             \"Update an existing tag\");\n\n  register_method<Features::Gallery::Types::GetParams, Features::Gallery::Types::OperationResult>(\n      app_state, app_state.rpc->registry, \"gallery.deleteTag\", handle_delete_tag,\n      \"Delete a tag and its associations\");\n\n  register_method<EmptyParams, std::vector<Features::Gallery::Types::TagStats>>(\n      app_state, app_state.rpc->registry, \"gallery.getTagStats\", handle_get_tag_stats,\n      \"Get tag usage statistics\");\n\n  // 资产-标签关联\n  register_method<Features::Gallery::Types::AddTagsToAssetParams,\n                  Features::Gallery::Types::OperationResult>(\n      app_state, app_state.rpc->registry, \"gallery.addTagsToAsset\", handle_add_tags_to_asset,\n      \"Add tags to an asset\");\n\n  register_method<Features::Gallery::Types::AddTagToAssetsParams,\n                  Features::Gallery::Types::OperationResult>(\n      app_state, app_state.rpc->registry, \"gallery.addTagToAssets\", handle_add_tag_to_assets,\n      \"Add a tag to multiple assets\");\n\n  register_method<Features::Gallery::Types::RemoveTagsFromAssetParams,\n                  Features::Gallery::Types::OperationResult>(\n      app_state, app_state.rpc->registry, \"gallery.removeTagsFromAsset\",\n      handle_remove_tags_from_asset, \"Remove tags from an asset\");\n\n  register_method<Features::Gallery::Types::GetAssetTagsParams,\n                  std::vector<Features::Gallery::Types::Tag>>(\n      app_state, app_state.rpc->registry, \"gallery.getAssetTags\", handle_get_asset_tags,\n      \"Get all tags for a specific asset\");\n\n  register_method<GetTagsByAssetIdsParams,\n                  std::unordered_map<std::int64_t, std::vector<Features::Gallery::Types::Tag>>>(\n      app_state, app_state.rpc->registry, \"gallery.getTagsByAssetIds\", handle_get_tags_by_asset_ids,\n      \"Batch get tags for multiple assets\");\n}\n\n}  // namespace Core::RPC::Endpoints::Gallery::Tag\n"
  },
  {
    "path": "src/core/rpc/endpoints/gallery/tag.ixx",
    "content": "module;\n\nexport module Core.RPC.Endpoints.Gallery.Tag;\n\nimport Core.State;\n\nnamespace Core::RPC::Endpoints::Gallery::Tag {\n\nexport auto register_all(Core::State::AppState& app_state) -> void;\n\n}  // namespace Core::RPC::Endpoints::Gallery::Tag\n"
  },
  {
    "path": "src/core/rpc/endpoints/registry/registry.cpp",
    "content": "module;\n\nmodule Core.RPC.Endpoints.Registry;\n\nimport std;\nimport Core.State;\nimport Core.RPC;\nimport Core.RPC.State;\nimport Core.RPC.Types;\nimport Core.Commands;\nimport Core.Commands.State;\nimport <asio.hpp>;\n\nnamespace Core::RPC::Endpoints::Registry {\n\nauto handle_get_all_commands(Core::State::AppState& app_state,\n                             const Core::Commands::GetAllCommandsParams& params)\n    -> asio::awaitable<Core::RPC::RpcResult<Core::Commands::GetAllCommandsResult>> {\n  try {\n    if (!app_state.commands) {\n      co_return std::unexpected(\n          Core::RPC::RpcError{.code = static_cast<int>(Core::RPC::ErrorCode::ServerError),\n                              .message = \"Command registry not initialized\"});\n    }\n\n    // 获取所有命令描述符\n    auto all_commands = Core::Commands::get_all_commands(app_state.commands->registry);\n\n    // 转换为 RPC 传输格式\n    Core::Commands::GetAllCommandsResult result;\n    result.commands.reserve(all_commands.size());\n\n    for (const auto& command : all_commands) {\n      Core::Commands::CommandDescriptorData data{\n          .id = command.id,\n          .i18n_key = command.i18n_key,\n          .is_toggle = command.is_toggle,\n      };\n      result.commands.push_back(std::move(data));\n    }\n\n    co_return result;\n  } catch (const std::exception& e) {\n    co_return std::unexpected(\n        Core::RPC::RpcError{.code = static_cast<int>(Core::RPC::ErrorCode::ServerError),\n                            .message = \"Failed to get commands: \" + std::string(e.what())});\n  }\n}\n\nauto handle_invoke_command(Core::State::AppState& app_state,\n                           const Core::Commands::InvokeCommandParams& params)\n    -> asio::awaitable<Core::RPC::RpcResult<Core::Commands::InvokeCommandResult>> {\n  try {\n    if (!app_state.commands) {\n      co_return std::unexpected(\n          Core::RPC::RpcError{.code = static_cast<int>(Core::RPC::ErrorCode::ServerError),\n                              .message = \"Command registry not initialized\"});\n    }\n\n    if (params.id.empty()) {\n      co_return std::unexpected(\n          Core::RPC::RpcError{.code = static_cast<int>(Core::RPC::ErrorCode::InvalidParams),\n                              .message = \"Command id cannot be empty\"});\n    }\n\n    const auto success = Core::Commands::invoke_command(app_state.commands->registry, params.id);\n\n    Core::Commands::InvokeCommandResult result{\n        .success = success,\n        .message =\n            success ? \"Command invoked successfully\" : \"Command not found or failed: \" + params.id,\n    };\n\n    co_return result;\n  } catch (const std::exception& e) {\n    co_return std::unexpected(\n        Core::RPC::RpcError{.code = static_cast<int>(Core::RPC::ErrorCode::ServerError),\n                            .message = \"Failed to invoke command: \" + std::string(e.what())});\n  }\n}\n\nauto register_all(Core::State::AppState& app_state) -> void {\n  Core::RPC::register_method<Core::Commands::GetAllCommandsParams,\n                             Core::Commands::GetAllCommandsResult>(\n      app_state, app_state.rpc->registry, \"commands.getAll\", handle_get_all_commands,\n      \"Get all available command descriptors\");\n\n  Core::RPC::register_method<Core::Commands::InvokeCommandParams,\n                             Core::Commands::InvokeCommandResult>(\n      app_state, app_state.rpc->registry, \"commands.invoke\", handle_invoke_command,\n      \"Invoke a command by id\");\n}\n\n}  // namespace Core::RPC::Endpoints::Registry\n"
  },
  {
    "path": "src/core/rpc/endpoints/registry/registry.ixx",
    "content": "module;\n\nexport module Core.RPC.Endpoints.Registry;\n\nimport Core.State;\n\nnamespace Core::RPC::Endpoints::Registry {\n\nexport auto register_all(Core::State::AppState& app_state) -> void;\n\n}  // namespace Core::RPC::Endpoints::Registry\n"
  },
  {
    "path": "src/core/rpc/endpoints/runtime_info/runtime_info.cpp",
    "content": "module;\n\n#include <asio.hpp>\n\nmodule Core.RPC.Endpoints.RuntimeInfo;\n\nimport std;\nimport Core.State;\nimport Core.State.RuntimeInfo;\nimport Core.RPC;\nimport Core.RPC.State;\nimport Core.RPC.Types;\n\nnamespace Core::RPC::Endpoints::RuntimeInfo {\n\nstruct GetRuntimeInfoParams {};\n\nusing GetRuntimeInfoResult = Core::State::RuntimeInfo::RuntimeInfoState;\n\nauto handle_get_runtime_info(Core::State::AppState& app_state,\n                             [[maybe_unused]] const GetRuntimeInfoParams& params)\n    -> asio::awaitable<Core::RPC::RpcResult<GetRuntimeInfoResult>> {\n  if (!app_state.runtime_info) {\n    co_return std::unexpected(\n        Core::RPC::RpcError{.code = static_cast<int>(Core::RPC::ErrorCode::ServerError),\n                            .message = \"Runtime info state not initialized\"});\n  }\n\n  co_return *app_state.runtime_info;\n}\n\nauto register_all(Core::State::AppState& app_state) -> void {\n  Core::RPC::register_method<GetRuntimeInfoParams, GetRuntimeInfoResult>(\n      app_state, app_state.rpc->registry, \"runtime_info.get\", handle_get_runtime_info,\n      \"Get application runtime info and capability flags\");\n}\n\n}  // namespace Core::RPC::Endpoints::RuntimeInfo\n"
  },
  {
    "path": "src/core/rpc/endpoints/runtime_info/runtime_info.ixx",
    "content": "module;\n\nexport module Core.RPC.Endpoints.RuntimeInfo;\n\nimport Core.State;\n\nnamespace Core::RPC::Endpoints::RuntimeInfo {\n\nexport auto register_all(Core::State::AppState& app_state) -> void;\n\n}  // namespace Core::RPC::Endpoints::RuntimeInfo\n"
  },
  {
    "path": "src/core/rpc/endpoints/settings/settings.cpp",
    "content": "module;\n\nmodule Core.RPC.Endpoints.Settings;\n\nimport std;\nimport Core.State;\nimport Core.RPC;\nimport Core.RPC.State;\nimport Core.RPC.Types;\nimport Features.Settings;\nimport Features.Settings.Types;\nimport Features.Settings.Background;\nimport <asio.hpp>;\n\nnamespace Core::RPC::Endpoints::Settings {\n\nauto handle_get_settings([[maybe_unused]] Core::State::AppState& app_state,\n                         const Features::Settings::Types::GetSettingsParams& params)\n    -> asio::awaitable<Core::RPC::RpcResult<Features::Settings::Types::GetSettingsResult>> {\n  auto result = Features::Settings::get_settings(params);\n\n  if (!result) {\n    co_return std::unexpected(\n        Core::RPC::RpcError{.code = static_cast<int>(Core::RPC::ErrorCode::ServerError),\n                            .message = \"Service error: \" + result.error()});\n  }\n\n  co_return result.value();\n}\n\nauto handle_update_settings(Core::State::AppState& app_state,\n                            const Features::Settings::Types::UpdateSettingsParams& params)\n    -> asio::awaitable<Core::RPC::RpcResult<Features::Settings::Types::UpdateSettingsResult>> {\n  auto result = Features::Settings::update_settings(app_state, params);\n\n  if (!result) {\n    co_return std::unexpected(\n        Core::RPC::RpcError{.code = static_cast<int>(Core::RPC::ErrorCode::ServerError),\n                            .message = \"Service error: \" + result.error()});\n  }\n\n  co_return result.value();\n}\n\nauto handle_patch_settings(Core::State::AppState& app_state,\n                           const Features::Settings::Types::PatchSettingsParams& params)\n    -> asio::awaitable<Core::RPC::RpcResult<Features::Settings::Types::PatchSettingsResult>> {\n  auto result = Features::Settings::patch_settings(app_state, params);\n\n  if (!result) {\n    co_return std::unexpected(\n        Core::RPC::RpcError{.code = static_cast<int>(Core::RPC::ErrorCode::ServerError),\n                            .message = \"Service error: \" + result.error()});\n  }\n\n  co_return result.value();\n}\n\nauto handle_analyze_background([[maybe_unused]] Core::State::AppState& app_state,\n                               const Features::Settings::Types::BackgroundAnalysisParams& params)\n    -> asio::awaitable<Core::RPC::RpcResult<Features::Settings::Types::BackgroundAnalysisResult>> {\n  auto result = Features::Settings::Background::analyze_background(params);\n\n  if (!result) {\n    co_return std::unexpected(\n        Core::RPC::RpcError{.code = static_cast<int>(Core::RPC::ErrorCode::ServerError),\n                            .message = \"Service error: \" + result.error()});\n  }\n\n  co_return result.value();\n}\n\nauto handle_import_background([[maybe_unused]] Core::State::AppState& app_state,\n                              const Features::Settings::Types::BackgroundImportParams& params)\n    -> asio::awaitable<Core::RPC::RpcResult<Features::Settings::Types::BackgroundImportResult>> {\n  auto result = Features::Settings::Background::import_background_image(params);\n\n  if (!result) {\n    co_return std::unexpected(\n        Core::RPC::RpcError{.code = static_cast<int>(Core::RPC::ErrorCode::ServerError),\n                            .message = \"Service error: \" + result.error()});\n  }\n\n  co_return result.value();\n}\n\nauto handle_remove_background([[maybe_unused]] Core::State::AppState& app_state,\n                              const Features::Settings::Types::BackgroundRemoveParams& params)\n    -> asio::awaitable<Core::RPC::RpcResult<Features::Settings::Types::BackgroundRemoveResult>> {\n  auto result = Features::Settings::Background::remove_background_image(params);\n\n  if (!result) {\n    co_return std::unexpected(\n        Core::RPC::RpcError{.code = static_cast<int>(Core::RPC::ErrorCode::ServerError),\n                            .message = \"Service error: \" + result.error()});\n  }\n\n  co_return result.value();\n}\n\nauto register_all(Core::State::AppState& app_state) -> void {\n  Core::RPC::register_method<Features::Settings::Types::GetSettingsParams,\n                             Features::Settings::Types::GetSettingsResult>(\n      app_state, app_state.rpc->registry, \"settings.get\", handle_get_settings,\n      \"Get current settings configuration\");\n\n  Core::RPC::register_method<Features::Settings::Types::UpdateSettingsParams,\n                             Features::Settings::Types::UpdateSettingsResult>(\n      app_state, app_state.rpc->registry, \"settings.update\", handle_update_settings,\n      \"Update settings configuration\");\n\n  Core::RPC::register_method<Features::Settings::Types::PatchSettingsParams,\n                             Features::Settings::Types::PatchSettingsResult>(\n      app_state, app_state.rpc->registry, \"settings.patch\", handle_patch_settings,\n      \"Patch settings configuration\");\n\n  Core::RPC::register_method<Features::Settings::Types::BackgroundAnalysisParams,\n                             Features::Settings::Types::BackgroundAnalysisResult>(\n      app_state, app_state.rpc->registry, \"settings.background.analyze\", handle_analyze_background,\n      \"Analyze background image and return recommended theme and overlay colors\");\n\n  Core::RPC::register_method<Features::Settings::Types::BackgroundImportParams,\n                             Features::Settings::Types::BackgroundImportResult>(\n      app_state, app_state.rpc->registry, \"settings.background.import\", handle_import_background,\n      \"Import a background image into managed app storage and return its logical path\");\n\n  Core::RPC::register_method<Features::Settings::Types::BackgroundRemoveParams,\n                             Features::Settings::Types::BackgroundRemoveResult>(\n      app_state, app_state.rpc->registry, \"settings.background.remove\", handle_remove_background,\n      \"Remove a managed background image from app storage\");\n}\n\n}  // namespace Core::RPC::Endpoints::Settings\n"
  },
  {
    "path": "src/core/rpc/endpoints/settings/settings.ixx",
    "content": "module;\n\nexport module Core.RPC.Endpoints.Settings;\n\nimport Core.State;\n\nnamespace Core::RPC::Endpoints::Settings {\n\nexport auto register_all(Core::State::AppState& app_state) -> void;\n\n}  // namespace Core::RPC::Endpoints::Settings"
  },
  {
    "path": "src/core/rpc/endpoints/tasks/tasks.cpp",
    "content": "module;\n\n#include <asio.hpp>\n\nmodule Core.RPC.Endpoints.Tasks;\n\nimport std;\nimport Core.State;\nimport Core.RPC;\nimport Core.RPC.State;\nimport Core.RPC.Types;\nimport Core.Tasks;\n\nnamespace Core::RPC::Endpoints::Tasks {\n\nstruct ClearFinishedTasksResult {\n  std::int32_t cleared_count = 0;\n};\n\nauto handle_list_tasks(Core::State::AppState& app_state, [[maybe_unused]] const EmptyParams& params)\n    -> RpcAwaitable<std::vector<Core::Tasks::TaskSnapshot>> {\n  co_return Core::Tasks::list_tasks(app_state);\n}\n\nauto handle_clear_finished_tasks(Core::State::AppState& app_state,\n                                 [[maybe_unused]] const EmptyParams& params)\n    -> RpcAwaitable<ClearFinishedTasksResult> {\n  co_return ClearFinishedTasksResult{\n      .cleared_count = static_cast<std::int32_t>(Core::Tasks::clear_finished_tasks(app_state)),\n  };\n}\n\nauto register_all(Core::State::AppState& app_state) -> void {\n  register_method<EmptyParams, std::vector<Core::Tasks::TaskSnapshot>>(\n      app_state, app_state.rpc->registry, \"task.list\", handle_list_tasks,\n      \"List recent background tasks\");\n\n  register_method<EmptyParams, ClearFinishedTasksResult>(\n      app_state, app_state.rpc->registry, \"task.clearFinished\", handle_clear_finished_tasks,\n      \"Clear finished background tasks\");\n}\n\n}  // namespace Core::RPC::Endpoints::Tasks\n"
  },
  {
    "path": "src/core/rpc/endpoints/tasks/tasks.ixx",
    "content": "module;\n\nexport module Core.RPC.Endpoints.Tasks;\n\nimport Core.State;\n\nnamespace Core::RPC::Endpoints::Tasks {\n\nexport auto register_all(Core::State::AppState& app_state) -> void;\n\n}  // namespace Core::RPC::Endpoints::Tasks\n"
  },
  {
    "path": "src/core/rpc/endpoints/update/update.cpp",
    "content": "module;\n\nmodule Core.RPC.Endpoints.Update;\n\nimport std;\nimport Core.State;\nimport Core.RPC;\nimport Core.RPC.State;\nimport Core.RPC.Types;\nimport Features.Update;\nimport Features.Update.Types;\nimport <asio.hpp>;\nimport <rfl.hpp>;\n\nnamespace Core::RPC::Endpoints::Update {\n\nauto handle_check_for_update(Core::State::AppState& app_state,\n                             [[maybe_unused]] const rfl::Generic& params)\n    -> asio::awaitable<Core::RPC::RpcResult<Features::Update::Types::CheckUpdateResult>> {\n  auto result = co_await Features::Update::check_for_update(app_state);\n\n  if (!result) {\n    co_return std::unexpected(\n        Core::RPC::RpcError{.code = static_cast<int>(Core::RPC::ErrorCode::ServerError),\n                            .message = \"Service error: \" + result.error()});\n  }\n\n  co_return result.value();\n}\n\nauto handle_start_download(Core::State::AppState& app_state,\n                           [[maybe_unused]] const rfl::Generic& params)\n    -> asio::awaitable<Core::RPC::RpcResult<Features::Update::Types::StartDownloadUpdateResult>> {\n  auto result = co_await Features::Update::start_download_update_task(app_state);\n\n  if (!result) {\n    co_return std::unexpected(\n        Core::RPC::RpcError{.code = static_cast<int>(Core::RPC::ErrorCode::ServerError),\n                            .message = \"Service error: \" + result.error()});\n  }\n\n  co_return result.value();\n}\n\nauto handle_install_update(Core::State::AppState& app_state,\n                           const Features::Update::Types::InstallUpdateParams& params)\n    -> asio::awaitable<Core::RPC::RpcResult<Features::Update::Types::InstallUpdateResult>> {\n  auto result = Features::Update::install_update(app_state, params);\n\n  if (!result) {\n    co_return std::unexpected(\n        Core::RPC::RpcError{.code = static_cast<int>(Core::RPC::ErrorCode::ServerError),\n                            .message = \"Service error: \" + result.error()});\n  }\n\n  co_return result.value();\n}\n\nauto register_all(Core::State::AppState& app_state) -> void {\n  Core::RPC::register_method<rfl::Generic, Features::Update::Types::CheckUpdateResult>(\n      app_state, app_state.rpc->registry, \"update.check_for_update\", handle_check_for_update,\n      \"Check for available updates\");\n\n  Core::RPC::register_method<rfl::Generic, Features::Update::Types::StartDownloadUpdateResult>(\n      app_state, app_state.rpc->registry, \"update.start_download\", handle_start_download,\n      \"Start downloading update package in background\");\n\n  Core::RPC::register_method<Features::Update::Types::InstallUpdateParams,\n                             Features::Update::Types::InstallUpdateResult>(\n      app_state, app_state.rpc->registry, \"update.install_update\", handle_install_update,\n      \"Install downloaded update\");\n}\n\n}  // namespace Core::RPC::Endpoints::Update\n"
  },
  {
    "path": "src/core/rpc/endpoints/update/update.ixx",
    "content": "module;\n\nexport module Core.RPC.Endpoints.Update;\n\nimport Core.State;\n\nnamespace Core::RPC::Endpoints::Update {\n\nexport auto register_all(Core::State::AppState& app_state) -> void;\n\n}  // namespace Core::RPC::Endpoints::Update\n"
  },
  {
    "path": "src/core/rpc/endpoints/webview/webview.cpp",
    "content": "module;\n\n#include <asio.hpp>\n#include <rfl/json.hpp>\n\nmodule Core.RPC.Endpoints.WebView;\n\nimport std;\nimport Core.State;\nimport Core.RPC;\nimport Core.RPC.State;\nimport Core.RPC.Types;\nimport Core.WebView.State;\nimport UI.WebViewWindow;\n\nnamespace Core::RPC::Endpoints::WebView {\n\nstruct WindowControlResult {\n  bool success;\n};\n\nstruct SetFullscreenParams {\n  bool fullscreen;\n};\n\nstruct FullscreenControlResult {\n  bool success;\n  bool fullscreen;\n};\n\nstruct WindowStateResult {\n  bool maximized;\n  bool fullscreen;\n};\n\nauto handle_minimize_window(Core::State::AppState& app_state,\n                            [[maybe_unused]] const rfl::Generic& params)\n    -> asio::awaitable<Core::RPC::RpcResult<WindowControlResult>> {\n  auto result = UI::WebViewWindow::minimize_window(app_state);\n\n  if (!result) {\n    co_return std::unexpected(\n        Core::RPC::RpcError{.code = static_cast<int>(Core::RPC::ErrorCode::ServerError),\n                            .message = \"Failed to minimize window: \" + result.error()});\n  }\n\n  co_return WindowControlResult{.success = true};\n}\n\nauto handle_toggle_maximize_window(Core::State::AppState& app_state,\n                                   [[maybe_unused]] const rfl::Generic& params)\n    -> asio::awaitable<Core::RPC::RpcResult<WindowControlResult>> {\n  auto result = UI::WebViewWindow::toggle_maximize_window(app_state);\n  if (!result) {\n    co_return std::unexpected(\n        Core::RPC::RpcError{.code = static_cast<int>(Core::RPC::ErrorCode::ServerError),\n                            .message = \"Failed to toggle maximize window: \" + result.error()});\n  }\n\n  co_return WindowControlResult{.success = true};\n}\n\nauto handle_set_fullscreen_window(Core::State::AppState& app_state,\n                                  const SetFullscreenParams& params)\n    -> asio::awaitable<Core::RPC::RpcResult<FullscreenControlResult>> {\n  auto result = UI::WebViewWindow::set_fullscreen_window(app_state, params.fullscreen);\n  if (!result) {\n    co_return std::unexpected(\n        Core::RPC::RpcError{.code = static_cast<int>(Core::RPC::ErrorCode::ServerError),\n                            .message = \"Failed to set fullscreen window state: \" + result.error()});\n  }\n\n  co_return FullscreenControlResult{.success = true, .fullscreen = params.fullscreen};\n}\n\nauto handle_close_window(Core::State::AppState& app_state,\n                         [[maybe_unused]] const rfl::Generic& params)\n    -> asio::awaitable<Core::RPC::RpcResult<WindowControlResult>> {\n  auto result = UI::WebViewWindow::close_window(app_state);\n\n  if (!result) {\n    co_return std::unexpected(\n        Core::RPC::RpcError{.code = static_cast<int>(Core::RPC::ErrorCode::ServerError),\n                            .message = \"Failed to close window: \" + result.error()});\n  }\n\n  co_return WindowControlResult{.success = true};\n}\n\nauto handle_get_window_state(Core::State::AppState& app_state,\n                             [[maybe_unused]] const rfl::Generic& params)\n    -> asio::awaitable<Core::RPC::RpcResult<WindowStateResult>> {\n  co_return WindowStateResult{\n      .maximized = app_state.webview && app_state.webview->window.is_maximized,\n      .fullscreen = app_state.webview && app_state.webview->window.is_fullscreen,\n  };\n}\n\nauto register_all(Core::State::AppState& app_state) -> void {\n  Core::RPC::register_method<rfl::Generic, WindowStateResult>(\n      app_state, app_state.rpc->registry, \"webview.getWindowState\", handle_get_window_state,\n      \"Get current window state for the webview host window\");\n\n  Core::RPC::register_method<rfl::Generic, WindowControlResult>(\n      app_state, app_state.rpc->registry, \"webview.minimize\", handle_minimize_window,\n      \"Minimize the webview window\");\n\n  Core::RPC::register_method<rfl::Generic, WindowControlResult>(\n      app_state, app_state.rpc->registry, \"webview.toggleMaximize\", handle_toggle_maximize_window,\n      \"Toggle maximize state of the webview window\");\n\n  Core::RPC::register_method<SetFullscreenParams, FullscreenControlResult>(\n      app_state, app_state.rpc->registry, \"webview.setFullscreen\", handle_set_fullscreen_window,\n      \"Set fullscreen state of the webview window\");\n\n  Core::RPC::register_method<rfl::Generic, WindowControlResult>(\n      app_state, app_state.rpc->registry, \"webview.close\", handle_close_window,\n      \"Close the webview window\");\n}\n\n}  // namespace Core::RPC::Endpoints::WebView\n"
  },
  {
    "path": "src/core/rpc/endpoints/webview/webview.ixx",
    "content": "module;\n\nexport module Core.RPC.Endpoints.WebView;\n\nimport Core.State;\n\nnamespace Core::RPC::Endpoints::WebView {\n\n// 注册RPC方法\nexport auto register_all(Core::State::AppState& app_state) -> void;\n\n}  // namespace Core::RPC::Endpoints::WebView"
  },
  {
    "path": "src/core/rpc/endpoints/window_control/window_control.cpp",
    "content": "module;\n\n#include <asio.hpp>\n\nmodule Core.RPC.Endpoints.WindowControl;\n\nimport std;\nimport Core.State;\nimport Core.RPC;\nimport Core.RPC.State;\nimport Core.RPC.Types;\nimport Features.WindowControl;\nimport Utils.String;\n\nnamespace Core::RPC::Endpoints::WindowControl {\n\nstruct VisibleWindowTitleItem {\n  std::string title;\n};\n\nauto is_selectable_window(const Features::WindowControl::WindowInfo& window) -> bool {\n  if (window.title.empty() || window.title == L\"Program Manager\") {\n    return false;\n  }\n\n  return window.title.find(L\"SpinningMomo\") == std::wstring::npos;\n}\n\nauto build_visible_window_title_items() -> std::vector<VisibleWindowTitleItem> {\n  std::vector<VisibleWindowTitleItem> items;\n  std::unordered_set<std::string> seen_titles;\n\n  auto windows = Features::WindowControl::get_visible_windows();\n  items.reserve(windows.size());\n\n  for (const auto& window : windows) {\n    if (!is_selectable_window(window)) {\n      continue;\n    }\n\n    auto title = Utils::String::ToUtf8(window.title);\n    if (title.empty() || seen_titles.contains(title)) {\n      continue;\n    }\n\n    seen_titles.insert(title);\n    items.push_back(VisibleWindowTitleItem{.title = std::move(title)});\n  }\n\n  return items;\n}\n\nauto handle_list_visible_windows([[maybe_unused]] Core::State::AppState& app_state,\n                                 [[maybe_unused]] const EmptyParams& params)\n    -> asio::awaitable<RpcResult<std::vector<VisibleWindowTitleItem>>> {\n  co_return build_visible_window_title_items();\n}\n\nauto register_all(Core::State::AppState& app_state) -> void {\n  register_method<EmptyParams, std::vector<VisibleWindowTitleItem>>(\n      app_state, app_state.rpc->registry, \"windowControl.listVisibleWindows\",\n      handle_list_visible_windows,\n      \"List current visible window titles for target window selection\");\n}\n\n}  // namespace Core::RPC::Endpoints::WindowControl\n"
  },
  {
    "path": "src/core/rpc/endpoints/window_control/window_control.ixx",
    "content": "module;\n\nexport module Core.RPC.Endpoints.WindowControl;\n\nimport Core.State;\n\nnamespace Core::RPC::Endpoints::WindowControl {\n\nexport auto register_all(Core::State::AppState& app_state) -> void;\n\n}  // namespace Core::RPC::Endpoints::WindowControl\n"
  },
  {
    "path": "src/core/rpc/notification_hub.cpp",
    "content": "module;\n\nmodule Core.RPC.NotificationHub;\n\nimport std;\nimport Core.State;\nimport Core.Events;\nimport Core.WebView.Events;\nimport Core.HttpServer.SseManager;\nimport Utils.Logger;\n\nnamespace Core::RPC::NotificationHub {\n\nauto build_json_rpc_notification(const std::string& method, const std::string& params_json)\n    -> std::string {\n  return std::format(R\"({{\"jsonrpc\":\"2.0\",\"method\":\"{}\",\"params\":{}}})\", method, params_json);\n}\n\nauto send_notification(Core::State::AppState& state, const std::string& method,\n                       const std::string& params_json) -> void {\n  auto payload = build_json_rpc_notification(method, params_json);\n\n  if (state.events) {\n    // watcher 可能在后台线程触发，这里先丢回主线程再发给 WebView。\n    Core::Events::post(*state.events, Core::WebView::Events::WebViewResponseEvent{payload});\n  }\n\n  // 浏览器开发模式也用同一份通知（SSE）。\n  Core::HttpServer::SseManager::broadcast_event(state, payload);\n\n  Logger().debug(\"Notification dispatched: {}\", method);\n}\n\n}  // namespace Core::RPC::NotificationHub\n"
  },
  {
    "path": "src/core/rpc/notification_hub.ixx",
    "content": "module;\n\nexport module Core.RPC.NotificationHub;\n\nimport std;\nimport Core.State;\n\nnamespace Core::RPC::NotificationHub {\n\n// 同时分发到 WebView 和 SSE 的统一通知出口\nexport auto send_notification(Core::State::AppState& state, const std::string& method,\n                              const std::string& params_json = \"{}\") -> void;\n\n}  // namespace Core::RPC::NotificationHub\n"
  },
  {
    "path": "src/core/rpc/registry.cpp",
    "content": "module;\n\nmodule Core.RPC.Registry;\n\nimport std;\nimport Core.State;\nimport Core.RPC.Endpoints.Clipboard;\nimport Core.RPC.Endpoints.Dialog;\nimport Core.RPC.Endpoints.File;\nimport Core.RPC.Endpoints.RuntimeInfo;\nimport Core.RPC.Endpoints.Settings;\nimport Core.RPC.Endpoints.Tasks;\nimport Core.RPC.Endpoints.Registry;\nimport Core.RPC.Endpoints.Update;\nimport Core.RPC.Endpoints.WebView;\nimport Core.RPC.Endpoints.Gallery;\nimport Core.RPC.Endpoints.Extensions;\nimport Core.RPC.Endpoints.WindowControl;\nimport Utils.Logger;\n\nnamespace Core::RPC::Registry {\n\n// 注册所有RPC端点\nauto register_all_endpoints(Core::State::AppState& state) -> void {\n  Logger().info(\"Starting RPC endpoints registration...\");\n\n  // 注册文件操作端点\n  Endpoints::File::register_all(state);\n\n  // 注册剪贴板端点\n  Endpoints::Clipboard::register_all(state);\n\n  // 注册应用运行时信息端点\n  Endpoints::RuntimeInfo::register_all(state);\n\n  // 注册设置端点\n  Endpoints::Settings::register_all(state);\n\n  // 注册后台任务端点\n  Endpoints::Tasks::register_all(state);\n\n  // 注册功能注册表端点\n  Endpoints::Registry::register_all(state);\n\n  // 注册对话框端点\n  Endpoints::Dialog::register_all(state);\n\n  // 注册更新端点\n  Endpoints::Update::register_all(state);\n\n  // 注册Webview端点\n  Endpoints::WebView::register_all(state);\n\n  // 注册Gallery端点\n  Endpoints::Gallery::register_all(state);\n\n  // 注册拓展端点\n  Endpoints::Extensions::register_all(state);\n\n  // 注册窗口控制端点\n  Endpoints::WindowControl::register_all(state);\n\n  Logger().info(\"RPC endpoints registration completed\");\n}\n\n}  // namespace Core::RPC::Registry\n"
  },
  {
    "path": "src/core/rpc/registry.ixx",
    "content": "module;\n\nexport module Core.RPC.Registry;\n\nimport std;\nimport Core.State;\n\nnamespace Core::RPC::Registry {\n\n// 注册所有RPC端点\nexport auto register_all_endpoints(Core::State::AppState& state) -> void;\n\n}  // namespace Core::RPC::Registry\n"
  },
  {
    "path": "src/core/rpc/rpc.cpp",
    "content": "module;\n\nmodule Core.RPC;\n\nimport std;\nimport Core.State;\nimport Core.RPC.State;\nimport Core.RPC.Types;\nimport Utils.Logger;\nimport <asio.hpp>;\nimport <rfl/json.hpp>;\n\nnamespace Core::RPC {\n\n// 创建标准错误响应\nauto create_error_response(rfl::Generic request_id, ErrorCode error_code,\n                           const std::string& message) -> std::string {\n  JsonRpcErrorResponse error_response;\n  error_response.id = request_id;\n  error_response.error = RpcError{.code = static_cast<int>(error_code), .message = message};\n  return rfl::json::write<rfl::SnakeCaseToCamelCase>(error_response);\n}\n\n// 获取所有已注册方法的列表\nauto get_method_list(const Core::State::AppState& app_state) -> std::vector<MethodListItem> {\n  std::vector<MethodListItem> methods;\n  const auto& registry = app_state.rpc->registry;\n\n  for (const auto& [name, info] : registry) {\n    methods.emplace_back(MethodListItem{.name = name, .description = info.description});\n  }\n\n  return methods;\n}\n\n// 处理系统内置方法\nauto handle_system_method(Core::State::AppState& app_state, const JsonRpcRequest& request,\n                          rfl::Generic request_id) -> std::optional<std::string> {\n  if (request.method == \"system.listMethods\") {\n    JsonRpcSuccessResponse success_response;\n    success_response.id = request_id;\n    success_response.result = rfl::to_generic(get_method_list(app_state));\n    return rfl::json::write<rfl::SnakeCaseToCamelCase>(success_response);\n  }\n\n  if (request.method == \"system.methodSignature\") {\n    // 提前验证参数\n    if (!request.params.has_value()) {\n      return create_error_response(request_id, ErrorCode::InvalidParams,\n                                   \"Missing required parameter: method\");\n    }\n\n    auto signature_request_result =\n        rfl::from_generic<MethodSignatureRequest, rfl::SnakeCaseToCamelCase>(\n            request.params.value());\n    if (!signature_request_result) {\n      return create_error_response(\n          request_id, ErrorCode::InvalidParams,\n          \"Invalid parameters: \" + signature_request_result.error().what());\n    }\n\n    const auto& registry = app_state.rpc->registry;\n    const auto method_it = registry.find(signature_request_result.value().method);\n\n    if (method_it == registry.end()) {\n      return create_error_response(request_id, ErrorCode::MethodNotFound,\n                                   \"Method not found: \" + signature_request_result.value().method);\n    }\n\n    // 构造响应\n    MethodSignatureResponse signature_response{.method = method_it->second.name,\n                                               .description = method_it->second.description,\n                                               .params_schema = method_it->second.params_schema};\n\n    JsonRpcSuccessResponse success_response;\n    success_response.id = request_id;\n    success_response.result = rfl::to_generic<rfl::SnakeCaseToCamelCase>(signature_response);\n    return rfl::json::write<rfl::SnakeCaseToCamelCase>(success_response);\n  }\n\n  return std::nullopt;  // 不是系统方法\n}\n\n// 执行已注册的方法\nauto execute_registered_method(const MethodInfo& method_info, rfl::Generic params_generic,\n                               rfl::Generic request_id) -> asio::awaitable<std::string> {\n  try {\n    auto response_json = co_await method_info.handler(params_generic, request_id);\n    Logger().trace(\"Response: {}\", response_json);\n    co_return response_json;\n  } catch (const std::exception& e) {\n    Logger().error(\"Internal error during method execution: {}\", e.what());\n    co_return create_error_response(\n        request_id, ErrorCode::InternalError,\n        \"Internal error during method execution: \" + std::string(e.what()));\n  }\n}\n\n// 处理JSON-RPC 2.0协议请求 - 优化版本\nauto process_request(Core::State::AppState& app_state, const std::string& request_json)\n    -> asio::awaitable<std::string> {\n  try {\n    // 解析JSON-RPC请求\n    auto request_result = rfl::json::read<JsonRpcRequest, rfl::SnakeCaseToCamelCase>(request_json);\n    if (!request_result) {\n      const auto error_msg = \"Parse error: \" + std::string(request_result.error().what());\n      Logger().error(error_msg);\n      co_return create_error_response(rfl::Generic(), ErrorCode::ParseError, error_msg);\n    }\n\n    auto request = request_result.value();\n    const rfl::Generic request_id = request.id.value_or(rfl::Generic());\n\n    // 验证JSON-RPC版本\n    if (request.jsonrpc != \"2.0\") {\n      const auto error_msg = \"Invalid request: jsonrpc must be '2.0'\";\n      Logger().error(error_msg);\n      co_return create_error_response(request_id, ErrorCode::InvalidRequest, error_msg);\n    }\n\n    // 处理系统内置方法\n    if (auto system_response = handle_system_method(app_state, request, request_id)) {\n      co_return system_response.value();\n    }\n\n    // 查找已注册的方法\n    const auto& registry = app_state.rpc->registry;\n    const auto method_it = registry.find(request.method);\n    if (method_it == registry.end()) {\n      const auto error_msg = \"Method not found: \" + request.method;\n      Logger().error(error_msg);\n      co_return create_error_response(request_id, ErrorCode::MethodNotFound, error_msg);\n    }\n\n    // 准备参数\n    rfl::Generic params_generic = request.params.value_or(rfl::Generic::Object());\n\n    // 执行方法处理器\n    co_return co_await execute_registered_method(method_it->second, params_generic, request_id);\n\n  } catch (const std::exception& e) {\n    // 顶层异常处理\n    const auto error_msg = \"Unexpected error: \" + std::string(e.what());\n    Logger().error(error_msg);\n    co_return create_error_response(rfl::Generic(), ErrorCode::InternalError, error_msg);\n  }\n}\n\n}  // namespace Core::RPC\n"
  },
  {
    "path": "src/core/rpc/rpc.ixx",
    "content": "module;\n\n#include <asio.hpp>\n\nexport module Core.RPC;\n\nimport std;\nimport Core.State;\nimport Core.RPC.Types;\nimport Utils.Logger;\nimport <rfl/json.hpp>;\n\nnamespace Core::RPC {\n\n// 异步处理器签名\ntemplate <typename Request, typename Response>\nusing AsyncHandler =\n    std::function<asio::awaitable<RpcResult<Response>>(Core::State::AppState&, const Request&)>;\n\n// 创建标准错误响应\nauto create_error_response(rfl::Generic request_id, ErrorCode error_code,\n                                  const std::string& message) -> std::string;\n\n// 处理JSON-RPC请求\nexport auto process_request(Core::State::AppState& app_state, const std::string& request_json)\n    -> asio::awaitable<std::string>;\n\n// 注册RPC方法\nexport template <typename Request, typename Response>\nauto register_method(Core::State::AppState& app_state,\n                     std::unordered_map<std::string, MethodInfo>& registry,\n                     const std::string& method_name, AsyncHandler<Request, Response> handler,\n                     const std::string& description = \"\") -> void {\n  // 创建类型擦除的处理器包装\n  auto wrapped_handler = [handler, &app_state](rfl::Generic params_generic,\n                                               rfl::Generic id) -> asio::awaitable<std::string> {\n    // 从 rfl::Generic 转换为 Request 类型\n    auto request_result =\n        rfl::from_generic<Request, rfl::SnakeCaseToCamelCase, rfl::DefaultIfMissing>(\n            params_generic);\n    if (!request_result) {\n      co_return create_error_response(id, ErrorCode::InvalidParams,\n                                      \"Invalid parameters: \" + request_result.error().what());\n    }\n\n    // 执行业务逻辑 - 传递 app_state 参数\n    auto result = co_await handler(app_state, request_result.value());\n\n    // 构造响应\n    if (result) {\n      // 成功响应\n      JsonRpcSuccessResponse success_response;\n      success_response.id = id;\n      success_response.result = rfl::to_generic<rfl::SnakeCaseToCamelCase>(result.value());\n      co_return rfl::json::write<rfl::SnakeCaseToCamelCase>(success_response);\n    } else {\n      // 错误响应\n      const auto& error = result.error();\n      Logger().error(\"Error response: {}\", error.message);\n      co_return create_error_response(id, static_cast<ErrorCode>(error.code), error.message);\n    }\n  };\n\n  // 自动生成参数的 JSON Schema\n  auto params_schema = rfl::json::to_schema<Request, rfl::SnakeCaseToCamelCase>();\n\n  // 存储到注册表\n  registry[method_name] = MethodInfo{.name = method_name,\n                                     .description = description,\n                                     .params_schema = params_schema,\n                                     .handler = std::move(wrapped_handler)};\n}\n\n}  // namespace Core::RPC\n"
  },
  {
    "path": "src/core/rpc/state.ixx",
    "content": "module;\n\nexport module Core.RPC.State;\n\nimport std;\nimport Core.RPC.Types;\n\nexport namespace Core::RPC::State {\n\nstruct RpcState {\n  std::unordered_map<std::string, MethodInfo> registry;\n};\n\n}  // namespace Core::RPC::State\n"
  },
  {
    "path": "src/core/rpc/types.ixx",
    "content": "module;\n\n#include <asio.hpp>\n#include <rfl.hpp>\n\nexport module Core.RPC.Types;\n\nimport std;\n\nexport namespace Core::RPC {\n\n// JSON-RPC 2.0 标准错误码\nenum class ErrorCode {\n  ParseError = -32700,      // JSON解析错误\n  InvalidRequest = -32600,  // 无效请求\n  MethodNotFound = -32601,  // 方法未找到\n  InvalidParams = -32602,   // 无效参数\n  InternalError = -32603,   // 内部错误\n  ServerError = -32000      // 服务器错误\n};\n\n// RPC错误结构\nstruct RpcError {\n  int code;\n  std::string message;\n  std::optional<std::string> data;  // 可选的详细信息\n};\n\n// 结果类型定义\ntemplate <typename T>\nusing RpcResult = std::expected<T, RpcError>;\n\ntemplate <typename T>\nusing RpcAwaitable = asio::awaitable<RpcResult<T>>;\n\nstruct MethodListItem {\n  std::string name;\n  std::string description;\n};\n\n// 方法签名请求结构\nstruct MethodSignatureRequest {\n  std::string method;  // 要查询的方法名\n};\n\n// 方法签名响应结构\nstruct MethodSignatureResponse {\n  std::string method;         // 方法名\n  std::string description;    // 方法描述\n  std::string params_schema;  // 参数的JSON Schema\n};\n\n// JSON-RPC请求结构\nstruct JsonRpcRequest {\n  std::string jsonrpc;                 // 必须为 \"2.0\"\n  std::string method;                  // 方法名\n  std::optional<rfl::Generic> params;  // 参数(支持对象/数组/null)\n  std::optional<rfl::Generic> id;      // 请求ID\n};\n\n// JSON-RPC成功响应结构\nstruct JsonRpcSuccessResponse {\n  std::string jsonrpc{\"2.0\"};  // 版本号\n  rfl::Generic result;         // 结果数据(JSON值)\n  rfl::Generic id;             // 请求ID\n};\n\n// JSON-RPC错误响应结构\nstruct JsonRpcErrorResponse {\n  std::string jsonrpc{\"2.0\"};  // 版本号\n  RpcError error;              // 错误信息\n  rfl::Generic id;             // 请求ID\n};\n\n// 方法信息存储结构\nstruct MethodInfo {\n  std::string name;\n  std::string description;\n  std::string params_schema;  // 参数的JSON Schema\n  std::function<asio::awaitable<std::string>(rfl::Generic, rfl::Generic)> handler;\n};\n\n// 空参数结构，用于不需要参数的RPC方法\nstruct EmptyParams {};\n\n}  // namespace Core::RPC\n"
  },
  {
    "path": "src/core/runtime_info/runtime_info.cpp",
    "content": "module;\n\nmodule Core.RuntimeInfo;\n\nimport std;\nimport Core.State;\nimport Core.State.RuntimeInfo;\nimport Core.WebView;\nimport Utils.Graphics.Capture;\nimport Utils.Logger;\nimport Utils.Media.AudioCapture;\nimport Utils.System;\nimport Vendor.BuildConfig;\nimport Vendor.Version;\n\nnamespace Core::RuntimeInfo::Detail {\n\nusing RuntimeInfoState = Core::State::RuntimeInfo::RuntimeInfoState;\n\nauto collect_app_version(RuntimeInfoState& runtime_info) -> void {\n  runtime_info.version = Vendor::Version::get_app_version();\n\n  auto parse_result = std::istringstream(runtime_info.version);\n  char dot = '.';\n  unsigned int major = 0;\n  unsigned int minor = 0;\n  unsigned int patch = 0;\n  unsigned int build = 0;\n  if (parse_result >> major >> dot >> minor >> dot >> patch >> dot >> build) {\n    runtime_info.major_version = major;\n    runtime_info.minor_version = minor;\n    runtime_info.patch_version = patch;\n    runtime_info.build_number = build;\n  }\n}\n\nauto collect_os_and_capabilities(RuntimeInfoState& runtime_info) -> void {\n  auto version_result = Utils::System::get_windows_version();\n  if (!version_result) {\n    Logger().error(\"Failed to get OS version: {}\", version_result.error());\n    return;\n  }\n\n  const auto& version = version_result.value();\n  Logger().info(\"OS Version: {}.{}.{}\", version.major_version, version.minor_version,\n                version.build_number);\n\n  runtime_info.os_major_version = version.major_version;\n  runtime_info.os_minor_version = version.minor_version;\n  runtime_info.os_build_number = version.build_number;\n  runtime_info.os_name = Utils::System::get_windows_name(version);\n\n  // Windows Graphics Capture 支持 (Windows 10 1903 / 18362+)\n  runtime_info.is_capture_supported =\n      (version.major_version > 10) ||\n      (version.major_version == 10 && version.build_number >= 18362);\n  runtime_info.is_cursor_capture_control_supported =\n      Utils::Graphics::Capture::is_cursor_capture_control_supported();\n  runtime_info.is_border_control_supported =\n      Utils::Graphics::Capture::is_border_control_supported();\n\n  // Process Loopback 音频支持 (Windows 10 2004 / 19041+)\n  runtime_info.is_process_loopback_audio_supported =\n      Utils::Media::AudioCapture::is_process_loopback_supported();\n\n  Logger().info(\"Capture support: {}, cursor control: {}, border control: {}, process loopback: {}\",\n                runtime_info.is_capture_supported, runtime_info.is_cursor_capture_control_supported,\n                runtime_info.is_border_control_supported,\n                runtime_info.is_process_loopback_audio_supported);\n}\n\nauto collect_webview_info(RuntimeInfoState& runtime_info) -> void {\n  auto webview2_version = Core::WebView::get_runtime_version();\n  if (!webview2_version) {\n    runtime_info.is_webview2_available = false;\n    runtime_info.webview2_version.clear();\n    Logger().warn(\"WebView2 runtime not available: {}\", webview2_version.error());\n    return;\n  }\n\n  runtime_info.is_webview2_available = true;\n  runtime_info.webview2_version = webview2_version.value();\n  Logger().info(\"WebView2 runtime: {}\", runtime_info.webview2_version);\n}\n\n}  // namespace Core::RuntimeInfo::Detail\n\nnamespace Core::RuntimeInfo {\n\nauto collect(Core::State::AppState& app_state) -> void {\n  if (!app_state.runtime_info) {\n    return;\n  }\n\n  auto& runtime_info = *app_state.runtime_info;\n  runtime_info.is_debug_build = Vendor::BuildConfig::is_debug_build();\n  Detail::collect_app_version(runtime_info);\n  Detail::collect_os_and_capabilities(runtime_info);\n  Detail::collect_webview_info(runtime_info);\n}\n\n}  // namespace Core::RuntimeInfo\n"
  },
  {
    "path": "src/core/runtime_info/runtime_info.ixx",
    "content": "module;\n\nexport module Core.RuntimeInfo;\n\nimport Core.State;\n\nnamespace Core::RuntimeInfo {\n\n// 采集运行时信息并写入 state.runtime_info，同时输出关键日志\nexport auto collect(Core::State::AppState& app_state) -> void;\n\n}  // namespace Core::RuntimeInfo\n"
  },
  {
    "path": "src/core/shutdown/shutdown.cpp",
    "content": "module;\n\nmodule Core.Shutdown;\n\nimport std;\n\nimport Core.Async;\nimport Core.DialogService;\nimport Core.WorkerPool;\nimport Core.HttpServer;\nimport Core.HttpClient;\nimport Core.Commands;\nimport Core.State;\nimport Features.Letterbox;\nimport Features.Overlay;\nimport Features.Preview;\nimport Features.Screenshot;\nimport Features.WindowControl;\nimport Features.Recording.UseCase;\nimport Features.Update;\nimport Features.Update.State;\nimport Features.Gallery;\nimport Features.Gallery.State;\nimport Features.Gallery.Watcher;\nimport Features.Gallery.Recovery.Service;\nimport Extensions.InfinityNikki.PhotoService;\nimport UI.FloatingWindow;\nimport UI.FloatingWindow.State;\nimport UI.ContextMenu;\nimport UI.TrayIcon;\nimport UI.WebViewWindow;\nimport Utils.Logger;\n\nnamespace Core::Shutdown {\n\nauto shutdown_application(Core::State::AppState& state) -> void {\n  Logger().info(\"==================================================\");\n  Logger().info(\"SpinningMomo shutdown begin\");\n  Logger().info(\"==================================================\");\n\n  // 清理顺序应该与 Core::Initializer::initialize_application 中的初始化顺序相反\n\n  // 先停止录制并等待录制切换线程结束，避免与后续 UI/核心清理并发\n  Features::Recording::UseCase::stop_recording_if_running(state);\n\n  Core::Commands::uninstall_keyboard_keepalive_hook(state);\n\n  Features::WindowControl::stop_center_lock_monitor(state);\n\n  if (state.floating_window) {\n    Core::Commands::unregister_all_hotkeys(state, state.floating_window->window.hwnd);\n  }\n\n  Core::DialogService::stop(*state.dialog_service);\n\n  if (state.gallery) {\n    state.gallery->shutdown_requested.store(true, std::memory_order_release);\n    Features::Gallery::Watcher::wait_for_start_registered_watchers(state);\n  }\n\n  // 1. UI 清理\n  UI::ContextMenu::cleanup(state);\n  UI::TrayIcon::destroy(state);\n  UI::FloatingWindow::destroy_window(state);\n  UI::WebViewWindow::cleanup(state);\n\n  // 2. 功能模块清理\n  // 检查是否有待处理的更新\n  if (state.update && state.update->pending_update) {\n    Logger().info(\"Executing pending update on program exit\");\n    Features::Update::execute_pending_update(state);\n  }\n  Features::Preview::stop_preview(state);\n  Features::Preview::cleanup_preview(state);\n  Features::Overlay::stop_overlay(state);\n  Features::Overlay::cleanup_overlay(state);\n  Features::Gallery::Recovery::Service::persist_registered_root_checkpoints(state);\n  Extensions::InfinityNikki::PhotoService::shutdown(state);\n  Features::Gallery::Watcher::shutdown_watchers(state);\n  Features::Gallery::cleanup(state);\n  if (auto result = Features::Letterbox::shutdown(state); !result) {\n    Logger().error(\"Failed to shutdown Letterbox: {}\", result.error());\n  }\n  Features::Screenshot::cleanup_system(state);\n  // 3. 核心服务清理\n  Core::HttpServer::shutdown(state);\n  Core::HttpClient::shutdown(state);\n\n  // 停止工作线程池（等待所有任务完成）\n  Core::WorkerPool::stop(*state.worker_pool);\n\n  Core::Async::stop(*state.async);\n\n  Logger().info(\"==================================================\");\n  Logger().info(\"SpinningMomo shutdown complete\");\n  Logger().info(\"==================================================\");\n}\n\n}  // namespace Core::Shutdown\n"
  },
  {
    "path": "src/core/shutdown/shutdown.ixx",
    "content": "module;\n\nexport module Core.Shutdown;\n\nimport Core.State;\n\nnamespace Core::Shutdown {\n\nexport auto shutdown_application(Core::State::AppState& state) -> void;\n\n}\n"
  },
  {
    "path": "src/core/state/app_state.cpp",
    "content": "module;\n\nmodule Core.State;\n\nimport Core.Async.State;\nimport Core.Commands;\nimport Core.Commands.State;\nimport Core.Database.State;\nimport Core.DialogService.State;\nimport Core.Events.State;\nimport Core.HttpServer.State;\nimport Core.HttpClient.State;\nimport Core.I18n.State;\nimport Core.RPC.State;\nimport Core.State.RuntimeInfo;\nimport Core.WebView.State;\nimport Core.WorkerPool.State;\nimport Core.Tasks.State;\nimport Features.Letterbox.State;\nimport Features.Notifications.State;\nimport Features.Gallery.State;\nimport Features.Overlay.State;\nimport Features.Preview.State;\nimport Features.WindowControl.State;\nimport Features.Screenshot.State;\nimport Features.Recording.State;\nimport Features.ReplayBuffer.State;\nimport Features.Settings.State;\nimport Features.Update.State;\nimport UI.FloatingWindow.State;\nimport UI.ContextMenu.State;\nimport UI.TrayIcon.State;\n\nnamespace Core::State {\n\nAppState::AppState()\n    : rpc(std::make_unique<Core::RPC::State::RpcState>()),\n      async(std::make_unique<Core::Async::State::AsyncState>()),\n      dialog_service(std::make_unique<Core::DialogService::State::DialogServiceState>()),\n      events(std::make_unique<Core::Events::State::EventsState>()),\n      i18n(std::make_unique<Core::I18n::State::I18nState>()),\n      webview(std::make_unique<Core::WebView::State::WebViewState>()),\n      runtime_info(std::make_unique<Core::State::RuntimeInfo::RuntimeInfoState>()),\n      database(std::make_unique<Core::Database::State::DatabaseState>()),\n      http_server(std::make_unique<Core::HttpServer::State::HttpServerState>()),\n      http_client(std::make_unique<Core::HttpClient::State::HttpClientState>()),\n      worker_pool(std::make_unique<Core::WorkerPool::State::WorkerPoolState>()),\n      commands(std::make_unique<Core::Commands::State::CommandState>()),\n      tasks(std::make_unique<Core::Tasks::State::TaskState>()),\n      settings(std::make_unique<Features::Settings::State::SettingsState>()),\n      update(std::make_unique<Features::Update::State::UpdateState>()),\n      floating_window(std::make_unique<UI::FloatingWindow::State::FloatingWindowState>()),\n      tray_icon(std::make_unique<UI::TrayIcon::State::TrayIconState>()),\n      context_menu(std::make_unique<UI::ContextMenu::State::ContextMenuState>()),\n      letterbox(std::make_unique<Features::Letterbox::State::LetterboxState>()),\n      notifications(std::make_unique<Features::Notifications::State::NotificationSystemState>()),\n      gallery(std::make_unique<Features::Gallery::State::GalleryState>()),\n      overlay(std::make_unique<Features::Overlay::State::OverlayState>()),\n      preview(std::make_unique<Features::Preview::State::PreviewState>()),\n      window_control(std::make_unique<Features::WindowControl::State::WindowControlState>()),\n      screenshot(std::make_unique<Features::Screenshot::State::ScreenshotState>()),\n      recording(std::make_unique<Features::Recording::State::RecordingState>()),\n      replay_buffer(std::make_unique<Features::ReplayBuffer::State::ReplayBufferState>()) {}\n\nAppState::~AppState() = default;\n\n}  // namespace Core::State\n"
  },
  {
    "path": "src/core/state/app_state.ixx",
    "content": "module;\n\nexport module Core.State;\n\nimport std;\n\nnamespace Core::RPC::State {\nexport struct RpcState;\n}\n\nnamespace Core::Async::State {\nexport struct AsyncState;\n}\n\nnamespace Core::DialogService::State {\nexport struct DialogServiceState;\n}\n\nnamespace Core::Events::State {\nexport struct EventsState;\n}\n\nnamespace Core::I18n::State {\nexport struct I18nState;\n}\n\nnamespace Core::WebView::State {\nexport struct WebViewState;\n}\n\nnamespace Core::State::RuntimeInfo {\nexport struct RuntimeInfoState;\n}\n\nnamespace Features::Settings::State {\nexport struct SettingsState;\n}\n\nnamespace Features::Update::State {\nexport struct UpdateState;\n}\n\nnamespace UI::FloatingWindow::State {\nexport struct FloatingWindowState;\n}\n\nnamespace UI::TrayIcon::State {\nexport struct TrayIconState;\n}\n\nnamespace UI::ContextMenu::State {\nexport struct ContextMenuState;\n}\n\nnamespace Features::Letterbox::State {\nexport struct LetterboxState;\n}\n\nnamespace Features::Notifications::State {\nexport struct NotificationSystemState;\n}\n\nnamespace Features::Gallery::State {\nexport struct GalleryState;\n}\n\nnamespace Features::Overlay::State {\nexport struct OverlayState;\n}\n\nnamespace Features::Preview::State {\nexport struct PreviewState;\n}\n\nnamespace Features::WindowControl::State {\nexport struct WindowControlState;\n}\n\nnamespace Features::Screenshot::State {\nexport struct ScreenshotState;\n}\n\nnamespace Features::Recording::State {\nexport struct RecordingState;\n}\n\nnamespace Features::ReplayBuffer::State {\nexport struct ReplayBufferState;\n}\n\nnamespace Core::HttpServer::State {\nexport struct HttpServerState;\n}\n\nnamespace Core::HttpClient::State {\nexport struct HttpClientState;\n}\n\nnamespace Core::Database::State {\nexport struct DatabaseState;\n}\n\nnamespace Core::WorkerPool::State {\nexport struct WorkerPoolState;\n}\n\nnamespace Core::Commands::State {\nexport struct CommandState;\n}\n\nnamespace Core::Tasks::State {\nexport struct TaskState;\n}\n\nnamespace Core::State {\n\nexport struct AppState {\n  AppState();\n  ~AppState();\n\n  // 应用级状态\n  std::unique_ptr<Core::RPC::State::RpcState> rpc;\n  std::unique_ptr<Core::Async::State::AsyncState> async;\n  std::unique_ptr<Core::DialogService::State::DialogServiceState> dialog_service;\n  std::unique_ptr<Core::Events::State::EventsState> events;\n  std::unique_ptr<Core::I18n::State::I18nState> i18n;\n  std::unique_ptr<Core::WebView::State::WebViewState> webview;\n  std::unique_ptr<Core::State::RuntimeInfo::RuntimeInfoState> runtime_info;\n  std::unique_ptr<Core::Database::State::DatabaseState> database;\n  std::unique_ptr<Core::HttpServer::State::HttpServerState> http_server;\n  std::unique_ptr<Core::HttpClient::State::HttpClientState> http_client;\n  std::unique_ptr<Core::WorkerPool::State::WorkerPoolState> worker_pool;\n  std::unique_ptr<Core::Commands::State::CommandState> commands;\n  std::unique_ptr<Core::Tasks::State::TaskState> tasks;\n\n  // 应用设置状态（包含配置和计算状态）\n  std::unique_ptr<Features::Settings::State::SettingsState> settings;\n\n  // 更新模块状态\n  std::unique_ptr<Features::Update::State::UpdateState> update;\n\n  // UI状态\n  std::unique_ptr<UI::FloatingWindow::State::FloatingWindowState> floating_window;\n  std::unique_ptr<UI::TrayIcon::State::TrayIconState> tray_icon;\n  std::unique_ptr<UI::ContextMenu::State::ContextMenuState> context_menu;\n\n  // 功能模块状态\n  std::unique_ptr<Features::Letterbox::State::LetterboxState> letterbox;\n  std::unique_ptr<Features::Notifications::State::NotificationSystemState> notifications;\n  std::unique_ptr<Features::Gallery::State::GalleryState> gallery;\n  std::unique_ptr<Features::Overlay::State::OverlayState> overlay;\n  std::unique_ptr<Features::Preview::State::PreviewState> preview;\n  std::unique_ptr<Features::WindowControl::State::WindowControlState> window_control;\n  std::unique_ptr<Features::Screenshot::State::ScreenshotState> screenshot;\n  std::unique_ptr<Features::Recording::State::RecordingState> recording;\n  std::unique_ptr<Features::ReplayBuffer::State::ReplayBufferState> replay_buffer;\n};\n\n}  // namespace Core::State\n"
  },
  {
    "path": "src/core/state/runtime_info.ixx",
    "content": "module;\n\nexport module Core.State.RuntimeInfo;\n\nimport std;\n\nnamespace Core::State::RuntimeInfo {\n\n// 应用基本信息结构\nexport struct RuntimeInfoState {\n  // 版本信息\n  std::string version;\n  unsigned int major_version = 0;\n  unsigned int minor_version = 0;\n  unsigned int patch_version = 0;\n  unsigned int build_number = 0;\n\n  // 系统信息\n  std::string os_name;\n  unsigned int os_major_version = 0;\n  unsigned int os_minor_version = 0;\n  unsigned int os_build_number = 0;\n\n  // WebView2 运行时信息\n  bool is_webview2_available = false;\n  std::string webview2_version;\n  bool is_debug_build = false;\n\n  // Windows Graphics Capture 支持状态 (Windows 10 1903 build 18362 or later)\n  bool is_capture_supported = false;\n  bool is_cursor_capture_control_supported = false;  // IsCursorCaptureEnabled\n  bool is_border_control_supported = false;          // IsBorderRequired (黄色边框)\n\n  // Process Loopback 支持状态 (Windows 10 2004 build 19041 or later)\n  bool is_process_loopback_audio_supported = false;\n};\n\n}  // namespace Core::State::RuntimeInfo\n"
  },
  {
    "path": "src/core/tasks/state.ixx",
    "content": "module;\n\nexport module Core.Tasks.State;\n\nimport std;\n\nnamespace Core::Tasks::State {\n\nexport struct TaskProgress {\n  std::string stage;\n  std::int64_t current = 0;\n  std::int64_t total = 0;\n  std::optional<double> percent;\n  std::optional<std::string> message;\n};\n\nexport struct TaskSnapshot {\n  std::string task_id;\n  std::string type;\n  std::string status = \"queued\";\n  std::int64_t created_at = 0;\n  std::optional<std::int64_t> started_at;\n  std::optional<std::int64_t> finished_at;\n  std::optional<TaskProgress> progress;\n  std::optional<std::string> error_message;\n  std::optional<std::string> context;\n};\n\nexport struct TaskState {\n  std::unordered_map<std::string, TaskSnapshot> tasks;\n  std::deque<std::string> order;  // 旧 -> 新\n  std::mutex mutex;\n  std::uint64_t next_task_id = 1;\n  size_t history_limit = 30;\n};\n\n}  // namespace Core::Tasks::State\n"
  },
  {
    "path": "src/core/tasks/tasks.cpp",
    "content": "module;\n\nmodule Core.Tasks;\n\nimport std;\nimport Core.State;\nimport Core.Tasks.State;\nimport Core.RPC.NotificationHub;\nimport Utils.Logger;\nimport <rfl/json.hpp>;\n\nnamespace Core::Tasks {\n\nauto now_millis() -> std::int64_t {\n  return std::chrono::duration_cast<std::chrono::milliseconds>(\n             std::chrono::system_clock::now().time_since_epoch())\n      .count();\n}\n\nauto is_task_active(const std::string& status) -> bool {\n  return status == \"queued\" || status == \"running\";\n}\n\nauto emit_task_updated(Core::State::AppState& state, const TaskSnapshot& snapshot) -> void {\n  auto params_json = rfl::json::write<rfl::SnakeCaseToCamelCase>(snapshot);\n  Core::RPC::NotificationHub::send_notification(state, \"task.updated\", params_json);\n}\n\nauto prune_history_unlocked(State::TaskState& task_state) -> void {\n  while (task_state.order.size() > task_state.history_limit) {\n    const auto& oldest_id = task_state.order.front();\n    auto it = task_state.tasks.find(oldest_id);\n    if (it == task_state.tasks.end()) {\n      task_state.order.pop_front();\n      continue;\n    }\n\n    // 仍在运行中的任务不裁剪，避免列表状态突然丢失。\n    if (is_task_active(it->second.status)) {\n      break;\n    }\n\n    task_state.tasks.erase(it);\n    task_state.order.pop_front();\n  }\n}\n\nauto create_task(Core::State::AppState& state, const std::string& type,\n                 const std::optional<std::string>& context) -> std::string {\n  if (!state.tasks) {\n    Logger().error(\"TaskState is not initialized\");\n    return \"\";\n  }\n\n  TaskSnapshot snapshot;\n  {\n    std::lock_guard<std::mutex> lock(state.tasks->mutex);\n\n    auto task_id = std::format(\"task_{}_{}\", now_millis(), state.tasks->next_task_id++);\n    snapshot.task_id = task_id;\n    snapshot.type = type;\n    snapshot.status = \"queued\";\n    snapshot.created_at = now_millis();\n    snapshot.context = context;\n\n    state.tasks->tasks[task_id] = snapshot;\n    state.tasks->order.push_back(task_id);\n    prune_history_unlocked(*state.tasks);\n  }\n\n  emit_task_updated(state, snapshot);\n  return snapshot.task_id;\n}\n\nauto has_active_task_of_type(Core::State::AppState& state, const std::string& type) -> bool {\n  return find_active_task_of_type(state, type).has_value();\n}\n\nauto find_active_task_of_type(Core::State::AppState& state, const std::string& type)\n    -> std::optional<TaskSnapshot> {\n  if (!state.tasks) {\n    return std::nullopt;\n  }\n\n  std::lock_guard<std::mutex> lock(state.tasks->mutex);\n  // 反向遍历以优先返回最新创建的活跃任务\n  for (auto it = state.tasks->order.rbegin(); it != state.tasks->order.rend(); ++it) {\n    auto task_it = state.tasks->tasks.find(*it);\n    if (task_it == state.tasks->tasks.end()) {\n      continue;\n    }\n\n    const auto& snapshot = task_it->second;\n    if (snapshot.type == type && is_task_active(snapshot.status)) {\n      return snapshot;\n    }\n  }\n\n  return std::nullopt;\n}\n\nauto mark_task_running(Core::State::AppState& state, const std::string& task_id) -> bool {\n  if (!state.tasks) {\n    return false;\n  }\n\n  std::optional<TaskSnapshot> snapshot;\n  {\n    std::lock_guard<std::mutex> lock(state.tasks->mutex);\n    auto it = state.tasks->tasks.find(task_id);\n    if (it == state.tasks->tasks.end()) {\n      return false;\n    }\n\n    auto& task = it->second;\n    task.status = \"running\";\n    task.started_at = now_millis();\n    task.finished_at.reset();\n    task.error_message.reset();\n    snapshot = task;\n  }\n\n  emit_task_updated(state, snapshot.value());\n  return true;\n}\n\nauto update_task_progress(Core::State::AppState& state, const std::string& task_id,\n                          const TaskProgress& progress) -> bool {\n  if (!state.tasks) {\n    return false;\n  }\n\n  std::optional<TaskSnapshot> snapshot;\n  {\n    std::lock_guard<std::mutex> lock(state.tasks->mutex);\n    auto it = state.tasks->tasks.find(task_id);\n    if (it == state.tasks->tasks.end()) {\n      return false;\n    }\n\n    auto& task = it->second;\n    if (task.status == \"succeeded\" || task.status == \"failed\" || task.status == \"cancelled\") {\n      return false;\n    }\n\n    if (task.status == \"queued\") {\n      task.status = \"running\";\n      task.started_at = now_millis();\n    }\n\n    task.progress = progress;\n    snapshot = task;\n  }\n\n  emit_task_updated(state, snapshot.value());\n  return true;\n}\n\nauto complete_task_success(Core::State::AppState& state, const std::string& task_id) -> bool {\n  if (!state.tasks) {\n    return false;\n  }\n\n  std::optional<TaskSnapshot> snapshot;\n  {\n    std::lock_guard<std::mutex> lock(state.tasks->mutex);\n    auto it = state.tasks->tasks.find(task_id);\n    if (it == state.tasks->tasks.end()) {\n      return false;\n    }\n\n    auto& task = it->second;\n    task.status = \"succeeded\";\n    if (!task.started_at.has_value()) {\n      task.started_at = task.created_at;\n    }\n    task.finished_at = now_millis();\n    task.error_message.reset();\n    if (task.progress.has_value() && !task.progress->percent.has_value()) {\n      task.progress->percent = 100.0;\n    }\n    snapshot = task;\n    prune_history_unlocked(*state.tasks);\n  }\n\n  emit_task_updated(state, snapshot.value());\n  return true;\n}\n\nauto complete_task_failed(Core::State::AppState& state, const std::string& task_id,\n                          const std::string& error_message) -> bool {\n  if (!state.tasks) {\n    return false;\n  }\n\n  std::optional<TaskSnapshot> snapshot;\n  {\n    std::lock_guard<std::mutex> lock(state.tasks->mutex);\n    auto it = state.tasks->tasks.find(task_id);\n    if (it == state.tasks->tasks.end()) {\n      return false;\n    }\n\n    auto& task = it->second;\n    task.status = \"failed\";\n    if (!task.started_at.has_value()) {\n      task.started_at = task.created_at;\n    }\n    task.finished_at = now_millis();\n    task.error_message = error_message;\n    snapshot = task;\n    prune_history_unlocked(*state.tasks);\n  }\n\n  emit_task_updated(state, snapshot.value());\n  return true;\n}\n\nauto list_tasks(Core::State::AppState& state) -> std::vector<TaskSnapshot> {\n  if (!state.tasks) {\n    return {};\n  }\n\n  std::vector<TaskSnapshot> result;\n  std::lock_guard<std::mutex> lock(state.tasks->mutex);\n  result.reserve(state.tasks->order.size());\n\n  for (auto it = state.tasks->order.rbegin(); it != state.tasks->order.rend(); ++it) {\n    if (auto task_it = state.tasks->tasks.find(*it); task_it != state.tasks->tasks.end()) {\n      result.push_back(task_it->second);\n    }\n  }\n\n  return result;\n}\n\nauto clear_finished_tasks(Core::State::AppState& state) -> std::size_t {\n  if (!state.tasks) {\n    return 0;\n  }\n\n  std::size_t cleared_count = 0;\n  std::lock_guard<std::mutex> lock(state.tasks->mutex);\n\n  std::deque<std::string> active_order;\n  for (const auto& task_id : state.tasks->order) {\n    auto it = state.tasks->tasks.find(task_id);\n    if (it == state.tasks->tasks.end()) {\n      continue;\n    }\n\n    if (is_task_active(it->second.status)) {\n      active_order.push_back(task_id);\n      continue;\n    }\n\n    state.tasks->tasks.erase(it);\n    cleared_count++;\n  }\n\n  state.tasks->order = std::move(active_order);\n  return cleared_count;\n}\n\n}  // namespace Core::Tasks\n"
  },
  {
    "path": "src/core/tasks/tasks.ixx",
    "content": "module;\n\nexport module Core.Tasks;\n\nimport std;\nimport Core.State;\nimport Core.Tasks.State;\n\nnamespace Core::Tasks {\n\nexport using TaskProgress = Core::Tasks::State::TaskProgress;\nexport using TaskSnapshot = Core::Tasks::State::TaskSnapshot;\n\nexport auto create_task(Core::State::AppState& state, const std::string& type,\n                        const std::optional<std::string>& context = std::nullopt) -> std::string;\n\nexport auto has_active_task_of_type(Core::State::AppState& state, const std::string& type) -> bool;\n\nexport auto find_active_task_of_type(Core::State::AppState& state, const std::string& type)\n    -> std::optional<TaskSnapshot>;\n\nexport auto mark_task_running(Core::State::AppState& state, const std::string& task_id) -> bool;\n\nexport auto update_task_progress(Core::State::AppState& state, const std::string& task_id,\n                                 const TaskProgress& progress) -> bool;\n\nexport auto complete_task_success(Core::State::AppState& state, const std::string& task_id) -> bool;\n\nexport auto complete_task_failed(Core::State::AppState& state, const std::string& task_id,\n                                 const std::string& error_message) -> bool;\n\nexport auto list_tasks(Core::State::AppState& state) -> std::vector<TaskSnapshot>;\n\nexport auto clear_finished_tasks(Core::State::AppState& state) -> std::size_t;\n\n}  // namespace Core::Tasks\n"
  },
  {
    "path": "src/core/webview/events.ixx",
    "content": "module;\n\nexport module Core.WebView.Events;\n\nimport std;\n\nnamespace Core::WebView::Events {\n\n// WebView响应事件\nexport struct WebViewResponseEvent {\n  std::string response;\n  \n  std::chrono::steady_clock::time_point timestamp = std::chrono::steady_clock::now();\n};\n\n} // namespace Core::WebView::Events"
  },
  {
    "path": "src/core/webview/host.cpp",
    "content": "module;\n\n#include <wil/com.h>\n\n#include <WebView2.h>  // 必须放最后面\n\nmodule Core.WebView.Host;\n\nimport std;\nimport Core.State;\nimport Core.WebView.RpcBridge;\nimport Core.WebView.State;\nimport Core.WebView.Static;\nimport Features.Settings.State;\nimport Utils.Logger;\nimport Utils.Path;\nimport Utils.String;\nimport Vendor.BuildConfig;\nimport Vendor.ShellApi;\nimport Vendor.WIL;\nimport <d3d11.h>;\nimport <dcomp.h>;\nimport <dxgi.h>;\nimport <rfl/json.hpp>;\nimport <windows.h>;\nimport <wrl.h>;\n\nnamespace Core::WebView::Host::Detail {\n\nstruct DirectWindowBridgeMessage {\n  std::string type;\n  std::optional<std::string> edge;\n};\n\nauto clear_host_runtime(Core::WebView::State::HostRuntime& host_runtime) -> void {\n  host_runtime.dcomp_root_visual.reset();\n  host_runtime.dcomp_target.reset();\n  host_runtime.dcomp_device.reset();\n  host_runtime.d3d_device.reset();\n}\n\nauto resize_edge_to_wmsz(std::string_view edge) -> std::optional<WPARAM> {\n  if (edge == \"left\") {\n    return WMSZ_LEFT;\n  }\n  if (edge == \"right\") {\n    return WMSZ_RIGHT;\n  }\n  if (edge == \"top\") {\n    return WMSZ_TOP;\n  }\n  if (edge == \"topLeft\") {\n    return WMSZ_TOPLEFT;\n  }\n  if (edge == \"topRight\") {\n    return WMSZ_TOPRIGHT;\n  }\n  if (edge == \"bottom\") {\n    return WMSZ_BOTTOM;\n  }\n  if (edge == \"bottomLeft\") {\n    return WMSZ_BOTTOMLEFT;\n  }\n  if (edge == \"bottomRight\") {\n    return WMSZ_BOTTOMRIGHT;\n  }\n  return std::nullopt;\n}\n\nauto try_handle_direct_window_bridge_message(Core::State::AppState& state,\n                                             const std::string& message) -> bool {\n  auto bridge_message_result = rfl::json::read<DirectWindowBridgeMessage>(message);\n  if (!bridge_message_result) {\n    return false;\n  }\n\n  const auto& bridge_message = bridge_message_result.value();\n  if (bridge_message.type != \"window.beginResize\") {\n    return false;\n  }\n\n  auto hwnd = state.webview ? state.webview->window.webview_hwnd : nullptr;\n  if (!hwnd) {\n    Logger().warn(\"Ignored window.beginResize bridge message: WebView window not created\");\n    return true;\n  }\n\n  if (!bridge_message.edge) {\n    Logger().warn(\"Ignored window.beginResize bridge message: missing edge\");\n    return true;\n  }\n\n  auto resize_edge = resize_edge_to_wmsz(*bridge_message.edge);\n  if (!resize_edge) {\n    Logger().warn(\"Ignored window.beginResize bridge message: unsupported edge {}\",\n                  *bridge_message.edge);\n    return true;\n  }\n\n  if (state.webview->window.is_fullscreen || IsZoomed(hwnd) == TRUE) {\n    return true;\n  }\n\n  if (!PostMessageW(hwnd, Core::WebView::State::kWM_APP_BEGIN_RESIZE, *resize_edge, 0)) {\n    Logger().warn(\"Failed to post deferred resize message for edge: {}\", *bridge_message.edge);\n    return true;\n  }\n\n  Logger().debug(\"WebView window deferred resize request from edge: {}\", *bridge_message.edge);\n  return true;\n}\n\nauto create_composition_host(HWND hwnd, Core::WebView::State::HostRuntime& host_runtime)\n    -> std::expected<void, std::string> {\n  clear_host_runtime(host_runtime);\n\n  UINT d3d_flags = D3D11_CREATE_DEVICE_BGRA_SUPPORT;\n#ifdef _DEBUG\n  d3d_flags |= D3D11_CREATE_DEVICE_DEBUG;\n#endif\n\n  wil::com_ptr<ID3D11DeviceContext> d3d_context;\n  HRESULT hr = D3D11CreateDevice(nullptr, D3D_DRIVER_TYPE_HARDWARE, nullptr, d3d_flags, nullptr, 0,\n                                 D3D11_SDK_VERSION, host_runtime.d3d_device.put(), nullptr,\n                                 d3d_context.put());\n  if (FAILED(hr)) {\n    return std::unexpected(\n        std::format(\"Failed to create D3D11 device for WebView composition: 0x{:08X}\",\n                    static_cast<unsigned>(hr)));\n  }\n\n  wil::com_ptr<IDXGIDevice> dxgi_device;\n  hr = host_runtime.d3d_device->QueryInterface(IID_PPV_ARGS(&dxgi_device));\n  if (FAILED(hr)) {\n    return std::unexpected(\n        std::format(\"Failed to query IDXGIDevice for WebView composition: 0x{:08X}\",\n                    static_cast<unsigned>(hr)));\n  }\n\n  hr = DCompositionCreateDevice(dxgi_device.get(), IID_PPV_ARGS(&host_runtime.dcomp_device));\n  if (FAILED(hr)) {\n    return std::unexpected(\n        std::format(\"Failed to create DComposition device: 0x{:08X}\", static_cast<unsigned>(hr)));\n  }\n\n  hr = host_runtime.dcomp_device->CreateTargetForHwnd(hwnd, TRUE, host_runtime.dcomp_target.put());\n  if (FAILED(hr)) {\n    return std::unexpected(\n        std::format(\"Failed to create DComposition target for WebView window: 0x{:08X}\",\n                    static_cast<unsigned>(hr)));\n  }\n\n  hr = host_runtime.dcomp_device->CreateVisual(host_runtime.dcomp_root_visual.put());\n  if (FAILED(hr)) {\n    return std::unexpected(std::format(\"Failed to create DComposition root visual: 0x{:08X}\",\n                                       static_cast<unsigned>(hr)));\n  }\n\n  hr = host_runtime.dcomp_target->SetRoot(host_runtime.dcomp_root_visual.get());\n  if (FAILED(hr)) {\n    return std::unexpected(\n        std::format(\"Failed to set DComposition root visual: 0x{:08X}\", static_cast<unsigned>(hr)));\n  }\n\n  hr = host_runtime.dcomp_device->Commit();\n  if (FAILED(hr)) {\n    return std::unexpected(std::format(\"Failed to commit initial DComposition tree: 0x{:08X}\",\n                                       static_cast<unsigned>(hr)));\n  }\n\n  return {};\n}\n\nauto is_system_light_theme() -> bool {\n  DWORD value = 0;\n  DWORD value_size = sizeof(value);\n  auto status = RegGetValueW(HKEY_CURRENT_USER,\n                             L\"Software\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\Themes\\\\Personalize\",\n                             L\"AppsUseLightTheme\", RRF_RT_REG_DWORD, nullptr, &value, &value_size);\n\n  if (status != ERROR_SUCCESS) {\n    Logger().warn(\"Failed to read system theme from registry, fallback to dark mode\");\n    return false;\n  }\n\n  return value != 0;\n}\n\nauto resolve_opaque_background_color(std::string_view theme_mode) -> COREWEBVIEW2_COLOR {\n  // Match web/src/index.css surface-bottom colors for light/dark themes.\n  // COREWEBVIEW2_COLOR field order is A, R, G, B.\n  constexpr COREWEBVIEW2_COLOR light_color{255, 239, 239, 239};  // #efefef\n  constexpr COREWEBVIEW2_COLOR dark_color{255, 25, 25, 25};      // #191919\n\n  if (theme_mode == \"light\") return light_color;\n  if (theme_mode == \"dark\") return dark_color;\n\n  return is_system_light_theme() ? light_color : dark_color;\n}\n\nauto read_background_mode(Core::State::AppState& state) -> std::pair<bool, std::string> {\n  bool transparent_enabled = false;\n  std::string theme_mode = \"system\";\n  if (state.settings) {\n    transparent_enabled = state.settings->raw.ui.webview_window.enable_transparent_background;\n    theme_mode = state.settings->raw.ui.web_theme.mode;\n  }\n  return {transparent_enabled, std::move(theme_mode)};\n}\n\nauto use_composition_host_from_settings(Core::State::AppState& state) -> bool {\n  if (!state.settings) {\n    return false;\n  }\n  return state.settings->raw.ui.webview_window.enable_transparent_background;\n}\n\nauto apply_default_background(ICoreWebView2Controller* controller, bool transparent_enabled,\n                              std::string_view theme_mode) -> void {\n  wil::com_ptr<ICoreWebView2Controller2> controller2;\n  if (FAILED(controller->QueryInterface(IID_PPV_ARGS(&controller2))) || !controller2) {\n    Logger().warn(\"ICoreWebView2Controller2 unavailable, WebView background not applied\");\n    return;\n  }\n\n  auto color = transparent_enabled ? COREWEBVIEW2_COLOR{0, 0, 0, 0}\n                                   : resolve_opaque_background_color(theme_mode);\n  auto hr = controller2->put_DefaultBackgroundColor(color);\n  if (FAILED(hr)) {\n    Logger().warn(\"Failed to apply WebView default background color: {}\", hr);\n    return;\n  }\n\n  Logger().info(\"WebView2 default background updated: transparent={}, alpha={}\",\n                transparent_enabled ? \"true\" : \"false\", static_cast<int>(color.A));\n}\n\nauto is_http_or_https_uri(std::wstring_view uri) -> bool {\n  auto starts_with_icase = [](std::wstring_view hay, std::wstring_view needle) -> bool {\n    if (hay.size() < needle.size()) {\n      return false;\n    }\n    for (size_t i = 0; i < needle.size(); ++i) {\n      wchar_t a = hay[i];\n      wchar_t b = needle[i];\n      if (a >= L'A' && a <= L'Z') {\n        a = static_cast<wchar_t>(a - L'A' + L'a');\n      }\n      if (b >= L'A' && b <= L'Z') {\n        b = static_cast<wchar_t>(b - L'A' + L'a');\n      }\n      if (a != b) {\n        return false;\n      }\n    }\n    return true;\n  };\n  return starts_with_icase(uri, L\"https://\") || starts_with_icase(uri, L\"http://\");\n}\n\nclass NavigationStartingEventHandler\n    : public Microsoft::WRL::RuntimeClass<\n          Microsoft::WRL::RuntimeClassFlags<Microsoft::WRL::ClassicCom>,\n          ICoreWebView2NavigationStartingEventHandler> {\n private:\n  Core::State::AppState* m_state;\n\n public:\n  explicit NavigationStartingEventHandler(Core::State::AppState* state) : m_state(state) {}\n\n  HRESULT STDMETHODCALLTYPE Invoke(ICoreWebView2* sender,\n                                   ICoreWebView2NavigationStartingEventArgs* args) {\n    (void)sender;\n    (void)args;\n    if (m_state) {\n      Logger().debug(\"WebView navigation starting\");\n    }\n    return S_OK;\n  }\n};\n\nclass NavigationCompletedEventHandler\n    : public Microsoft::WRL::RuntimeClass<\n          Microsoft::WRL::RuntimeClassFlags<Microsoft::WRL::ClassicCom>,\n          ICoreWebView2NavigationCompletedEventHandler> {\n private:\n  Core::State::AppState* m_state;\n\n public:\n  explicit NavigationCompletedEventHandler(Core::State::AppState* state) : m_state(state) {}\n\n  HRESULT STDMETHODCALLTYPE Invoke(ICoreWebView2* sender,\n                                   ICoreWebView2NavigationCompletedEventArgs* args) {\n    (void)sender;\n    if (!m_state) return S_OK;\n\n    BOOL success;\n    args->get_IsSuccess(&success);\n\n    if (success) {\n      auto& webview_state = *m_state->webview;\n      if (!webview_state.has_initial_content) {\n        webview_state.has_initial_content = true;\n        if (auto hwnd = webview_state.window.webview_hwnd; hwnd) {\n          InvalidateRect(hwnd, nullptr, TRUE);\n        }\n      }\n      Logger().info(\"WebView navigation completed successfully\");\n    } else {\n      COREWEBVIEW2_WEB_ERROR_STATUS error;\n      args->get_WebErrorStatus(&error);\n      Logger().error(\"WebView navigation failed with error: {}\", static_cast<int>(error));\n    }\n\n    return S_OK;\n  }\n};\n\nauto setup_navigation_events(Core::State::AppState* state, ICoreWebView2* webview,\n                             Core::WebView::State::CoreResources& resources) -> HRESULT {\n  if (!state || !webview) return E_FAIL;\n\n  auto navigation_starting_handler = Microsoft::WRL::Make<NavigationStartingEventHandler>(state);\n  HRESULT hr = webview->add_NavigationStarting(navigation_starting_handler.Get(),\n                                               &resources.navigation_starting_token);\n  if (FAILED(hr)) {\n    Logger().error(\"Failed to register NavigationStarting event: {}\", hr);\n    return hr;\n  }\n\n  auto navigation_completed_handler = Microsoft::WRL::Make<NavigationCompletedEventHandler>(state);\n  hr = webview->add_NavigationCompleted(navigation_completed_handler.Get(),\n                                        &resources.navigation_completed_token);\n  if (FAILED(hr)) {\n    Logger().error(\"Failed to register NavigationCompleted event: {}\", hr);\n    return hr;\n  }\n\n  Logger().debug(\"Navigation events registered successfully\");\n  return S_OK;\n}\n\nauto setup_new_window_requested(Core::State::AppState* state, ICoreWebView2* webview,\n                                Core::WebView::State::CoreResources& resources) -> HRESULT {\n  if (!state || !webview) return E_FAIL;\n  (void)state;\n\n  auto handler = Microsoft::WRL::Callback<ICoreWebView2NewWindowRequestedEventHandler>(\n      [](ICoreWebView2* sender, ICoreWebView2NewWindowRequestedEventArgs* args) -> HRESULT {\n        (void)sender;\n\n        BOOL user_initiated = FALSE;\n        if (FAILED(args->get_IsUserInitiated(&user_initiated))) {\n          args->put_Handled(TRUE);\n          return S_OK;\n        }\n        if (!user_initiated) {\n          args->put_Handled(TRUE);\n          return S_OK;\n        }\n\n        wil::unique_cotaskmem_string uri_raw;\n        if (FAILED(args->get_Uri(&uri_raw)) || !uri_raw) {\n          args->put_Handled(TRUE);\n          return S_OK;\n        }\n\n        if (!is_http_or_https_uri(uri_raw.get())) {\n          Logger().warn(\"NewWindowRequested ignored non-http(s) URI: {}\",\n                        Utils::String::ToUtf8(uri_raw.get()));\n          args->put_Handled(TRUE);\n          return S_OK;\n        }\n\n        Vendor::ShellApi::SHELLEXECUTEINFOW exec_info{\n            .cbSize = sizeof(exec_info),\n            .fMask = Vendor::ShellApi::kSEE_MASK_NOASYNC,\n            .lpVerb = L\"open\",\n            .lpFile = uri_raw.get(),\n            .nShow = Vendor::ShellApi::kSW_SHOWNORMAL,\n        };\n        if (!Vendor::ShellApi::ShellExecuteExW(&exec_info)) {\n          Logger().warn(\"ShellExecuteExW failed for NewWindowRequested URI: {}\",\n                        Utils::String::ToUtf8(uri_raw.get()));\n        }\n        args->put_Handled(TRUE);\n        return S_OK;\n      });\n\n  HRESULT hr =\n      webview->add_NewWindowRequested(handler.Get(), &resources.new_window_requested_token);\n  if (FAILED(hr)) {\n    Logger().error(\"Failed to register NewWindowRequested event: {}\", hr);\n    return hr;\n  }\n\n  Logger().debug(\"NewWindowRequested handler registered successfully\");\n  return S_OK;\n}\n\nauto setup_message_handler(Core::State::AppState* state, ICoreWebView2* webview,\n                           Core::WebView::State::CoreResources& resources) -> HRESULT {\n  if (!state || !webview) return E_FAIL;\n\n  auto message_handler = Core::WebView::RpcBridge::create_message_handler(*state);\n\n  auto webview_message_handler =\n      Microsoft::WRL::Callback<ICoreWebView2WebMessageReceivedEventHandler>(\n          [state, message_handler](ICoreWebView2* sender,\n                                   ICoreWebView2WebMessageReceivedEventArgs* args) -> HRESULT {\n            (void)sender;\n            try {\n              LPWSTR message_raw;\n              HRESULT hr = args->get_WebMessageAsJson(&message_raw);\n\n              if (SUCCEEDED(hr) && message_raw) {\n                std::string message = Utils::String::ToUtf8(message_raw);\n                if (!Detail::try_handle_direct_window_bridge_message(*state, message)) {\n                  message_handler(message);\n                }\n                CoTaskMemFree(message_raw);\n              }\n\n              return S_OK;\n            } catch (const std::exception& e) {\n              Logger().error(\"Error in WebView message handler: {}\", e.what());\n              return E_FAIL;\n            }\n          });\n\n  HRESULT hr = webview->add_WebMessageReceived(webview_message_handler.Get(),\n                                               &resources.web_message_received_token);\n  if (FAILED(hr)) {\n    Logger().error(\"Failed to register WebMessageReceived handler: {}\", hr);\n    return hr;\n  }\n\n  Logger().debug(\"Message handler registered successfully\");\n  return S_OK;\n}\n\nauto setup_virtual_host_mapping(ICoreWebView2* webview, Core::WebView::State::WebViewConfig& config)\n    -> HRESULT {\n  if (!webview) return E_FAIL;\n\n  auto dist_path_result = Utils::Path::GetEmbeddedWebRootDirectory();\n  if (!dist_path_result) {\n    Logger().error(\"Failed to resolve embedded web root: {}\", dist_path_result.error());\n    return E_FAIL;\n  }\n\n  auto dist_path = dist_path_result.value().wstring();\n\n  wil::com_ptr<ICoreWebView2_3> webview3;\n  HRESULT hr = webview->QueryInterface(IID_PPV_ARGS(&webview3));\n  if (FAILED(hr)) {\n    Logger().error(\"Failed to query ICoreWebView2_3 interface: {}\", hr);\n    return hr;\n  }\n\n  if (!webview3) {\n    Logger().error(\"ICoreWebView2_3 interface not available\");\n    return E_NOINTERFACE;\n  }\n\n  hr = webview3->SetVirtualHostNameToFolderMapping(\n      config.virtual_host_name.c_str(), dist_path.c_str(),\n      COREWEBVIEW2_HOST_RESOURCE_ACCESS_KIND_DENY_CORS);\n  if (FAILED(hr)) {\n    Logger().error(\"Failed to set virtual host mapping: {}\", hr);\n    return hr;\n  }\n\n  Logger().info(\"Virtual host mapping established: {} -> {}\",\n                Utils::String::ToUtf8(config.virtual_host_name), Utils::String::ToUtf8(dist_path));\n  return S_OK;\n}\n\nauto apply_registered_virtual_host_folder_mappings(Core::State::AppState& state,\n                                                   ICoreWebView2* webview) -> HRESULT {\n  // 把“WebView 创建前就登记好的 root host 映射”重新应用到真实 WebView 实例上。\n  // 这样无论 root watch 先恢复还是 WebView 先创建，最终状态都能收敛一致。\n  if (!webview) return E_FAIL;\n\n  wil::com_ptr<ICoreWebView2_3> webview3;\n  auto hr = webview->QueryInterface(IID_PPV_ARGS(&webview3));\n  if (FAILED(hr)) {\n    Logger().warn(\"Failed to query ICoreWebView2_3 for registered virtual host mappings: {}\", hr);\n    return hr;\n  }\n  if (!webview3) {\n    Logger().warn(\"ICoreWebView2_3 interface not available for registered virtual host mappings\");\n    return E_NOINTERFACE;\n  }\n\n  std::vector<std::pair<std::wstring, Core::WebView::State::VirtualHostFolderMapping>> mappings;\n  {\n    std::lock_guard<std::mutex> lock(state.webview->resources.virtual_host_folder_mappings_mutex);\n    for (const auto& [host_name, mapping] : state.webview->resources.virtual_host_folder_mappings) {\n      mappings.emplace_back(host_name, mapping);\n    }\n    state.webview->resources.applied_virtual_host_folder_mappings.clear();\n  }\n\n  for (const auto& [host_name, mapping] : mappings) {\n    hr = webview3->SetVirtualHostNameToFolderMapping(\n        host_name.c_str(), mapping.folder_path.c_str(),\n        static_cast<COREWEBVIEW2_HOST_RESOURCE_ACCESS_KIND>(mapping.access_kind));\n    if (FAILED(hr)) {\n      Logger().warn(\"Failed to restore virtual host mapping {} -> {}: {}\",\n                    Utils::String::ToUtf8(host_name), Utils::String::ToUtf8(mapping.folder_path),\n                    hr);\n      continue;\n    }\n\n    {\n      std::lock_guard<std::mutex> lock(state.webview->resources.virtual_host_folder_mappings_mutex);\n      state.webview->resources.applied_virtual_host_folder_mappings.insert(host_name);\n    }\n\n    Logger().info(\"Restored virtual host mapping: {} -> {}\", Utils::String::ToUtf8(host_name),\n                  Utils::String::ToUtf8(mapping.folder_path));\n  }\n\n  return S_OK;\n}\n\nauto apply_registered_document_created_scripts(Core::State::AppState& state, ICoreWebView2* webview)\n    -> HRESULT {\n  if (!webview || !state.webview) {\n    return E_FAIL;\n  }\n\n  std::vector<Core::WebView::State::DocumentCreatedScript> scripts;\n  {\n    std::lock_guard<std::mutex> lock(state.webview->resources.document_created_scripts_mutex);\n    scripts.reserve(state.webview->resources.document_created_scripts.size());\n    for (const auto& [id, script] : state.webview->resources.document_created_scripts) {\n      (void)id;\n      scripts.push_back(script);\n    }\n  }\n\n  for (const auto& script : scripts) {\n    HRESULT hr = webview->AddScriptToExecuteOnDocumentCreated(script.script.c_str(), nullptr);\n    if (FAILED(hr)) {\n      Logger().warn(\"Failed to register document-created script {}: {}\", script.id, hr);\n      return hr;\n    }\n  }\n\n  if (!scripts.empty()) {\n    Logger().info(\"Registered {} WebView document-created script(s)\", scripts.size());\n  }\n\n  return S_OK;\n}\n\nauto select_initial_url(Core::WebView::State::WebViewConfig& config) -> void {\n  if (Vendor::BuildConfig::is_debug_build()) {\n    config.initial_url = config.dev_server_url;\n    Logger().info(\"Debug mode: Using Vite dev server at {}\",\n                  Utils::String::ToUtf8(config.dev_server_url));\n  } else {\n    config.initial_url = L\"https://\" + config.virtual_host_name + L\"/index.html\";\n    Logger().info(\"Release mode: Using built frontend from resources/web\");\n  }\n}\n\nauto enable_non_client_region_support(ICoreWebView2* webview) -> bool {\n  if (!webview) return false;\n\n  wil::com_ptr<ICoreWebView2Settings> settings;\n  auto hr = webview->get_Settings(settings.put());\n  if (FAILED(hr) || !settings) {\n    Logger().warn(\"Failed to get WebView settings, non-client region support disabled\");\n    return false;\n  }\n\n  wil::com_ptr<ICoreWebView2Settings9> settings9;\n  hr = settings->QueryInterface(IID_PPV_ARGS(&settings9));\n  if (FAILED(hr) || !settings9) {\n    Logger().warn(\"ICoreWebView2Settings9 unavailable, non-client region support disabled\");\n    return false;\n  }\n\n  hr = settings9->put_IsNonClientRegionSupportEnabled(TRUE);\n  if (FAILED(hr)) {\n    Logger().warn(\"Failed to enable non-client region support: {}\", hr);\n    return false;\n  }\n\n  Logger().info(\"WebView non-client region support enabled\");\n  return true;\n}\n\nauto setup_composition_non_client_support(\n    ICoreWebView2CompositionController* composition_controller,\n    Core::WebView::State::CoreResources& resources) -> void {\n  if (!composition_controller) {\n    resources.composition_controller4.reset();\n    return;\n  }\n\n  auto hr =\n      composition_controller->QueryInterface(IID_PPV_ARGS(resources.composition_controller4.put()));\n  if (FAILED(hr) || !resources.composition_controller4) {\n    Logger().warn(\"ICoreWebView2CompositionController4 unavailable, composition hit-test disabled\");\n    resources.composition_controller4.reset();\n    return;\n  }\n\n  Logger().info(\"Composition non-client region interface enabled\");\n}\n\nauto initialize_navigation(ICoreWebView2* webview, const std::wstring& initial_url) -> HRESULT {\n  HRESULT hr = webview->Navigate(initial_url.c_str());\n  if (FAILED(hr)) {\n    Logger().error(\"Failed to navigate to initial URL: {}\", hr);\n    return hr;\n  }\n\n  Logger().info(\"WebView2 ready, navigating to: {}\", Utils::String::ToUtf8(initial_url));\n  return S_OK;\n}\n\nauto finalize_controller_initialization(Core::State::AppState* state,\n                                        ICoreWebView2Controller* controller,\n                                        ICoreWebView2CompositionController* composition_controller)\n    -> HRESULT {\n  if (!state || !controller) return E_FAIL;\n\n  auto& webview_state = *state->webview;\n  auto& resources = webview_state.resources;\n\n  resources.navigation_starting_token = {};\n  resources.navigation_completed_token = {};\n  resources.new_window_requested_token = {};\n  resources.web_message_received_token = {};\n  resources.webresource_requested_tokens.clear();\n  resources.webview.reset();\n\n  resources.controller = controller;\n  resources.composition_controller = composition_controller;\n  if (!composition_controller) {\n    resources.composition_controller4.reset();\n    clear_host_runtime(resources.host_runtime);\n  }\n\n  HRESULT hr = resources.controller->get_CoreWebView2(resources.webview.put());\n  if (FAILED(hr)) {\n    Logger().error(\"Failed to get CoreWebView2: {}\", hr);\n    return hr;\n  }\n\n  if (composition_controller) {\n    auto& host_runtime = resources.host_runtime;\n    if (!host_runtime.dcomp_root_visual || !host_runtime.dcomp_device) {\n      Logger().error(\"Composition host runtime is not initialized\");\n      return E_FAIL;\n    }\n\n    hr = composition_controller->put_RootVisualTarget(host_runtime.dcomp_root_visual.get());\n    if (FAILED(hr)) {\n      Logger().error(\"Failed to set composition root visual target: {}\", hr);\n      return hr;\n    }\n\n    hr = host_runtime.dcomp_device->Commit();\n    if (FAILED(hr)) {\n      Logger().error(\"Failed to commit composition visual tree: {}\", hr);\n      return hr;\n    }\n  }\n\n  auto* webview = resources.webview.get();\n  auto* environment = resources.environment.get();\n\n  RECT bounds = {0, 0, webview_state.window.width, webview_state.window.height};\n  resources.controller->put_Bounds(bounds);\n\n  auto [transparent_enabled, theme_mode] = read_background_mode(*state);\n  apply_default_background(resources.controller.get(), transparent_enabled, theme_mode);\n\n  hr = setup_navigation_events(state, webview, resources);\n  if (FAILED(hr)) return hr;\n\n  hr = setup_new_window_requested(state, webview, resources);\n  if (FAILED(hr)) return hr;\n\n  hr = setup_message_handler(state, webview, resources);\n  if (FAILED(hr)) return hr;\n\n  hr = setup_virtual_host_mapping(webview, webview_state.config);\n  if (FAILED(hr)) {\n    if (Vendor::BuildConfig::is_debug_build()) {\n      Logger().warn(\"Virtual host mapping failed in debug mode, continuing with dev server\");\n    } else {\n      return hr;\n    }\n  }\n\n  hr = apply_registered_virtual_host_folder_mappings(*state, webview);\n  if (FAILED(hr) && !Vendor::BuildConfig::is_debug_build()) {\n    Logger().warn(\"Registered virtual host mapping restore failed: {}\", hr);\n  }\n\n  hr = Core::WebView::Static::setup_resource_interception(*state, webview, environment, resources,\n                                                          webview_state.config);\n  if (FAILED(hr)) {\n    Logger().warn(\"Resource interception setup failed, continuing without thumbnail support\");\n  }\n\n  hr = apply_registered_document_created_scripts(*state, webview);\n  if (FAILED(hr)) {\n    Logger().warn(\"Document-created script registration failed: {}\", hr);\n  }\n\n  select_initial_url(webview_state.config);\n\n  auto non_client_enabled = enable_non_client_region_support(webview);\n  if (composition_controller && non_client_enabled) {\n    setup_composition_non_client_support(composition_controller, resources);\n  } else {\n    resources.composition_controller4.reset();\n  }\n\n  hr = initialize_navigation(webview, webview_state.config.initial_url);\n  if (FAILED(hr)) return hr;\n\n  webview_state.is_ready = true;\n  Core::WebView::RpcBridge::initialize_rpc_bridge(*state);\n\n  return S_OK;\n}\n\nclass CompositionControllerCompletedHandler\n    : public Microsoft::WRL::RuntimeClass<\n          Microsoft::WRL::RuntimeClassFlags<Microsoft::WRL::ClassicCom>,\n          ICoreWebView2CreateCoreWebView2CompositionControllerCompletedHandler> {\n private:\n  Core::State::AppState* m_state;\n\n public:\n  explicit CompositionControllerCompletedHandler(Core::State::AppState* state) : m_state(state) {}\n\n  HRESULT STDMETHODCALLTYPE Invoke(HRESULT result,\n                                   ICoreWebView2CompositionController* composition_controller) {\n    if (!m_state) return E_FAIL;\n    if (FAILED(result)) {\n      Logger().error(\"Failed to create WebView2 composition controller: {}\", result);\n      return result;\n    }\n    if (!composition_controller) {\n      return E_POINTER;\n    }\n\n    wil::com_ptr<ICoreWebView2Controller> controller;\n    auto hr = composition_controller->QueryInterface(IID_PPV_ARGS(&controller));\n    if (FAILED(hr) || !controller) {\n      Logger().error(\"Failed to cast composition controller to base controller: {}\", hr);\n      return FAILED(hr) ? hr : E_NOINTERFACE;\n    }\n\n    return finalize_controller_initialization(m_state, controller.get(), composition_controller);\n  }\n};\n\nclass HwndControllerCompletedHandler\n    : public Microsoft::WRL::RuntimeClass<\n          Microsoft::WRL::RuntimeClassFlags<Microsoft::WRL::ClassicCom>,\n          ICoreWebView2CreateCoreWebView2ControllerCompletedHandler> {\n private:\n  Core::State::AppState* m_state;\n\n public:\n  explicit HwndControllerCompletedHandler(Core::State::AppState* state) : m_state(state) {}\n\n  HRESULT STDMETHODCALLTYPE Invoke(HRESULT result, ICoreWebView2Controller* controller) {\n    if (!m_state) return E_FAIL;\n    if (FAILED(result)) {\n      Logger().error(\"Failed to create WebView2 HWND controller: {}\", result);\n      return result;\n    }\n\n    return finalize_controller_initialization(m_state, controller, nullptr);\n  }\n};\n\nclass EnvironmentCompletedHandler\n    : public Microsoft::WRL::RuntimeClass<\n          Microsoft::WRL::RuntimeClassFlags<Microsoft::WRL::ClassicCom>,\n          ICoreWebView2CreateCoreWebView2EnvironmentCompletedHandler> {\n private:\n  Core::State::AppState* m_state;\n  HWND m_webview_hwnd;\n\n public:\n  EnvironmentCompletedHandler(Core::State::AppState* state, HWND webview_hwnd)\n      : m_state(state), m_webview_hwnd(webview_hwnd) {}\n\n  HRESULT STDMETHODCALLTYPE Invoke(HRESULT result, ICoreWebView2Environment* env) {\n    if (!m_state) return E_FAIL;\n    auto& webview_state = *m_state->webview;\n\n    if (FAILED(result)) {\n      Logger().error(\"Failed to create WebView2 environment: {}\", result);\n      return result;\n    }\n\n    webview_state.resources.environment = env;\n\n    if (use_composition_host_from_settings(*m_state)) {\n      Logger().info(\"WebView host mode resolved to Composition\");\n\n      auto create_host_result =\n          create_composition_host(m_webview_hwnd, webview_state.resources.host_runtime);\n      if (!create_host_result) {\n        Logger().error(\"Failed to initialize composition host: {}\", create_host_result.error());\n        return E_FAIL;\n      }\n\n      wil::com_ptr<ICoreWebView2Environment3> env3;\n      auto hr = env->QueryInterface(IID_PPV_ARGS(&env3));\n      if (FAILED(hr) || !env3) {\n        Logger().error(\n            \"ICoreWebView2Environment3 is unavailable; composition hosting not supported\");\n        clear_host_runtime(webview_state.resources.host_runtime);\n        return FAILED(hr) ? hr : E_NOINTERFACE;\n      }\n\n      auto controller_handler =\n          Microsoft::WRL::Make<CompositionControllerCompletedHandler>(m_state);\n      hr = env3->CreateCoreWebView2CompositionController(m_webview_hwnd, controller_handler.Get());\n      if (FAILED(hr)) {\n        Logger().error(\"Failed to start composition controller creation: {}\", hr);\n        clear_host_runtime(webview_state.resources.host_runtime);\n      }\n      return hr;\n    }\n\n    Logger().info(\"WebView host mode resolved to HWND controller\");\n    clear_host_runtime(webview_state.resources.host_runtime);\n    webview_state.resources.composition_controller.reset();\n    webview_state.resources.composition_controller4.reset();\n\n    auto controller_handler = Microsoft::WRL::Make<HwndControllerCompletedHandler>(m_state);\n    auto hr = env->CreateCoreWebView2Controller(m_webview_hwnd, controller_handler.Get());\n    if (FAILED(hr)) {\n      Logger().error(\"Failed to start HWND controller creation: {}\", hr);\n    }\n    return hr;\n  }\n};\n\n}  // namespace Core::WebView::Host::Detail\n\nnamespace Core::WebView::Host {\n\nauto start_environment_creation(Core::State::AppState& state, HWND webview_hwnd)\n    -> std::expected<void, std::string> {\n  try {\n    auto app_data_dir_result = Utils::Path::GetAppDataDirectory();\n    if (!app_data_dir_result) {\n      return std::unexpected(\"Failed to get app data directory: \" + app_data_dir_result.error());\n    }\n\n    auto configured_user_data_folder = std::filesystem::path(\n        state.webview->config.user_data_folder.empty() ? L\"webview2\"\n                                                       : state.webview->config.user_data_folder);\n    auto user_data_folder = configured_user_data_folder.is_absolute()\n                                ? configured_user_data_folder\n                                : app_data_dir_result.value() / configured_user_data_folder;\n\n    auto ensure_result = Utils::Path::EnsureDirectoryExists(user_data_folder);\n    if (!ensure_result) {\n      return std::unexpected(\"Failed to create WebView2 user data directory: \" +\n                             ensure_result.error());\n    }\n\n    state.webview->resources.user_data_folder = user_data_folder.wstring();\n\n    auto environment_handler =\n        Microsoft::WRL::Make<Detail::EnvironmentCompletedHandler>(&state, webview_hwnd);\n\n    HRESULT hr = CreateCoreWebView2EnvironmentWithOptions(\n        nullptr, state.webview->resources.user_data_folder.c_str(), nullptr,\n        environment_handler.Get());\n    Vendor::WIL::throw_if_failed(hr);\n\n    return {};\n  } catch (const wil::ResultException& e) {\n    auto error_msg =\n        std::format(\"Failed to initialize WebView2 environment: {} (HRESULT: 0x{:08X})\", e.what(),\n                    static_cast<unsigned>(e.GetErrorCode()));\n    Logger().error(error_msg);\n    return std::unexpected(error_msg);\n  } catch (const std::exception& e) {\n    auto error_msg =\n        std::format(\"Unexpected error during WebView2 environment creation: {}\", e.what());\n    Logger().error(error_msg);\n    return std::unexpected(error_msg);\n  }\n}\n\nauto reset_host_runtime(Core::State::AppState& state) -> void {\n  if (!state.webview) return;\n  Detail::clear_host_runtime(state.webview->resources.host_runtime);\n}\n\nauto apply_background_mode_from_settings(Core::State::AppState& state) -> void {\n  auto* controller = state.webview->resources.controller.get();\n  if (!controller) {\n    Logger().debug(\"Skip applying WebView background mode: controller is not ready\");\n    return;\n  }\n\n  auto [transparent_enabled, theme_mode] = Detail::read_background_mode(state);\n  Detail::apply_default_background(controller, transparent_enabled, theme_mode);\n}\n\nauto get_loading_background_color(Core::State::AppState& state) -> COLORREF {\n  auto [transparent_enabled, theme_mode] = Detail::read_background_mode(state);\n  (void)transparent_enabled;\n\n  auto color = Detail::resolve_opaque_background_color(theme_mode);\n  return RGB(color.R, color.G, color.B);\n}\n\n}  // namespace Core::WebView::Host\n"
  },
  {
    "path": "src/core/webview/host.ixx",
    "content": "module;\n\nexport module Core.WebView.Host;\n\nimport std;\nimport Core.State;\nimport <windows.h>;\n\nnamespace Core::WebView::Host {\n\nexport auto start_environment_creation(Core::State::AppState& state, HWND webview_hwnd)\n    -> std::expected<void, std::string>;\n\nexport auto reset_host_runtime(Core::State::AppState& state) -> void;\n\nexport auto apply_background_mode_from_settings(Core::State::AppState& state) -> void;\n\nexport auto get_loading_background_color(Core::State::AppState& state) -> COLORREF;\n\n}  // namespace Core::WebView::Host\n"
  },
  {
    "path": "src/core/webview/rpc_bridge.cpp",
    "content": "module;\r\n\r\n#include <asio.hpp>\r\n\r\nmodule Core.WebView.RpcBridge;\r\n\r\nimport std;\r\nimport Core.State;\r\nimport Core.Events;\r\nimport Core.WebView;\r\nimport Core.WebView.State;\r\nimport Core.RPC;\r\nimport Core.Async;\r\nimport Core.WebView.Events;\r\nimport Utils.Logger;\r\n\r\nnamespace Core::WebView::RpcBridge {\r\n\r\n// 辅助函数：创建通用错误响应（当无法处理请求时）\r\nauto create_generic_error_response(const std::string& error_message) -> std::string {\r\n  return std::format(R\"({{\r\n    \"jsonrpc\": \"2.0\",\r\n    \"error\": {{\r\n      \"code\": -32603,\r\n      \"message\": \"Internal error: {}\"\r\n    }},\r\n    \"id\": null\r\n  }})\",\r\n                     error_message);\r\n}\r\n\r\nauto initialize_rpc_bridge(Core::State::AppState& state) -> void {\r\n  Logger().info(\"Initializing WebView RPC bridge\");\r\n\r\n  // 确保异步运行时已启动\r\n  if (!Core::Async::is_running(*state.async)) {\r\n    Logger().warn(\"Async runtime not running when initializing RPC bridge\");\r\n  }\r\n\r\n  // 初始化RPC桥接状态\r\n  state.webview->messaging.next_message_id = 1;\r\n\r\n  Logger().info(\"WebView RPC bridge initialized\");\r\n}\r\n\r\nauto handle_webview_message(Core::State::AppState& state, const std::string& message)\r\n    -> asio::awaitable<void> {\r\n  Logger().debug(\"Handling WebView message: {}\",\r\n                 message.substr(0, 100) + (message.size() > 100 ? \"...\" : \"\"));\r\n\r\n  try {\r\n    // 在异步线程上处理RPC请求\r\n    auto response = co_await Core::RPC::process_request(state, message);\r\n\r\n    // 直接投递响应字符串到UI线程处理\r\n    Core::Events::post(*state.events, Core::WebView::Events::WebViewResponseEvent{response});\r\n\r\n    Logger().debug(\"WebView response queued for UI thread processing\");\r\n\r\n  } catch (const std::exception& e) {\r\n    Logger().error(\"Error handling WebView RPC message: {}\", e.what());\r\n\r\n    // 错误处理：直接投递错误响应字符串\r\n    Core::Events::post(*state.events, Core::WebView::Events::WebViewResponseEvent{\r\n                                             create_generic_error_response(e.what())});\r\n\r\n    Logger().debug(\"WebView error response queued for UI thread processing\");\r\n  }\r\n}\r\n\r\nauto send_notification(Core::State::AppState& state, const std::string& method,\r\n                       const std::string& params) -> void {\r\n  // 构造JSON-RPC 2.0通知格式\r\n  auto notification = std::format(R\"({{\r\n        \"jsonrpc\": \"2.0\",\r\n        \"method\": \"{}\",\r\n        \"params\": {}\r\n    }})\",\r\n                                  method, params);\r\n\r\n  try {\r\n    Core::WebView::post_message(state, notification);\r\n    Logger().debug(\"Sent notification: {}\", method);\r\n  } catch (const std::exception& e) {\r\n    Logger().error(\"Failed to send notification '{}': {}\", method, e.what());\r\n  }\r\n}\r\n\r\nauto create_message_handler(Core::State::AppState& state)\r\n    -> std::function<void(const std::string&)> {\r\n  return [&state](const std::string& message) {\r\n    // 在异步运行时中处理消息\r\n    asio::co_spawn(\r\n        *Core::Async::get_io_context(*state.async),\r\n        [&state, message]() -> asio::awaitable<void> {\r\n          co_await handle_webview_message(state, message);\r\n        },\r\n        asio::detached);\r\n  };\r\n}\r\n\r\n}  // namespace Core::WebView::RpcBridge"
  },
  {
    "path": "src/core/webview/rpc_bridge.ixx",
    "content": "module;\r\n\r\n#include <asio.hpp>\r\n\r\nexport module Core.WebView.RpcBridge;\r\n\r\nimport std;\r\nimport Core.State;\r\n\r\nnamespace Core::WebView::RpcBridge {\r\n\r\n// 初始化RPC桥接\r\nexport auto initialize_rpc_bridge(Core::State::AppState& state) -> void;\r\n\r\n// 处理来自前端的RPC消息\r\nexport auto handle_webview_message(Core::State::AppState& state, const std::string& message)\r\n    -> asio::awaitable<void>;\r\n\r\n// 向前端发送通知 (JSON-RPC notification)\r\nexport auto send_notification(Core::State::AppState& state, const std::string& method,\r\n                              const std::string& params) -> void;\r\n\r\n// 处理WebView消息的回调函数\r\nexport auto create_message_handler(Core::State::AppState& state)\r\n    -> std::function<void(const std::string&)>;\r\n\r\n}  // namespace Core::WebView::RpcBridge"
  },
  {
    "path": "src/core/webview/state.ixx",
    "content": "module;\n\n#include <wil/com.h>\n\n#include <WebView2.h>  // 必须放最后面\n\nexport module Core.WebView.State;\n\nimport std;\nimport Core.WebView.Types;\nimport <d3d11.h>;\nimport <dcomp.h>;\nimport <windows.h>;\n\nnamespace Core::WebView::State {\n\nexport constexpr UINT kWM_APP_BEGIN_RESIZE = WM_APP + 2;\n// WM_APP + 3：通知 WebView 窗口线程对虚拟主机映射进行协调同步\n// 由 register/unregister_virtual_host_folder_mapping 触发，实际执行在窗口消息循环中\nexport constexpr UINT kWM_APP_RECONCILE_VIRTUAL_HOST_MAPPINGS = WM_APP + 3;\n\n// Composition Hosting 运行时资源\nexport struct HostRuntime {\n  wil::com_ptr<ID3D11Device> d3d_device;\n  wil::com_ptr<IDCompositionDevice> dcomp_device;\n  wil::com_ptr<IDCompositionTarget> dcomp_target;\n  wil::com_ptr<IDCompositionVisual> dcomp_root_visual;\n};\n\n// WebView窗口状态\nexport struct WindowState {\n  HWND webview_hwnd = nullptr;\n  int width = 900;\n  int height = 600;\n  int x = 0;\n  int y = 0;\n  bool is_visible = false;\n  bool is_maximized = false;\n  bool is_fullscreen = false;\n  bool has_fullscreen_restore_state = false;\n  DWORD fullscreen_restore_style = 0;\n  WINDOWPLACEMENT fullscreen_restore_placement{sizeof(WINDOWPLACEMENT)};\n};\n\n// WebView核心资源\nexport enum class VirtualHostResourceAccessKind {\n  deny = COREWEBVIEW2_HOST_RESOURCE_ACCESS_KIND_DENY,\n  allow = COREWEBVIEW2_HOST_RESOURCE_ACCESS_KIND_ALLOW,\n  deny_cors = COREWEBVIEW2_HOST_RESOURCE_ACCESS_KIND_DENY_CORS,\n};\n\nexport struct VirtualHostFolderMapping {\n  std::wstring folder_path;\n  VirtualHostResourceAccessKind access_kind = VirtualHostResourceAccessKind::deny_cors;\n};\n\nexport struct DocumentCreatedScript {\n  std::string id;\n  std::wstring script;\n};\n\nexport struct CoreResources {\n  wil::com_ptr<ICoreWebView2Environment> environment;\n  wil::com_ptr<ICoreWebView2Controller> controller;\n  wil::com_ptr<ICoreWebView2CompositionController> composition_controller;\n  wil::com_ptr<ICoreWebView2CompositionController4> composition_controller4;\n  wil::com_ptr<ICoreWebView2> webview;\n\n  // 事件注册令牌，用于清理时取消注册\n  EventRegistrationToken navigation_starting_token{};\n  EventRegistrationToken navigation_completed_token{};\n  EventRegistrationToken new_window_requested_token{};\n  EventRegistrationToken web_message_received_token{};\n  std::vector<EventRegistrationToken> webresource_requested_tokens;\n\n  std::wstring user_data_folder;\n  std::wstring current_url;\n\n  // WebView 资源解析器注册表\n  std::unique_ptr<Types::WebResolverRegistry> web_resolvers;\n  std::unordered_map<std::wstring, VirtualHostFolderMapping> virtual_host_folder_mappings;\n  // 已通过 WebView2 COM 接口实际设置的虚拟主机映射集合\n  // 与 virtual_host_folder_mappings（期望状态）共同用于 reconcile 时的差量计算\n  std::unordered_set<std::wstring> applied_virtual_host_folder_mappings;\n  std::mutex virtual_host_folder_mappings_mutex;\n  std::unordered_map<std::string, DocumentCreatedScript> document_created_scripts;\n  std::mutex document_created_scripts_mutex;\n  HostRuntime host_runtime;\n\n  // 构造函数：初始化解析器注册表\n  CoreResources() : web_resolvers(std::make_unique<Types::WebResolverRegistry>()) {}\n};\n\n// 消息通信状态\nexport struct MessageState {\n  std::queue<std::string> pending_messages;\n  std::unordered_map<std::string, std::string> message_responses;\n  std::unordered_map<std::string, std::function<void(const std::string&)>> handlers;\n  std::mutex message_mutex;\n  std::atomic<uint64_t> next_message_id{0};\n};\n\n// WebView配置\nexport struct WebViewConfig {\n  std::wstring user_data_folder = L\"webview2\";\n  std::wstring initial_url = L\"\";  // 运行时根据编译模式自动设置\n  std::vector<std::wstring> allowed_origins;\n  bool enable_password_autosave = false;\n  bool enable_general_autofill = false;\n\n  // 前端加载配置\n  std::wstring frontend_dist_path = L\"./resources/web\";    // 前端构建产物路径\n  std::wstring virtual_host_name = L\"app.test\";            // 前端虚拟主机名\n  std::wstring static_host_name = L\"static.test\";          // 通用静态资源路径\n  std::wstring thumbnail_host_name = L\"thumbs.test\";       // 缩略图虚拟主机名\n  std::wstring dev_server_url = L\"http://localhost:5173\";  // 开发服务器URL\n};\n\n// WebView完整状态\nexport struct WebViewState {\n  WindowState window;\n  CoreResources resources;\n  MessageState messaging;\n  WebViewConfig config;\n\n  bool is_initialized = false;\n  bool is_ready = false;\n  bool has_initial_content = false;\n};\n\n}  // namespace Core::WebView::State\n"
  },
  {
    "path": "src/core/webview/static.cpp",
    "content": "module;\n\n#include <wil/com.h>\n\n#include <WebView2.h>  // 必须放最后面\n\nmodule Core.WebView.Static;\n\nimport std;\nimport Core.State;\nimport Core.WebView.State;\nimport Core.WebView.Types;\nimport Utils.File.Mime;\nimport Utils.Logger;\nimport Utils.String;\nimport Utils.Time;\nimport Vendor.BuildConfig;\nimport <Shlwapi.h>;\nimport <windows.h>;\nimport <wrl.h>;\n\nnamespace Core::WebView::Static {\n\n// ---- 自定义静态资源须支持 Range；否则嵌入式 <video> 无法 seek ----\nstruct ByteRange {\n  std::uint64_t start = 0;\n  std::uint64_t end = 0;  // inclusive\n};\n\nstruct RangeHeaderParseResult {\n  bool valid = true;\n  std::optional<ByteRange> range;\n};\n\nauto parse_range_header(std::string_view header_value, std::uint64_t file_size)\n    -> RangeHeaderParseResult {\n  if (header_value.empty()) {\n    return {};\n  }\n\n  // 与 HTTP 静态服务器保持一致：只处理单一 range，拒绝 multipart range。\n  if (!header_value.starts_with(\"bytes=\") || file_size == 0) {\n    return {.valid = false, .range = std::nullopt};\n  }\n\n  auto range_spec = header_value.substr(6);\n  auto comma_pos = range_spec.find(',');\n  if (comma_pos != std::string_view::npos) {\n    return {.valid = false, .range = std::nullopt};\n  }\n\n  auto dash_pos = range_spec.find('-');\n  if (dash_pos == std::string_view::npos) {\n    return {.valid = false, .range = std::nullopt};\n  }\n\n  auto start_part = range_spec.substr(0, dash_pos);\n  auto end_part = range_spec.substr(dash_pos + 1);\n\n  if (start_part.empty()) {\n    std::uint64_t suffix_length = 0;\n    auto [ptr, ec] =\n        std::from_chars(end_part.data(), end_part.data() + end_part.size(), suffix_length);\n    if (ec != std::errc{} || ptr != end_part.data() + end_part.size() || suffix_length == 0) {\n      return {.valid = false, .range = std::nullopt};\n    }\n\n    auto clamped_length = std::min(suffix_length, file_size);\n    return {.valid = true,\n            .range = ByteRange{.start = file_size - clamped_length, .end = file_size - 1}};\n  }\n\n  std::uint64_t start = 0;\n  auto [start_ptr, start_ec] =\n      std::from_chars(start_part.data(), start_part.data() + start_part.size(), start);\n  if (start_ec != std::errc{} || start_ptr != start_part.data() + start_part.size() ||\n      start >= file_size) {\n    return {.valid = false, .range = std::nullopt};\n  }\n\n  if (end_part.empty()) {\n    return {.valid = true, .range = ByteRange{.start = start, .end = file_size - 1}};\n  }\n\n  std::uint64_t end = 0;\n  auto [end_ptr, end_ec] = std::from_chars(end_part.data(), end_part.data() + end_part.size(), end);\n  if (end_ec != std::errc{} || end_ptr != end_part.data() + end_part.size() || end < start) {\n    return {.valid = false, .range = std::nullopt};\n  }\n\n  return {.valid = true, .range = ByteRange{.start = start, .end = std::min(end, file_size - 1)}};\n}\n\nauto get_request_header(ICoreWebView2WebResourceRequest* request, const std::wstring& name)\n    -> std::optional<std::wstring> {\n  if (!request) {\n    return std::nullopt;\n  }\n\n  wil::com_ptr<ICoreWebView2HttpRequestHeaders> headers;\n  if (FAILED(request->get_Headers(headers.put())) || !headers) {\n    return std::nullopt;\n  }\n\n  wil::unique_cotaskmem_string value_raw;\n  if (FAILED(headers->GetHeader(name.c_str(), &value_raw)) || !value_raw) {\n    return std::nullopt;\n  }\n\n  return std::wstring(value_raw.get());\n}\n\nauto create_memory_stream_from_bytes(const std::vector<char>& bytes) -> wil::com_ptr<IStream> {\n  wil::com_ptr<IStream> stream;\n  if (FAILED(CreateStreamOnHGlobal(nullptr, TRUE, stream.put())) || !stream) {\n    return nullptr;\n  }\n\n  if (!bytes.empty()) {\n    ULONG bytes_written = 0;\n    if (FAILED(stream->Write(bytes.data(), static_cast<ULONG>(bytes.size()), &bytes_written)) ||\n        bytes_written != bytes.size()) {\n      return nullptr;\n    }\n  }\n\n  LARGE_INTEGER seek_origin{};\n  stream->Seek(seek_origin, STREAM_SEEK_SET, nullptr);\n  return stream;\n}\n\nauto read_file_range(const std::filesystem::path& file_path, const ByteRange& range)\n    -> std::expected<std::vector<char>, std::string> {\n  // WebView2 的自定义响应没有现成的“文件分段流”，partial response 这里走内存流即可。\n  std::ifstream file(file_path, std::ios::binary);\n  if (!file) {\n    return std::unexpected(\"Failed to open file for partial WebView response\");\n  }\n\n  auto length = range.end - range.start + 1;\n  std::vector<char> bytes(static_cast<std::size_t>(length));\n  file.seekg(static_cast<std::streamoff>(range.start), std::ios::beg);\n  file.read(bytes.data(), static_cast<std::streamsize>(length));\n\n  if (!file && !file.eof()) {\n    return std::unexpected(\"Failed to read requested WebView byte range\");\n  }\n\n  bytes.resize(static_cast<std::size_t>(file.gcount()));\n  if (bytes.size() != length) {\n    return std::unexpected(\"WebView byte range read was shorter than expected\");\n  }\n\n  return bytes;\n}\n\nstruct CacheValidators {\n  std::wstring etag;\n  std::wstring last_modified;\n};\n\n// 去掉条件请求头两端的空白，便于后续按 HTTP 语义做精确匹配。\nauto trim_http_header_value(std::wstring_view value) -> std::wstring_view {\n  while (!value.empty() && std::iswspace(value.front())) {\n    value.remove_prefix(1);\n  }\n  while (!value.empty() && std::iswspace(value.back())) {\n    value.remove_suffix(1);\n  }\n  return value;\n}\n\n// 基于文件大小和最后修改时间构造 WebView 自定义响应的缓存校验器。\nauto build_cache_validators(const std::filesystem::path& file_path, std::uint64_t file_size)\n    -> std::expected<CacheValidators, std::string> {\n  std::error_code ec;\n  auto last_write_time = std::filesystem::last_write_time(file_path, ec);\n  if (ec) {\n    return std::unexpected(\"Failed to query file last write time: \" + ec.message());\n  }\n\n  auto modified_time = std::chrono::time_point_cast<std::chrono::seconds>(\n      Utils::Time::file_time_to_system_clock(last_write_time));\n  auto modified_seconds = Utils::Time::file_time_to_seconds(last_write_time);\n\n  return CacheValidators{\n      .etag = std::format(L\"\\\"{:x}-{:x}\\\"\", file_size, modified_seconds),\n      .last_modified = std::format(L\"{:%a, %d %b %Y %H:%M:%S GMT}\", modified_time)};\n}\n\n// If-None-Match 允许多个候选值；只要命中当前资源的 ETag 就可以回 304。\nauto if_none_match_matches(std::wstring_view header_value, std::wstring_view etag) -> bool {\n  auto remaining = header_value;\n  while (!remaining.empty()) {\n    auto comma_pos = remaining.find(L',');\n    auto candidate = trim_http_header_value(remaining.substr(0, comma_pos));\n    if (candidate == L\"*\" || candidate == etag) {\n      return true;\n    }\n\n    if (comma_pos == std::wstring_view::npos) {\n      break;\n    }\n    remaining.remove_prefix(comma_pos + 1);\n  }\n  return false;\n}\n\n// WebView 条件请求判定与 HTTP 服务器保持一致：Range 请求不走 304。\nauto is_not_modified_request(ICoreWebView2WebResourceRequest* request,\n                             const CacheValidators& validators, bool has_range_request) -> bool {\n  if (has_range_request) {\n    return false;\n  }\n\n  auto if_none_match = get_request_header(request, L\"If-None-Match\");\n  if (if_none_match.has_value()) {\n    return if_none_match_matches(trim_http_header_value(*if_none_match), validators.etag);\n  }\n\n  auto if_modified_since = get_request_header(request, L\"If-Modified-Since\");\n  if (if_modified_since.has_value()) {\n    return trim_http_header_value(*if_modified_since) == validators.last_modified;\n  }\n\n  return false;\n}\n\n// 构建 200/206 自定义响应头，统一写入缓存校验信息与可选的 Range/CORS 头。\nauto build_response_headers(const std::wstring& content_type, std::uint64_t content_length,\n                            std::wstring_view cache_control, const CacheValidators& validators,\n                            std::optional<std::uint64_t> source_file_size = std::nullopt,\n                            std::optional<ByteRange> range = std::nullopt,\n                            std::optional<std::wstring> allowed_origin = std::nullopt)\n    -> std::wstring {\n  auto headers = std::format(\n      L\"Content-Type: {}\\r\\n\"\n      L\"Cache-Control: {}\\r\\n\"\n      L\"ETag: {}\\r\\n\"\n      L\"Last-Modified: {}\\r\\n\"\n      L\"Accept-Ranges: bytes\\r\\n\"\n      L\"Content-Length: {}\\r\\n\",\n      content_type, cache_control, validators.etag, validators.last_modified, content_length);\n\n  if (allowed_origin.has_value() && !allowed_origin->empty()) {\n    headers += std::format(L\"Access-Control-Allow-Origin: {}\\r\\n\", *allowed_origin);\n    headers += L\"Vary: Origin\\r\\n\";\n  }\n\n  if (range.has_value() && source_file_size.has_value()) {\n    headers += std::format(L\"Content-Range: bytes {}-{}/{}\\r\\n\", range->start, range->end,\n                           source_file_size.value());\n  }\n\n  return headers;\n}\n\n// 构建 304 响应头；虽然没有实体，但仍需带回缓存相关元数据。\nauto build_not_modified_headers(std::wstring_view cache_control, const CacheValidators& validators,\n                                std::optional<std::wstring> allowed_origin = std::nullopt)\n    -> std::wstring {\n  auto headers = std::format(L\"Cache-Control: {}\\r\\nETag: {}\\r\\nLast-Modified: {}\\r\\n\",\n                             cache_control, validators.etag, validators.last_modified);\n\n  if (allowed_origin.has_value() && !allowed_origin->empty()) {\n    headers += std::format(L\"Access-Control-Allow-Origin: {}\\r\\n\", *allowed_origin);\n    headers += L\"Vary: Origin\\r\\n\";\n  }\n\n  return headers;\n}\n\nauto try_web_resource_resolve(Core::State::AppState& state, std::wstring_view url)\n    -> std::optional<Types::WebResourceResolution> {\n  if (!state.webview || !state.webview->resources.web_resolvers) {\n    return std::nullopt;\n  }\n\n  auto& registry = *state.webview->resources.web_resolvers;\n  // 无锁读取（RCU 模式）\n  auto resolvers = registry.resolvers.load();\n\n  for (const auto& entry : *resolvers) {\n    if (url.starts_with(entry.prefix)) {\n      auto result = entry.resolver(url);\n      if (result.success) {\n        return result;\n      }\n    }\n  }\n\n  return std::nullopt;\n}\n\nauto handle_custom_web_resource_request(Core::State::AppState& state,\n                                        ICoreWebView2Environment* environment,\n                                        ICoreWebView2WebResourceRequestedEventArgs* args)\n    -> HRESULT {\n  if (!environment) {\n    return S_OK;\n  }\n\n  wil::com_ptr<ICoreWebView2WebResourceRequest> request;\n  if (FAILED(args->get_Request(request.put())) || !request) {\n    return S_OK;\n  }\n\n  wil::unique_cotaskmem_string uri_raw;\n  if (FAILED(request->get_Uri(&uri_raw)) || !uri_raw) {\n    return S_OK;\n  }\n\n  std::wstring uri(uri_raw.get());\n\n  auto custom_result = try_web_resource_resolve(state, uri);\n  if (!custom_result) {\n    return S_OK;\n  }\n\n  const auto& resolution = *custom_result;\n  if (!resolution.success) {\n    Logger().warn(\"WebView resource resolution failed: {}\", resolution.error_message);\n\n    wil::com_ptr<ICoreWebView2WebResourceResponse> not_found_response;\n    if (SUCCEEDED(environment->CreateWebResourceResponse(nullptr, 404, L\"Not Found\", nullptr,\n                                                         not_found_response.put()))) {\n      args->put_Response(not_found_response.get());\n    }\n    return S_OK;\n  }\n\n  std::error_code file_ec;\n  auto file_size = std::filesystem::file_size(resolution.file_path, file_ec);\n  if (file_ec) {\n    Logger().error(\"Failed to query custom resource file size: {} ({})\",\n                   Utils::String::ToUtf8(resolution.file_path.wstring()), file_ec.message());\n    return S_OK;\n  }\n\n  // 图库原文件常无显式 content_type，须按扩展名补 MIME，否则播放器可能拒播。\n  auto content_type = resolution.content_type.value_or(\n      Utils::String::FromUtf8(Utils::File::Mime::get_mime_type(resolution.file_path)));\n  auto cache_control =\n      resolution.cache_control_header.value_or(std::wstring{L\"public, max-age=86400\"});\n  auto allowed_origin =\n      state.webview\n          ? std::optional<std::wstring>(L\"https://\" + state.webview->config.virtual_host_name)\n          : std::nullopt;\n  auto range_header = get_request_header(request.get(), L\"Range\");\n  auto range_parse = parse_range_header(range_header ? Utils::String::ToUtf8(*range_header) : \"\",\n                                        static_cast<std::uint64_t>(file_size));\n\n  if (!range_parse.valid) {\n    auto headers = std::format(L\"Accept-Ranges: bytes\\r\\nContent-Range: bytes */{}\\r\\n\",\n                               static_cast<std::uint64_t>(file_size));\n    if (allowed_origin.has_value() && !allowed_origin->empty()) {\n      headers += std::format(L\"Access-Control-Allow-Origin: {}\\r\\n\", *allowed_origin);\n      headers += L\"Vary: Origin\\r\\n\";\n    }\n    wil::com_ptr<ICoreWebView2WebResourceResponse> invalid_range_response;\n    if (SUCCEEDED(environment->CreateWebResourceResponse(nullptr, 416, L\"Range Not Satisfiable\",\n                                                         headers.c_str(),\n                                                         invalid_range_response.put()))) {\n      args->put_Response(invalid_range_response.get());\n    }\n    return S_OK;\n  }\n\n  auto validators_result =\n      build_cache_validators(resolution.file_path, static_cast<std::uint64_t>(file_size));\n  if (!validators_result) {\n    Logger().error(\"Failed to build WebView cache validators: {} ({})\",\n                   Utils::String::ToUtf8(resolution.file_path.wstring()),\n                   validators_result.error());\n    return S_OK;\n  }\n  auto validators = std::move(validators_result.value());\n\n  if (is_not_modified_request(request.get(), validators, range_parse.range.has_value())) {\n    auto not_modified_headers =\n        build_not_modified_headers(cache_control, validators, allowed_origin);\n    wil::com_ptr<ICoreWebView2WebResourceResponse> not_modified_response;\n    if (SUCCEEDED(environment->CreateWebResourceResponse(nullptr, 304, L\"Not Modified\",\n                                                         not_modified_headers.c_str(),\n                                                         not_modified_response.put())) &&\n        not_modified_response) {\n      args->put_Response(not_modified_response.get());\n    }\n    return S_OK;\n  }\n\n  wil::com_ptr<IStream> stream;\n  std::wstring headers;\n  int status_code = resolution.status_code.value_or(200);\n  const wchar_t* status_text = status_code == 200 ? L\"OK\" : L\"Error\";\n\n  // WebView2 CreateWebResourceResponse 无「文件区间流」API；Range 响应只能先读入内存再建 IStream。\n  if (range_parse.range.has_value()) {\n    auto bytes_result = read_file_range(resolution.file_path, *range_parse.range);\n    if (!bytes_result) {\n      Logger().error(\"Failed to read partial custom resource file: {} ({})\",\n                     Utils::String::ToUtf8(resolution.file_path.wstring()), bytes_result.error());\n      return S_OK;\n    }\n\n    stream = create_memory_stream_from_bytes(*bytes_result);\n    if (!stream) {\n      Logger().error(\"Failed to create memory stream for partial custom resource: {}\",\n                     Utils::String::ToUtf8(resolution.file_path.wstring()));\n      return S_OK;\n    }\n\n    headers = build_response_headers(content_type, bytes_result->size(), cache_control, validators,\n                                     static_cast<std::uint64_t>(file_size), range_parse.range,\n                                     allowed_origin);\n    status_code = 206;\n    status_text = L\"Partial Content\";\n  } else {\n    HRESULT hr =\n        SHCreateStreamOnFileEx(resolution.file_path.c_str(), STGM_READ | STGM_SHARE_DENY_WRITE,\n                               FILE_ATTRIBUTE_NORMAL, FALSE, nullptr, stream.put());\n    if (FAILED(hr) || !stream) {\n      Logger().error(\"Failed to open custom resource file: {} (hr={})\",\n                     Utils::String::ToUtf8(resolution.file_path.wstring()), hr);\n      return S_OK;\n    }\n\n    headers =\n        build_response_headers(content_type, static_cast<std::uint64_t>(file_size), cache_control,\n                               validators, std::nullopt, std::nullopt, allowed_origin);\n  }\n\n  wil::com_ptr<ICoreWebView2WebResourceResponse> response;\n  if (FAILED(environment->CreateWebResourceResponse(stream.get(), status_code, status_text,\n                                                    headers.c_str(), response.put())) ||\n      !response) {\n    Logger().error(\"Failed to create WebView2 response for custom resource {}\",\n                   Utils::String::ToUtf8(resolution.file_path.wstring()));\n    return S_OK;\n  }\n\n  args->put_Response(response.get());\n  return S_OK;\n}\n\nauto register_web_resource_resolver(Core::State::AppState& state, std::wstring prefix,\n                                    Types::WebResourceResolver resolver) -> void {\n  if (!state.webview || !state.webview->resources.web_resolvers) {\n    Logger().error(\"WebView state not initialized, cannot register resource resolver\");\n    return;\n  }\n\n  auto& registry = *state.webview->resources.web_resolvers;\n  std::unique_lock lock(registry.write_mutex);\n\n  // RCU 写入：复制当前 vector，添加新项，然后原子替换\n  auto current = registry.resolvers.load();\n  auto new_resolvers = std::make_shared<std::vector<Types::WebResolverEntry>>(*current);\n  new_resolvers->push_back({std::move(prefix), std::move(resolver)});\n  registry.resolvers.store(new_resolvers);\n\n  Logger().debug(\"Registered WebView resource resolver for: {}\", Utils::String::ToUtf8(prefix));\n}\n\nauto setup_resource_interception(Core::State::AppState& state, ICoreWebView2* webview,\n                                 ICoreWebView2Environment* environment,\n                                 Core::WebView::State::CoreResources& resources,\n                                 Core::WebView::State::WebViewConfig& config) -> HRESULT {\n  auto webview22 = wil::com_ptr<ICoreWebView2>(webview).try_query<ICoreWebView2_22>();\n  if (!webview22) {\n    Logger().warn(\n        \"ICoreWebView2_22 interface not available, custom resource interception disabled\");\n    return S_OK;\n  }\n\n  constexpr auto source_kinds = COREWEBVIEW2_WEB_RESOURCE_REQUEST_SOURCE_KINDS_DOCUMENT;\n\n  std::wstring filter;\n  if (Vendor::BuildConfig::is_debug_build()) {\n    filter = config.dev_server_url + L\"/static/*\";\n    Logger().info(\"Debug mode: Intercepting static resources from {}\",\n                  Utils::String::ToUtf8(filter));\n  } else {\n    filter = L\"https://\" + config.static_host_name + L\"/*\";\n    Logger().info(\"Release mode: Intercepting static resources from {}\",\n                  Utils::String::ToUtf8(filter));\n  }\n\n  HRESULT hr = webview22->AddWebResourceRequestedFilterWithRequestSourceKinds(\n      filter.c_str(), COREWEBVIEW2_WEB_RESOURCE_CONTEXT_IMAGE, source_kinds);\n  if (FAILED(hr)) {\n    Logger().warn(\"Failed to add image WebResourceRequested filter: {}\", hr);\n    return hr;\n  }\n\n  // <video src> 等请求常落在 MEDIA/OTHER，仅挂 IMAGE 时无法拦截图库原片 URL。\n  hr = webview22->AddWebResourceRequestedFilterWithRequestSourceKinds(\n      filter.c_str(), COREWEBVIEW2_WEB_RESOURCE_CONTEXT_MEDIA, source_kinds);\n  if (FAILED(hr)) {\n    Logger().warn(\"Failed to add media WebResourceRequested filter: {}\", hr);\n    return hr;\n  }\n\n  // OTHER：兜底部分导航/子资源上下文，避免漏拦。\n  hr = webview22->AddWebResourceRequestedFilterWithRequestSourceKinds(\n      filter.c_str(), COREWEBVIEW2_WEB_RESOURCE_CONTEXT_OTHER, source_kinds);\n  if (FAILED(hr)) {\n    Logger().warn(\"Failed to add WebResourceRequested filter: {}\", hr);\n    return hr;\n  }\n\n  auto app_state_ptr = &state;\n  auto web_resource_requested_handler =\n      Microsoft::WRL::Callback<ICoreWebView2WebResourceRequestedEventHandler>(\n          [app_state_ptr, environment](\n              ICoreWebView2* sender, ICoreWebView2WebResourceRequestedEventArgs* args) -> HRESULT {\n            return handle_custom_web_resource_request(*app_state_ptr, environment, args);\n          });\n\n  EventRegistrationToken token;\n  hr = webview->add_WebResourceRequested(web_resource_requested_handler.Get(), &token);\n  if (FAILED(hr)) {\n    Logger().warn(\"Failed to register WebResourceRequested handler: {}\", hr);\n    return hr;\n  }\n\n  resources.webresource_requested_tokens.push_back(token);\n  Logger().info(\"Custom WebResourceRequested handler registered\");\n  return S_OK;\n}\n\n}  // namespace Core::WebView::Static\n"
  },
  {
    "path": "src/core/webview/static.ixx",
    "content": "module;\n\n#include <wil/com.h>\n\n#include <WebView2.h>\n\nexport module Core.WebView.Static;\n\nimport std;\nimport Core.State;\nimport Core.WebView.Types;\nimport Core.WebView.State;\n\nnamespace Core::WebView::Static {\n\n// 注册 WebView 资源解析器（接受 AppState）\nexport auto register_web_resource_resolver(Core::State::AppState& state, std::wstring prefix,\n                                           Types::WebResourceResolver resolver) -> void;\n\n// 设置 WebResourceRequested 拦截\nexport auto setup_resource_interception(Core::State::AppState& state, ICoreWebView2* webview,\n                                        ICoreWebView2Environment* environment,\n                                        Core::WebView::State::CoreResources& resources,\n                                        Core::WebView::State::WebViewConfig& config) -> HRESULT;\n\n}  // namespace Core::WebView::Static\n"
  },
  {
    "path": "src/core/webview/types.ixx",
    "content": "module;\n\nexport module Core.WebView.Types;\n\nimport std;\n\nexport namespace Core::WebView::Types {\n\nstruct WebResourceResolution {\n  bool success;\n  std::filesystem::path file_path;\n  std::string error_message;\n  std::optional<std::wstring> content_type;\n  std::optional<int> status_code;\n  std::optional<std::wstring> cache_control_header;\n};\n\nusing WebResourceResolver = std::function<WebResourceResolution(std::wstring_view)>;\n\nstruct WebResolverEntry {\n  std::wstring prefix;\n  WebResourceResolver resolver;\n};\n\n// 使用 RCU 模式的注册表（无锁读取）\nstruct WebResolverRegistry {\n  // 使用 atomic shared_ptr 实现无锁读取（RCU 模式）\n  // 读取时无需加锁，写入时复制整个 vector\n  std::atomic<std::shared_ptr<const std::vector<WebResolverEntry>>> resolvers{\n      std::make_shared<const std::vector<WebResolverEntry>>()};\n\n  // 写锁：仅用于保护写操作之间的竞争\n  std::mutex write_mutex;\n};\n\n}  // namespace Core::WebView::Types\n"
  },
  {
    "path": "src/core/webview/webview.cpp",
    "content": "module;\n\n#include <wil/com.h>\n\n#include <WebView2.h>  // 必须放最后面\n\nmodule Core.WebView;\n\nimport std;\nimport Core.State;\nimport Core.WebView.Host;\nimport Core.WebView.State;\nimport Utils.Logger;\nimport Utils.String;\nimport <windows.h>;\nimport <windowsx.h>;\n\nnamespace Core::WebView::Detail {\n\nauto to_mouse_virtual_keys(WPARAM wparam) -> COREWEBVIEW2_MOUSE_EVENT_VIRTUAL_KEYS {\n  COREWEBVIEW2_MOUSE_EVENT_VIRTUAL_KEYS keys = COREWEBVIEW2_MOUSE_EVENT_VIRTUAL_KEYS_NONE;\n  if (wparam & MK_LBUTTON)\n    keys = static_cast<COREWEBVIEW2_MOUSE_EVENT_VIRTUAL_KEYS>(\n        keys | COREWEBVIEW2_MOUSE_EVENT_VIRTUAL_KEYS_LEFT_BUTTON);\n  if (wparam & MK_RBUTTON)\n    keys = static_cast<COREWEBVIEW2_MOUSE_EVENT_VIRTUAL_KEYS>(\n        keys | COREWEBVIEW2_MOUSE_EVENT_VIRTUAL_KEYS_RIGHT_BUTTON);\n  if (wparam & MK_MBUTTON)\n    keys = static_cast<COREWEBVIEW2_MOUSE_EVENT_VIRTUAL_KEYS>(\n        keys | COREWEBVIEW2_MOUSE_EVENT_VIRTUAL_KEYS_MIDDLE_BUTTON);\n  if (wparam & MK_XBUTTON1)\n    keys = static_cast<COREWEBVIEW2_MOUSE_EVENT_VIRTUAL_KEYS>(\n        keys | COREWEBVIEW2_MOUSE_EVENT_VIRTUAL_KEYS_X_BUTTON1);\n  if (wparam & MK_XBUTTON2)\n    keys = static_cast<COREWEBVIEW2_MOUSE_EVENT_VIRTUAL_KEYS>(\n        keys | COREWEBVIEW2_MOUSE_EVENT_VIRTUAL_KEYS_X_BUTTON2);\n  if (wparam & MK_SHIFT)\n    keys = static_cast<COREWEBVIEW2_MOUSE_EVENT_VIRTUAL_KEYS>(\n        keys | COREWEBVIEW2_MOUSE_EVENT_VIRTUAL_KEYS_SHIFT);\n  if (wparam & MK_CONTROL)\n    keys = static_cast<COREWEBVIEW2_MOUSE_EVENT_VIRTUAL_KEYS>(\n        keys | COREWEBVIEW2_MOUSE_EVENT_VIRTUAL_KEYS_CONTROL);\n  return keys;\n}\n\nauto to_mouse_event_kind(UINT msg) -> std::optional<COREWEBVIEW2_MOUSE_EVENT_KIND> {\n  switch (msg) {\n    case WM_MOUSEMOVE:\n      return COREWEBVIEW2_MOUSE_EVENT_KIND_MOVE;\n    case WM_LBUTTONDOWN:\n      return COREWEBVIEW2_MOUSE_EVENT_KIND_LEFT_BUTTON_DOWN;\n    case WM_LBUTTONUP:\n      return COREWEBVIEW2_MOUSE_EVENT_KIND_LEFT_BUTTON_UP;\n    case WM_LBUTTONDBLCLK:\n      return COREWEBVIEW2_MOUSE_EVENT_KIND_LEFT_BUTTON_DOUBLE_CLICK;\n    case WM_RBUTTONDOWN:\n      return COREWEBVIEW2_MOUSE_EVENT_KIND_RIGHT_BUTTON_DOWN;\n    case WM_RBUTTONUP:\n      return COREWEBVIEW2_MOUSE_EVENT_KIND_RIGHT_BUTTON_UP;\n    case WM_RBUTTONDBLCLK:\n      return COREWEBVIEW2_MOUSE_EVENT_KIND_RIGHT_BUTTON_DOUBLE_CLICK;\n    case WM_MBUTTONDOWN:\n      return COREWEBVIEW2_MOUSE_EVENT_KIND_MIDDLE_BUTTON_DOWN;\n    case WM_MBUTTONUP:\n      return COREWEBVIEW2_MOUSE_EVENT_KIND_MIDDLE_BUTTON_UP;\n    case WM_MBUTTONDBLCLK:\n      return COREWEBVIEW2_MOUSE_EVENT_KIND_MIDDLE_BUTTON_DOUBLE_CLICK;\n    case WM_XBUTTONDOWN:\n      return COREWEBVIEW2_MOUSE_EVENT_KIND_X_BUTTON_DOWN;\n    case WM_XBUTTONUP:\n      return COREWEBVIEW2_MOUSE_EVENT_KIND_X_BUTTON_UP;\n    case WM_XBUTTONDBLCLK:\n      return COREWEBVIEW2_MOUSE_EVENT_KIND_X_BUTTON_DOUBLE_CLICK;\n    case WM_MOUSEWHEEL:\n      return COREWEBVIEW2_MOUSE_EVENT_KIND_WHEEL;\n    case WM_MOUSEHWHEEL:\n      return COREWEBVIEW2_MOUSE_EVENT_KIND_HORIZONTAL_WHEEL;\n    default:\n      return std::nullopt;\n  }\n}\n\nauto to_non_client_hit_test(COREWEBVIEW2_NON_CLIENT_REGION_KIND kind) -> LRESULT {\n  switch (kind) {\n    case COREWEBVIEW2_NON_CLIENT_REGION_KIND_CAPTION:\n      return HTCAPTION;\n    case COREWEBVIEW2_NON_CLIENT_REGION_KIND_MINIMIZE:\n      return HTMINBUTTON;\n    case COREWEBVIEW2_NON_CLIENT_REGION_KIND_MAXIMIZE:\n      return HTMAXBUTTON;\n    case COREWEBVIEW2_NON_CLIENT_REGION_KIND_CLOSE:\n      return HTCLOSE;\n    case COREWEBVIEW2_NON_CLIENT_REGION_KIND_CLIENT:\n    case COREWEBVIEW2_NON_CLIENT_REGION_KIND_NOWHERE:\n    default:\n      return HTCLIENT;\n  }\n}\n\n}  // namespace Core::WebView::Detail\n\nnamespace Core::WebView {\n\nnamespace Detail {\n\nauto snapshot_virtual_host_folder_mappings(Core::State::AppState& state)\n    -> std::pair<std::unordered_map<std::wstring, Core::WebView::State::VirtualHostFolderMapping>,\n                 std::unordered_set<std::wstring>> {\n  auto& resources = state.webview->resources;\n\n  std::lock_guard<std::mutex> lock(resources.virtual_host_folder_mappings_mutex);\n  return {resources.virtual_host_folder_mappings, resources.applied_virtual_host_folder_mappings};\n}\n\nauto store_applied_virtual_host_folder_mappings(Core::State::AppState& state,\n                                                std::unordered_set<std::wstring> applied_hosts)\n    -> void {\n  auto& resources = state.webview->resources;\n\n  std::lock_guard<std::mutex> lock(resources.virtual_host_folder_mappings_mutex);\n  resources.applied_virtual_host_folder_mappings = std::move(applied_hosts);\n}\n\nauto clear_applied_virtual_host_folder_mappings(Core::State::AppState& state) -> void {\n  auto& resources = state.webview->resources;\n\n  std::lock_guard<std::mutex> lock(resources.virtual_host_folder_mappings_mutex);\n  resources.applied_virtual_host_folder_mappings.clear();\n}\n\n}  // namespace Detail\n\nauto get_runtime_version() -> std::expected<std::string, std::string> {\n  LPWSTR version_raw = nullptr;\n  auto hr = GetAvailableCoreWebView2BrowserVersionString(nullptr, &version_raw);\n  if (FAILED(hr)) {\n    return std::unexpected(\n        std::format(\"GetAvailableCoreWebView2BrowserVersionString failed: 0x{:08X}\",\n                    static_cast<unsigned>(hr)));\n  }\n\n  if (!version_raw) {\n    return std::unexpected(\"WebView2 runtime version string is empty\");\n  }\n\n  std::string version = Utils::String::ToUtf8(version_raw);\n  CoTaskMemFree(version_raw);\n  return version;\n}\n\nauto initialize(Core::State::AppState& state, HWND webview_hwnd)\n    -> std::expected<void, std::string> {\n  auto& webview_state = *state.webview;\n\n  if (webview_state.is_initialized) {\n    return std::unexpected(\"WebView already initialized\");\n  }\n\n  try {\n    // 使用 wil RAII 初始化 COM，static 确保整个应用生命周期内有效\n    static auto coinit = wil::CoInitializeEx(COINIT_APARTMENTTHREADED);\n    (void)coinit;\n\n    auto init_result = Core::WebView::Host::start_environment_creation(state, webview_hwnd);\n    if (!init_result) {\n      return std::unexpected(init_result.error());\n    }\n\n    webview_state.is_initialized = true;\n    webview_state.has_initial_content = false;\n    Logger().info(\"WebView2 initialization started\");\n    return {};\n  } catch (const wil::ResultException& e) {\n    auto error_msg = std::format(\"Failed to initialize WebView2: {} (HRESULT: 0x{:08X})\", e.what(),\n                                 static_cast<unsigned>(e.GetErrorCode()));\n    Logger().error(error_msg);\n    return std::unexpected(error_msg);\n  } catch (const std::exception& e) {\n    auto error_msg = std::format(\"Unexpected error during WebView2 initialization: {}\", e.what());\n    Logger().error(error_msg);\n    return std::unexpected(error_msg);\n  }\n}\n\nauto resize_webview(Core::State::AppState& state, int width, int height) -> void {\n  auto& webview_state = *state.webview;\n\n  if (webview_state.resources.controller) {\n    webview_state.window.width = width;\n    webview_state.window.height = height;\n\n    RECT bounds = {0, 0, width, height};\n    webview_state.resources.controller.get()->put_Bounds(bounds);\n    Logger().debug(\"WebView resized to {}x{}\", width, height);\n  }\n}\n\nauto navigate_to_url(Core::State::AppState& state, const std::wstring& url)\n    -> std::expected<void, std::string> {\n  auto& webview_state = *state.webview;\n\n  if (!webview_state.is_ready) {\n    return std::unexpected(\"WebView not ready\");\n  }\n\n  auto hr = webview_state.resources.webview.get()->Navigate(url.c_str());\n  if (FAILED(hr)) {\n    return std::unexpected(\"Failed to navigate to URL\");\n  }\n\n  webview_state.resources.current_url = url;\n  Logger().info(\"Navigating to: {}\", Utils::String::ToUtf8(url));\n  return {};\n}\n\nauto shutdown(Core::State::AppState& state) -> void {\n  auto& webview_state = *state.webview;\n\n  if (webview_state.resources.controller) {\n    webview_state.resources.controller.get()->Close();\n  }\n\n  webview_state.resources.webview.reset();\n  webview_state.resources.composition_controller4.reset();\n  webview_state.resources.composition_controller.reset();\n  webview_state.resources.controller.reset();\n  webview_state.resources.environment.reset();\n  Detail::clear_applied_virtual_host_folder_mappings(state);\n  Core::WebView::Host::reset_host_runtime(state);\n\n  webview_state.is_initialized = false;\n  webview_state.is_ready = false;\n  webview_state.has_initial_content = false;\n  webview_state.window.is_visible = false;\n\n  Logger().info(\"WebView shutdown completed\");\n}\n\nauto post_message(Core::State::AppState& state, const std::string& message) -> void {\n  auto& webview_state = *state.webview;\n\n  if (webview_state.is_ready) {\n    std::wstring wmessage = Utils::String::FromUtf8(message);\n    webview_state.resources.webview.get()->PostWebMessageAsJson(wmessage.c_str());\n    Logger().debug(\"Posted message to WebView\");\n  }\n}\n\nauto register_message_handler(Core::State::AppState& state, const std::string& message_type,\n                              std::function<void(const std::string&)> handler) -> void {\n  auto& webview_state = *state.webview;\n\n  std::lock_guard<std::mutex> lock(webview_state.messaging.message_mutex);\n  webview_state.messaging.handlers[message_type] = std::move(handler);\n  Logger().debug(\"Registered message handler for type: {}\", message_type);\n}\n\nauto register_document_created_script(Core::State::AppState& state, std::string script_id,\n                                      std::wstring script_source) -> void {\n  if (!state.webview) {\n    return;\n  }\n\n  auto& resources = state.webview->resources;\n  {\n    std::lock_guard<std::mutex> lock(resources.document_created_scripts_mutex);\n    resources.document_created_scripts[script_id] = Core::WebView::State::DocumentCreatedScript{\n        .id = std::move(script_id),\n        .script = std::move(script_source),\n    };\n  }\n\n  Logger().debug(\"Registered WebView document-created script\");\n}\n\n// 注册一条“虚拟 host -> 本地目录”的映射。\n// 这里只登记状态；真正的 WebView COM 调用必须统一回到 WebView 所在线程执行。\nauto register_virtual_host_folder_mapping(\n    Core::State::AppState& state, std::wstring host_name, std::wstring folder_path,\n    Core::WebView::State::VirtualHostResourceAccessKind access_kind) -> void {\n  auto& resources = state.webview->resources;\n  {\n    std::lock_guard<std::mutex> lock(resources.virtual_host_folder_mappings_mutex);\n    resources.virtual_host_folder_mappings[host_name] =\n        Core::WebView::State::VirtualHostFolderMapping{.folder_path = folder_path,\n                                                       .access_kind = access_kind};\n  }\n\n  Logger().debug(\"Queued WebView virtual host mapping: {} -> {}\", Utils::String::ToUtf8(host_name),\n                 Utils::String::ToUtf8(folder_path));\n  request_virtual_host_folder_mapping_reconcile(state);\n}\n\n// 注销一条虚拟 host 映射。\n// root watch 被移除时，需要把对应的 r-<rootId>.test 一并移除，避免旧 URL 继续生效。\nauto unregister_virtual_host_folder_mapping(Core::State::AppState& state,\n                                            std::wstring_view host_name) -> void {\n  auto& resources = state.webview->resources;\n  {\n    std::lock_guard<std::mutex> lock(resources.virtual_host_folder_mappings_mutex);\n    resources.virtual_host_folder_mappings.erase(std::wstring(host_name));\n  }\n\n  Logger().debug(\"Removed queued WebView virtual host mapping: {}\",\n                 Utils::String::ToUtf8(std::wstring(host_name)));\n  request_virtual_host_folder_mapping_reconcile(state);\n}\n\n// 请求协调虚拟主机映射。\n// 通过 PostMessage 将任务发布到 WebView 窗口消息队列，确保 WebView COM 调用在正确的线程上执行。\nauto request_virtual_host_folder_mapping_reconcile(Core::State::AppState& state) -> void {\n  if (!state.webview) {\n    return;\n  }\n\n  auto hwnd = state.webview->window.webview_hwnd;\n  if (!hwnd) {\n    return;\n  }\n\n  if (!PostMessageW(hwnd, Core::WebView::State::kWM_APP_RECONCILE_VIRTUAL_HOST_MAPPINGS, 0, 0)) {\n    Logger().debug(\"Skipped WebView virtual host mapping reconcile request: hwnd={} err={}\",\n                   reinterpret_cast<std::uintptr_t>(hwnd), GetLastError());\n  }\n}\n\n// 执行虚拟主机映射协调。\n// 比对 desired_mappings（期望状态）与 applied_hosts（已应用状态），执行差量更新：\n// - 已应用但不在期望中的映射调用 ClearVirtualHostNameToFolderMapping 清除\n// - 期望中但未应用的映射调用 SetVirtualHostNameToFolderMapping 添加\n// - 已应用且仍在期望中的映射保持不变\nauto reconcile_virtual_host_folder_mappings(Core::State::AppState& state) -> void {\n  if (!state.webview) {\n    return;\n  }\n\n  auto* webview = state.webview->resources.webview.get();\n  if (!webview) {\n    Detail::clear_applied_virtual_host_folder_mappings(state);\n    return;\n  }\n\n  auto [desired_mappings, applied_hosts] = Detail::snapshot_virtual_host_folder_mappings(state);\n\n  wil::com_ptr<ICoreWebView2_3> webview3;\n  auto hr = webview->QueryInterface(IID_PPV_ARGS(&webview3));\n  if (FAILED(hr) || !webview3) {\n    Logger().warn(\"Failed to query ICoreWebView2_3 for virtual host mapping reconcile: {}\", hr);\n    return;\n  }\n\n  auto next_applied_hosts = applied_hosts;\n\n  for (const auto& applied_host : applied_hosts) {\n    if (desired_mappings.contains(applied_host)) {\n      continue;\n    }\n\n    hr = webview3->ClearVirtualHostNameToFolderMapping(applied_host.c_str());\n    if (FAILED(hr)) {\n      Logger().warn(\"Failed to clear WebView virtual host mapping {}: {}\",\n                    Utils::String::ToUtf8(applied_host), hr);\n      continue;\n    }\n\n    next_applied_hosts.erase(applied_host);\n    Logger().info(\"Cleared WebView virtual host mapping: {}\", Utils::String::ToUtf8(applied_host));\n  }\n\n  for (const auto& [host_name, mapping] : desired_mappings) {\n    hr = webview3->SetVirtualHostNameToFolderMapping(\n        host_name.c_str(), mapping.folder_path.c_str(),\n        static_cast<COREWEBVIEW2_HOST_RESOURCE_ACCESS_KIND>(mapping.access_kind));\n    if (FAILED(hr)) {\n      Logger().warn(\"Failed to apply WebView virtual host mapping {} -> {}: {}\",\n                    Utils::String::ToUtf8(host_name), Utils::String::ToUtf8(mapping.folder_path),\n                    hr);\n      continue;\n    }\n\n    next_applied_hosts.insert(host_name);\n    Logger().info(\"Applied WebView virtual host mapping: {} -> {}\",\n                  Utils::String::ToUtf8(host_name), Utils::String::ToUtf8(mapping.folder_path));\n  }\n\n  Detail::store_applied_virtual_host_folder_mappings(state, std::move(next_applied_hosts));\n}\n\nauto apply_background_mode_from_settings(Core::State::AppState& state) -> void {\n  Core::WebView::Host::apply_background_mode_from_settings(state);\n}\n\nauto get_loading_background_color(Core::State::AppState& state) -> COLORREF {\n  return Core::WebView::Host::get_loading_background_color(state);\n}\n\nauto is_composition_active(Core::State::AppState& state) -> bool {\n  return state.webview->resources.composition_controller != nullptr;\n}\n\nauto forward_mouse_message(Core::State::AppState& state, HWND hwnd, UINT msg, WPARAM wparam,\n                           LPARAM lparam) -> bool {\n  auto& webview_state = *state.webview;\n  auto* composition_controller = webview_state.resources.composition_controller.get();\n  if (!composition_controller) {\n    return false;\n  }\n\n  auto event_kind = Detail::to_mouse_event_kind(msg);\n  if (!event_kind) {\n    return false;\n  }\n\n  COREWEBVIEW2_MOUSE_EVENT_VIRTUAL_KEYS keys = Detail::to_mouse_virtual_keys(wparam);\n\n  UINT32 mouse_data = 0;\n  if (msg == WM_MOUSEWHEEL || msg == WM_MOUSEHWHEEL) {\n    mouse_data = static_cast<UINT32>(GET_WHEEL_DELTA_WPARAM(wparam));\n  } else if (msg == WM_XBUTTONDOWN || msg == WM_XBUTTONUP || msg == WM_XBUTTONDBLCLK) {\n    mouse_data = static_cast<UINT32>(GET_XBUTTON_WPARAM(wparam));\n  }\n\n  POINT point;\n  point.x = GET_X_LPARAM(lparam);\n  point.y = GET_Y_LPARAM(lparam);\n  if (msg == WM_MOUSEWHEEL || msg == WM_MOUSEHWHEEL) {\n    ScreenToClient(hwnd, &point);\n  }\n\n  composition_controller->SendMouseInput(*event_kind, keys, mouse_data, point);\n  return true;\n}\n\nauto forward_non_client_right_button_message(Core::State::AppState& state, HWND hwnd, UINT msg,\n                                             WPARAM wparam, LPARAM lparam) -> bool {\n  (void)wparam;\n\n  auto& webview_state = *state.webview;\n  auto* composition_controller4 = webview_state.resources.composition_controller4.get();\n  if (!composition_controller4) {\n    return false;\n  }\n\n  COREWEBVIEW2_MOUSE_EVENT_KIND event_kind;\n  if (msg == WM_NCRBUTTONDOWN) {\n    event_kind = COREWEBVIEW2_MOUSE_EVENT_KIND_NON_CLIENT_RIGHT_BUTTON_DOWN;\n  } else if (msg == WM_NCRBUTTONUP) {\n    event_kind = COREWEBVIEW2_MOUSE_EVENT_KIND_NON_CLIENT_RIGHT_BUTTON_UP;\n  } else {\n    return false;\n  }\n\n  COREWEBVIEW2_MOUSE_EVENT_VIRTUAL_KEYS keys = COREWEBVIEW2_MOUSE_EVENT_VIRTUAL_KEYS_RIGHT_BUTTON;\n  if (GetKeyState(VK_SHIFT) < 0) {\n    keys = static_cast<COREWEBVIEW2_MOUSE_EVENT_VIRTUAL_KEYS>(\n        keys | COREWEBVIEW2_MOUSE_EVENT_VIRTUAL_KEYS_SHIFT);\n  }\n  if (GetKeyState(VK_CONTROL) < 0) {\n    keys = static_cast<COREWEBVIEW2_MOUSE_EVENT_VIRTUAL_KEYS>(\n        keys | COREWEBVIEW2_MOUSE_EVENT_VIRTUAL_KEYS_CONTROL);\n  }\n\n  POINT point;\n  point.x = GET_X_LPARAM(lparam);\n  point.y = GET_Y_LPARAM(lparam);\n  ScreenToClient(hwnd, &point);\n\n  composition_controller4->SendMouseInput(event_kind, keys, 0, point);\n  return true;\n}\n\nauto hit_test_non_client_region(Core::State::AppState& state, HWND hwnd, LPARAM lparam)\n    -> std::optional<LRESULT> {\n  auto& webview_state = *state.webview;\n  auto* composition_controller4 = webview_state.resources.composition_controller4.get();\n  if (!composition_controller4) {\n    return std::nullopt;\n  }\n\n  POINT point;\n  point.x = GET_X_LPARAM(lparam);\n  point.y = GET_Y_LPARAM(lparam);\n  ScreenToClient(hwnd, &point);\n\n  COREWEBVIEW2_NON_CLIENT_REGION_KIND region_kind = COREWEBVIEW2_NON_CLIENT_REGION_KIND_CLIENT;\n  auto hr = composition_controller4->GetNonClientRegionAtPoint(point, &region_kind);\n  if (FAILED(hr)) {\n    return std::nullopt;\n  }\n\n  return Detail::to_non_client_hit_test(region_kind);\n}\n\nauto send_message(Core::State::AppState& state, const std::string& message)\n    -> std::expected<std::string, std::string> {\n  // 这是一个简化版本，实际应该实现完整的请求-响应机制\n  post_message(state, message);\n  return std::string(\"Message sent\");\n}\n\n}  // namespace Core::WebView\n"
  },
  {
    "path": "src/core/webview/webview.ixx",
    "content": "module;\n\nexport module Core.WebView;\n\nimport std;\nimport Core.State;\nimport Core.WebView.State;\nimport Core.WebView.Static;\nimport Core.WebView.Types;\nimport <windows.h>;\n\nnamespace Core::WebView {\n\n// 初始化函数\nexport auto initialize(Core::State::AppState& state, HWND webview_hwnd)\n    -> std::expected<void, std::string>;\n\n// 检测本机 WebView2 Runtime 版本\nexport auto get_runtime_version() -> std::expected<std::string, std::string>;\n\n// 销毁函数\nexport auto shutdown(Core::State::AppState& state) -> void;\n\n// 窗口操作\nexport auto resize_webview(Core::State::AppState& state, int width, int height) -> void;\n\n// 导航操作\nexport auto navigate_to_url(Core::State::AppState& state, const std::wstring& url)\n    -> std::expected<void, std::string>;\n\n// 消息通信\nexport auto send_message(Core::State::AppState& state, const std::string& message)\n    -> std::expected<std::string, std::string>;\nexport auto post_message(Core::State::AppState& state, const std::string& message) -> void;\nexport auto register_message_handler(Core::State::AppState& state, const std::string& message_type,\n                                     std::function<void(const std::string&)> handler) -> void;\nexport auto register_document_created_script(Core::State::AppState& state, std::string script_id,\n                                             std::wstring script_source) -> void;\nexport auto register_virtual_host_folder_mapping(\n    Core::State::AppState& state, std::wstring host_name, std::wstring folder_path,\n    Core::WebView::State::VirtualHostResourceAccessKind access_kind) -> void;\nexport auto unregister_virtual_host_folder_mapping(Core::State::AppState& state,\n                                                   std::wstring_view host_name) -> void;\n// 请求协调虚拟主机映射：通过 PostMessage 触发窗口线程执行 reconcile\nexport auto request_virtual_host_folder_mapping_reconcile(Core::State::AppState& state) -> void;\n// 执行虚拟主机映射协调：在 WebView 所在线程中比对期望状态与已应用状态，差量更新\nexport auto reconcile_virtual_host_folder_mappings(Core::State::AppState& state) -> void;\nexport auto apply_background_mode_from_settings(Core::State::AppState& state) -> void;\nexport auto get_loading_background_color(Core::State::AppState& state) -> COLORREF;\nexport auto is_composition_active(Core::State::AppState& state) -> bool;\n\n// Composition hosting 输入转发\nexport auto forward_mouse_message(Core::State::AppState& state, HWND hwnd, UINT msg, WPARAM wparam,\n                                  LPARAM lparam) -> bool;\nexport auto forward_non_client_right_button_message(Core::State::AppState& state, HWND hwnd,\n                                                    UINT msg, WPARAM wparam, LPARAM lparam) -> bool;\nexport auto hit_test_non_client_region(Core::State::AppState& state, HWND hwnd, LPARAM lparam)\n    -> std::optional<LRESULT>;\n\n}  // namespace Core::WebView\n"
  },
  {
    "path": "src/core/worker_pool/state.ixx",
    "content": "module;\n\nexport module Core.WorkerPool.State;\n\nimport std;\n\nexport namespace Core::WorkerPool::State {\n\nstruct WorkerPoolState {\n  // 工作线程池\n  std::vector<std::jthread> worker_threads;\n\n  // 任务队列和同步原语\n  std::queue<std::function<void()>> task_queue;\n  std::mutex queue_mutex;\n  std::condition_variable condition;\n\n  // 运行状态\n  std::atomic<bool> is_running{false};\n  std::atomic<bool> shutdown_requested{false};\n};\n\n}  // namespace Core::WorkerPool::State\n"
  },
  {
    "path": "src/core/worker_pool/worker_pool.cpp",
    "content": "module;\n\nmodule Core.WorkerPool;\n\nimport std;\nimport Core.WorkerPool.State;\nimport Utils.Logger;\n\nnamespace Core::WorkerPool {\n\nauto start(Core::WorkerPool::State::WorkerPoolState& pool, size_t thread_count)\n    -> std::expected<void, std::string> {\n  // 检查是否已经运行\n  if (pool.is_running.exchange(true)) {\n    Logger().warn(\"WorkerPool already started\");\n    return std::unexpected(\"WorkerPool already started\");\n  }\n\n  try {\n    // 确定线程数\n    if (thread_count == 0) {\n      thread_count = std::thread::hardware_concurrency();\n      if (thread_count == 0) thread_count = 2;  // 备用值\n    }\n\n    Logger().info(\"Starting WorkerPool with {} threads\", thread_count);\n\n    // 创建工作线程池\n    pool.worker_threads.reserve(thread_count);\n    for (size_t i = 0; i < thread_count; ++i) {\n      pool.worker_threads.emplace_back([&pool, i]() {\n        try {\n          // 工作线程主循环\n          while (!pool.shutdown_requested.load()) {\n            std::function<void()> task;\n\n            // 从任务队列获取任务\n            {\n              std::unique_lock<std::mutex> lock(pool.queue_mutex);\n              pool.condition.wait(lock, [&pool] {\n                return pool.shutdown_requested.load() || !pool.task_queue.empty();\n              });\n\n              // 如果收到关闭信号且队列为空，退出线程\n              if (pool.shutdown_requested.load() && pool.task_queue.empty()) {\n                break;\n              }\n\n              // 获取任务\n              if (!pool.task_queue.empty()) {\n                task = std::move(pool.task_queue.front());\n                pool.task_queue.pop();\n              }\n            }\n\n            // 执行任务\n            if (task) {\n              try {\n                task();\n              } catch (const std::exception& e) {\n                Logger().error(\"WorkerPool task execution error: {}\", e.what());\n              } catch (...) {\n                Logger().error(\"WorkerPool task execution unknown error\");\n              }\n            }\n          }\n        } catch (const std::exception& e) {\n          Logger().error(\"WorkerPool worker thread {} error: {}\", i, e.what());\n        }\n      });\n    }\n\n    Logger().info(\"WorkerPool started successfully\");\n    return {};\n\n  } catch (const std::exception& e) {\n    // 启动失败，恢复状态\n    pool.is_running = false;\n    pool.worker_threads.clear();\n\n    auto error_msg = std::format(\"Failed to start WorkerPool: {}\", e.what());\n    Logger().error(error_msg);\n    return std::unexpected(error_msg);\n  }\n}\n\nauto stop(Core::WorkerPool::State::WorkerPoolState& pool) -> void {\n  if (!pool.is_running.exchange(false)) {\n    return;  // 已经停止\n  }\n\n  Logger().info(\"Stopping WorkerPool\");\n\n  try {\n    // 标记关闭请求\n    pool.shutdown_requested = true;\n\n    // 唤醒所有工作线程\n    pool.condition.notify_all();\n\n    // 等待所有工作线程结束\n    for (auto& worker : pool.worker_threads) {\n      if (worker.joinable()) {\n        worker.join();\n      }\n    }\n\n    // 清理资源\n    pool.worker_threads.clear();\n    {\n      std::lock_guard<std::mutex> lock(pool.queue_mutex);\n      // 清空任务队列\n      std::queue<std::function<void()>> empty;\n      pool.task_queue.swap(empty);\n    }\n    pool.shutdown_requested = false;\n\n    Logger().info(\"WorkerPool stopped\");\n\n  } catch (const std::exception& e) {\n    Logger().error(\"Error during WorkerPool shutdown: {}\", e.what());\n  }\n}\n\nauto is_running(const Core::WorkerPool::State::WorkerPoolState& pool) -> bool {\n  return pool.is_running.load();\n}\n\nauto submit_task(Core::WorkerPool::State::WorkerPoolState& pool, std::function<void()> task)\n    -> bool {\n  if (!pool.is_running.load()) {\n    return false;  // 线程池未运行\n  }\n\n  if (pool.shutdown_requested.load()) {\n    return false;  // 正在关闭，不接受新任务\n  }\n\n  try {\n    {\n      std::lock_guard<std::mutex> lock(pool.queue_mutex);\n      pool.task_queue.push(std::move(task));\n    }\n    pool.condition.notify_one();\n    return true;\n  } catch (const std::exception& e) {\n    Logger().error(\"Failed to submit task to WorkerPool: {}\", e.what());\n    return false;\n  }\n}\n\nauto get_thread_count(const Core::WorkerPool::State::WorkerPoolState& pool) -> size_t {\n  return pool.worker_threads.size();\n}\n\nauto get_pending_tasks(Core::WorkerPool::State::WorkerPoolState& pool) -> size_t {\n  std::lock_guard<std::mutex> lock(pool.queue_mutex);\n  return pool.task_queue.size();\n}\n\n}  // namespace Core::WorkerPool\n"
  },
  {
    "path": "src/core/worker_pool/worker_pool.ixx",
    "content": "module;\n\nexport module Core.WorkerPool;\n\nimport std;\nimport Core.WorkerPool.State;\n\nnamespace Core::WorkerPool {\n\n// 启动工作线程池\nexport auto start(Core::WorkerPool::State::WorkerPoolState& pool, size_t thread_count = 0)\n    -> std::expected<void, std::string>;\n\n// 停止工作线程池（优雅关闭）\nexport auto stop(Core::WorkerPool::State::WorkerPoolState& pool) -> void;\n\n// 检查线程池是否正在运行\nexport auto is_running(const Core::WorkerPool::State::WorkerPoolState& pool) -> bool;\n\n// 提交任务到线程池\nexport auto submit_task(Core::WorkerPool::State::WorkerPoolState& pool, std::function<void()> task)\n    -> bool;\n\n// 获取工作线程数量\nexport auto get_thread_count(const Core::WorkerPool::State::WorkerPoolState& pool) -> size_t;\n\n// 获取待处理任务数量\nexport auto get_pending_tasks(Core::WorkerPool::State::WorkerPoolState& pool) -> size_t;\n\n}  // namespace Core::WorkerPool\n"
  },
  {
    "path": "src/extensions/infinity_nikki/game_directory.cpp",
    "content": "module;\n\n#include <wil/com.h>\n\nmodule Extensions.InfinityNikki.GameDirectory;\n\nimport std;\nimport Extensions.InfinityNikki.Types;\nimport Utils.Logger;\nimport Utils.String;\nimport Vendor.ShellApi;\nimport Vendor.Windows;\n\nnamespace Extensions::InfinityNikki::GameDirectory {\n\nauto to_filesystem_path(const std::string& utf8_path) -> std::filesystem::path {\n  return std::filesystem::path(Utils::String::FromUtf8(utf8_path));\n}\n\nauto has_valid_game_executable(const std::string& game_dir_utf8) -> bool {\n  auto game_dir_path = to_filesystem_path(game_dir_utf8);\n  if (game_dir_path.empty() || !std::filesystem::exists(game_dir_path) ||\n      !std::filesystem::is_directory(game_dir_path)) {\n    return false;\n  }\n\n  auto game_exe_path = game_dir_path / L\"InfinityNikki.exe\";\n  return std::filesystem::exists(game_exe_path) && std::filesystem::is_regular_file(game_exe_path);\n}\n\nauto get_game_directory_from_config(const std::filesystem::path& config_path)\n    -> std::expected<std::string, std::string> {\n  constexpr Vendor::Windows::DWORD buffer_size = Vendor::Windows::kMAX_PATH * 2;\n  auto buffer = wil::make_unique_hlocal_nothrow<wchar_t[]>(buffer_size);\n  if (!buffer) {\n    return std::unexpected(\"Memory allocation failed\");\n  }\n\n  Vendor::Windows::DWORD result = Vendor::Windows::GetPrivateProfileStringW(\n      L\"Download\", L\"gameDir\", L\"\", buffer.get(), buffer_size, config_path.wstring().c_str());\n  if (result == 0) {\n    return std::unexpected(\"gameDir not found in config file\");\n  }\n\n  return Utils::String::ToUtf8(buffer.get());\n}\n\nauto get_game_directory() -> std::expected<InfinityNikkiGameDirResult, std::string> {\n  InfinityNikkiGameDirResult result;\n\n  wil::unique_cotaskmem_string local_app_data_path;\n  HRESULT hr = Vendor::ShellApi::SHGetKnownFolderPath(Vendor::ShellApi::kFOLDERID_LocalAppData, 0,\n                                                      nullptr, &local_app_data_path);\n  if (!Vendor::Windows::_SUCCEEDED(hr) || !local_app_data_path) {\n    result.message = \"Failed to get user profile path\";\n    return result;\n  }\n\n  const std::filesystem::path local_app_data_dir = local_app_data_path.get();\n  constexpr std::array launcher_names = {\n      L\"InfinityNikki Launcher\",\n      L\"InfinityNikkiGlobal Launcher\",\n      L\"InfinityNikkiBili Launcher\",\n      L\"InfinityNikkiSteam Launcher\",\n  };\n\n  std::string last_error = \"No valid launcher config found\";\n  for (const auto* launcher_name : launcher_names) {\n    auto config_path = local_app_data_dir / launcher_name / L\"config.ini\";\n\n    Logger().info(\"Checking config file at: {}\", config_path.string());\n\n    if (!std::filesystem::exists(config_path)) {\n      last_error = \"Config file not found at \" + config_path.string();\n      continue;\n    }\n\n    result.config_found = true;\n\n    auto game_dir_result = get_game_directory_from_config(config_path);\n    if (!game_dir_result) {\n      last_error = game_dir_result.error();\n      Logger().warn(\"Failed to read gameDir from config: {}\", config_path.string());\n      continue;\n    }\n\n    auto game_dir = game_dir_result.value();\n    if (!has_valid_game_executable(game_dir)) {\n      last_error = \"Invalid game directory: InfinityNikki.exe not found\";\n      Logger().warn(\"Detected gameDir is invalid: {}\", game_dir);\n      continue;\n    }\n\n    result.game_dir = game_dir;\n    result.game_dir_found = true;\n    result.message = \"Game directory found successfully\";\n    Logger().info(\"Found Infinity Nikki game directory: {}\", *result.game_dir);\n    return result;\n  }\n\n  result.message = last_error;\n  return result;\n}\n\n}  // namespace Extensions::InfinityNikki::GameDirectory\n"
  },
  {
    "path": "src/extensions/infinity_nikki/game_directory.ixx",
    "content": "module;\n\nexport module Extensions.InfinityNikki.GameDirectory;\n\nimport std;\nimport Extensions.InfinityNikki.Types;\n\nnamespace Extensions::InfinityNikki::GameDirectory {\n\nexport auto get_game_directory() -> std::expected<InfinityNikkiGameDirResult, std::string>;\n\n}  // namespace Extensions::InfinityNikki::GameDirectory\n"
  },
  {
    "path": "src/extensions/infinity_nikki/generated/map_injection_script.ixx",
    "content": "// Auto-generated map injection script module\n// DO NOT EDIT - This file is generated by scripts/generate-map-injection-cpp.js\n\nmodule;\n\nexport module Extensions.InfinityNikki.Generated.MapInjectionScript;\n\nimport std;\n\nexport namespace Extensions::InfinityNikki::Generated {\n\nconstexpr std::wstring_view map_bridge_script =\n    LR\"SM_MAP_JS(if(window.location.hostname===\"myl.nuanpaper.com\"){let b;window.__SPINNING_MOMO_ALLOW_DEV_EVAL__=__ALLOW_DEV_EVAL__,window.__SPINNING_MOMO_PENDING_MARKERS__=[],window.__SPINNING_MOMO_RENDER_OPTIONS__={},window.__SPINNING_MOMO_CLUSTER_OPTIONS__={},(()=>{if(window.__SPINNING_MOMO_MAP_SIDEBAR_COLLAPSED__)return;const l=\"#infinitynikki-map-oversea + div > div > div:nth-child(2) > div:first-child\",s=()=>{const N=document.querySelector(l);return N?(N.click(),window.__SPINNING_MOMO_MAP_SIDEBAR_COLLAPSED__=!0,!0):!1};if(s())return;let r=0;const f=40,v=setInterval(()=>{r+=1,(s()||r>=f)&&clearInterval(v)},100)})();const B=l=>{const s=l&&l.L,r=l&&l.map;if(!s||!r)return;const f=Array.isArray(l.markers)?l.markers:[],v=l.renderOptions||{},N=l.runtimeOptions||{},ie=l.flyToFirst===!0,ae=N.clusterEnabled!==!1,z=Number(N.clusterRadius||44),H=N.hoverCardEnabled!==!1,T=\"spinning-momo-photo-pane\",se=()=>{if(document.getElementById(\"spinning-momo-popup-style\"))return;const e=document.createElement(\"style\");e.id=\"spinning-momo-popup-style\",e.textContent=[\".spinning-momo-hover-card-root {\",\"  position: absolute;\",\"  left: 0;\",\"  top: 0;\",\"  z-index: 1200;\",\"  pointer-events: auto;\",\"}\",\".spinning-momo-hover-card-root.is-hidden {\",\"  display: none;\",\"}\",\".spinning-momo-hover-card-shell {\",\"  position: relative;\",\"  display: block;\",\"  width: max-content;\",\"  max-width: 320px;\",\"  border-radius: 12px;\",\"  background: linear-gradient(rgb(240, 222, 208), rgb(245, 236, 227));\",\"  color: rgb(123, 93, 74);\",\"  box-shadow: 0 16px 40px rgba(15, 23, 42, 0.22);\",\"  cursor: default !important;\",\"  will-change: opacity, transform;\",\"}\",'.spinning-momo-hover-card-root[data-placement=\"top\"] .spinning-momo-hover-card-shell {',\"  transform-origin: center bottom;\",\"  animation: spinning-momo-hover-card-enter-from-bottom 160ms cubic-bezier(0.22, 1, 0.36, 1);\",\"}\",'.spinning-momo-hover-card-root[data-placement=\"bottom\"] .spinning-momo-hover-card-shell {',\"  transform-origin: center top;\",\"  animation: spinning-momo-hover-card-enter-from-top 160ms cubic-bezier(0.22, 1, 0.36, 1);\",\"}\",\".spinning-momo-hover-card-inner {\",\"  position: relative;\",\"  z-index: 1;\",\"  border-radius: 12px;\",\"  overflow: hidden;\",\"}\",\".spinning-momo-hover-card-caret {\",\"  position: absolute;\",\"  left: 50%;\",\"  width: 14px;\",\"  height: 14px;\",\"  border-radius: 2px;\",\"  background: linear-gradient(rgb(240, 222, 208), rgb(245, 236, 227));\",\"  transform: translateX(-50%) rotate(45deg);\",\"}\",'.spinning-momo-hover-card-root[data-placement=\"top\"] .spinning-momo-hover-card-caret {',\"  bottom: -7px;\",\"}\",'.spinning-momo-hover-card-root[data-placement=\"bottom\"] .spinning-momo-hover-card-caret {',\"  top: -7px;\",\"}\",\".spinning-momo-popup-body {\",\"  display: block;\",\"  box-sizing: border-box;\",\"  width: auto;\",\"  max-width: 320px;\",\"  max-height: 320px;\",\"  padding: 0.75rem;\",\"}\",\".spinning-momo-popup-title {\",\"  font-size: 13px;\",\"  font-weight: 600;\",\"  line-height: 1.5;\",\"  margin-bottom: 4px;\",\"  color: rgb(123, 93, 74);\",\"  font-family: 'Helvetica Neue', Arial, Helvetica, sans-serif;\",\"}\",\".spinning-momo-popup-thumbnail-block {\",\"  margin-top: 8px;\",\"  max-width: 100%;\",\"}\",\".spinning-momo-popup-thumbnail-link {\",\"  display: block;\",\"  max-width: 296px;\",\"  margin: 0;\",\"}\",\".spinning-momo-popup-thumbnail-image {\",\"  display: block;\",\"  width: auto;\",\"  height: auto;\",\"  max-width: 296px;\",\"  max-height: calc(320px - 4rem);\",\"  border-radius: 6px;\",\"  background: #f2f2f2;\",\"}\",\".spinning-momo-popup-thumbnail-fallback {\",\"  font-size: 12px;\",\"  color: #888;\",\"}\",\"@keyframes spinning-momo-hover-card-enter-from-bottom {\",\"  from {\",\"    opacity: 0;\",\"    transform: translateY(10px) scale(0.96);\",\"  }\",\"  to {\",\"    opacity: 1;\",\"    transform: translateY(0) scale(1);\",\"  }\",\"}\",\"@keyframes spinning-momo-hover-card-enter-from-top {\",\"  from {\",\"    opacity: 0;\",\"    transform: translateY(-10px) scale(0.96);\",\"  }\",\"  to {\",\"    opacity: 1;\",\"    transform: translateY(0) scale(1);\",\"  }\",\"}\"].join(`\n`),document.head.appendChild(e)};((e,t)=>{let o=r.getPane?r.getPane(e):null;return!o&&r.createPane&&(o=r.c)SM_MAP_JS\"\n    LR\"SM_MAP_JS(reatePane(e)),o&&(o.style.zIndex=String(t),o.style.pointerEvents=\"auto\"),o})(T,975),se(),window.__SPINNING_MOMO_RUNTIME__||(window.__SPINNING_MOMO_RUNTIME__={});const n=window.__SPINNING_MOMO_RUNTIME__;n.boundMap&&n.boundMap!==r&&(n.boundRecluster&&n.boundMap.off&&(n.boundMap.off(\"zoomend\",n.boundRecluster),n.boundMap.off(\"moveend\",n.boundRecluster)),n.boundHideHoverCardOnMapMove&&n.boundMap.off&&(n.boundMap.off(\"movestart\",n.boundHideHoverCardOnMapMove),n.boundMap.off(\"zoomstart\",n.boundHideHoverCardOnMapMove),n.boundMap.off(\"resize\",n.boundHideHoverCardOnMapMove)),n.markerLayer&&n.markerLayer.remove&&n.markerLayer.remove(),n.clusterLayer&&n.clusterLayer.remove&&n.clusterLayer.remove(),n.hoverCardRoot&&n.hoverCardRoot.remove&&n.hoverCardRoot.remove(),n.markerLayer=null,n.clusterLayer=null,n.boundRecluster=null,n.hoverCardRoot=null,n.boundHideHoverCardOnMapMove=null,n.activeHoverCardOwner=null,n.activeHoverCardContext=null),n.boundMap=r,n.markerLayer||(n.markerLayer=s.layerGroup().addTo(r)),n.clusterLayer||(n.clusterLayer=s.layerGroup().addTo(r)),n.markers=f,n.renderOptions=v,n.runtimeOptions=N;const F=v.closePopupOnMouseOut!==!1,le=Math.max(0,Number(v.popupOpenDelayMs??180)),ce=Math.max(0,Number(v.popupCloseDelayMs??260)),U=v.keepPopupVisibleOnHover!==!1,de=16,ue=52,pe=(e,t)=>{e.closeTimer&&(clearTimeout(e.closeTimer),e.closeTimer=null),e.openTimer&&clearTimeout(e.openTimer),e.openTimer=setTimeout(()=>{e.openTimer=null,t()},le)},k=(e,t)=>{e.openTimer&&(clearTimeout(e.openTimer),e.openTimer=null),e.closeTimer&&clearTimeout(e.closeTimer),e.closeTimer=setTimeout(()=>{e.closeTimer=null,t()},ce)},me=e=>{e.openTimer&&(clearTimeout(e.openTimer),e.openTimer=null)},P=e=>String(e||\"\").replace(/&/g,\"&amp;\").replace(/</g,\"&lt;\").replace(/>/g,\"&gt;\").replace(/\"/g,\"&quot;\").replace(/'/g,\"&#39;\"),D=e=>{if(!e||!e.querySelectorAll)return;e.querySelectorAll(\"[data-sm-open-asset-id]\").forEach(o=>{const i=o;if(!i||!i.getAttribute)return;const c=i.dataset||{};c.smCardClickBound!==\"true\"&&(c.smCardClickBound=\"true\",i.addEventListener(\"click\",a=>{a.preventDefault(),a.stopPropagation();const u=i.getAttribute(\"data-sm-open-asset-id\"),m=Number(u);if(!Number.isFinite(m))return;const p=i.getAttribute(\"data-sm-open-asset-index\"),h=p!=null,I=h?Number(p):void 0;h&&!Number.isFinite(I)||window.parent&&window.parent!==window&&window.parent.postMessage({action:\"SPINNING_MOMO_OPEN_GALLERY_ASSET\",payload:h?{assetId:m,assetIndex:I}:{assetId:m}},\"*\")}))})},ge=()=>{const e=r&&r.getContainer?r.getContainer():null;if(!e)return null;if(n.hoverCardRoot&&n.hoverCardRoot.isConnected)return n.hoverCardRoot;const t=document.createElement(\"div\");return t.className=\"spinning-momo-hover-card-root is-hidden\",t.addEventListener(\"mouseenter\",()=>{if(!U)return;const o=n.activeHoverCardContext;o&&(o.state.popupHovered=!0,o.state.closeTimer&&(clearTimeout(o.state.closeTimer),o.state.closeTimer=null))}),t.addEventListener(\"mouseleave\",()=>{if(!U)return;const o=n.activeHoverCardContext;o&&(o.state.popupHovered=!1,!o.state.markerHovered&&F&&k(o.state,()=>O(o.ownerId)))}),e.appendChild(t),n.hoverCardRoot=t,t},O=e=>{if(e&&n.activeHoverCardOwner&&n.activeHoverCardOwner!==e)return;const t=n.hoverCardRoot;if(!t){n.activeHoverCardOwner=null,n.activeHoverCardContext=null;return}t.innerHTML=\"\",t.classList.add(\"is-hidden\"),t.removeAttribute(\"data-placement\"),n.activeHoverCardOwner=null,n.activeHoverCardContext=null},fe=()=>{n.boundHideHoverCardOnMapMove||!r||!r.on||(n.boundHideHoverCardOnMapMove=()=>{O()},r.on(\"movestart\",n.boundHideHoverCardOnMapMove),r.on(\"zoomstart\",n.boundHideHoverCardOnMapMove),r.on(\"resize\",n.boundHideHoverCardOnMapMove))},V=e=>{const t=r&&r.getContainer?r.getContainer():null,o=r&&r.latLngToContainerPoint?r.latLngToContainerPoint(e):null;if(!t||!o)return\"top\";const i=Number(t.clientHeight||0);return!Number.isFinite(i)||i<=0?\"top\":Number(o.y||0)<i/2?\"bottom\":\"top\"},j=(e,t,o)=>{if(!e||!r||!r.latLngToContainerPoint)return!1;const i=r.latLngToContainerPoint(t);return i?(e.style.left=String(Number(i.x||0))+\"px\",o===\"bottom\"?(e.style.top=String(Number(i.y||0)+de)+\"px\",e.)SM_MAP_JS\"\n    LR\"SM_MAP_JS(style.transform=\"translate(-50%, 0)\"):(e.style.top=String(Number(i.y||0)-ue)+\"px\",e.style.transform=\"translate(-50%, -100%)\"),e.dataset.placement=o,!0):!1},ve=()=>{const e=n.activeHoverCardContext,t=n.hoverCardRoot;if(!e||!t||!e.latLng)return;const o=V(e.latLng);j(t,e.latLng,o)},he=(e,t)=>{if(!t||!t.latLng)return null;fe();const o=ge();if(!o)return null;const i=t.placement||V(t.latLng);return o.innerHTML='<div class=\"spinning-momo-hover-card-shell\"><div class=\"spinning-momo-hover-card-inner\">'+t.contentHtml+'</div><div class=\"spinning-momo-hover-card-caret\"></div></div>',j(o,t.latLng,i)?(n.activeHoverCardOwner=t.ownerId||null,n.activeHoverCardContext={ownerId:t.ownerId||null,state:e,latLng:t.latLng},o.classList.remove(\"is-hidden\"),D(o),typeof t.afterOpen==\"function\"&&t.afterOpen(o),o):(o.innerHTML=\"\",null)},q=(e,t)=>{!t||!t.latLng||!t.contentHtml||pe(e,()=>{!e.markerHovered&&!e.popupHovered||he(e,{ownerId:t.ownerId,latLng:t.latLng,contentHtml:t.contentHtml,afterOpen:t.afterOpen,placement:t.placement})})},W=e=>{if(!e||typeof e!=\"object\")return'<div style=\"width:100%;height:100%;aspect-ratio:1/1;border-radius:6px;background:#1f2937;display:flex;align-items:center;justify-content:center;color:#9ca3af;font-size:11px;\">\\u65E0\\u56FE</div>';const t=Number.isFinite(Number(e.assetId)),o=t?' data-sm-open-asset-id=\"'+String(e.assetId)+'\"':\"\",c=Number.isFinite(Number(e.assetIndex))?' data-sm-open-asset-index=\"'+String(e.assetIndex)+'\"':\"\",a=t?\"width:100%;height:100%;cursor:pointer;\":\"width:100%;height:100%;\";if(e.thumbnailUrl){const m='<img src=\"'+P(String(e.thumbnailUrl))+'\" loading=\"lazy\" style=\"width:100%;height:100%;aspect-ratio:1/1;object-fit:cover;border-radius:6px;background:#1f2937;display:block;\" />';return\"<div\"+o+c+' style=\"'+a+'\">'+m+\"</div>\"}return\"<div\"+o+c+' style=\"'+a+'\">'+'<div style=\"width:100%;height:100%;aspect-ratio:1/1;border-radius:6px;background:#1f2937;display:flex;align-items:center;justify-content:center;color:#9ca3af;font-size:11px;\">\\u65E0\\u56FE</div>'+\"</div>\"},be=e=>{const t=P(String(e&&e.cardTitle!=null?e.cardTitle:\"\")),o=e&&e.thumbnailUrl?String(e.thumbnailUrl):\"\",i=e&&Number.isFinite(Number(e.assetId)),c=e&&Number.isFinite(Number(e.assetIndex)),a=i?' data-sm-open-asset-id=\"'+String(Number(e.assetId))+'\"':\"\",u=c?' data-sm-open-asset-index=\"'+String(Number(e.assetIndex))+'\"':\"\",m=i?\"cursor:pointer;\":\"\";let p=\"\";return o?p='<div class=\"spinning-momo-popup-thumbnail-block\"><div class=\"spinning-momo-popup-thumbnail-link\" style=\"'+m+'\"'+a+u+'><img class=\"spinning-momo-popup-thumbnail-image\" src=\"'+P(o)+'\" alt=\"'+t+'\" loading=\"eager\" decoding=\"async\" /></div></div>':p='<div class=\"spinning-momo-popup-thumbnail-block\"><div class=\"spinning-momo-popup-thumbnail-link\" style=\"'+m+'\"'+a+u+'><div class=\"spinning-momo-popup-thumbnail-fallback\">\\u65E0\\u56FE</div></div></div>','<div style=\"line-height: 1.5;\"><div class=\"spinning-momo-popup-body\"><div class=\"spinning-momo-popup-title\">'+t+\"</div>\"+p+\"</div></div>\"},_e=(e,t,o)=>'<div data-sm-cluster-grid-root=\"1\" style=\"display:grid;grid-template-columns:repeat('+Math.min(3,Math.max(1,t))+\",\"+o+\"px);grid-auto-rows:\"+o+'px;gap:6px;\">'+e.join(\"\")+\"</div>\",ye=e=>{const t=e.length,o=9,i=t>o?o-1:Math.min(t,o),c=Math.max(0,t-i),a=96,u=e.slice(0,i).map(I=>W(I));c>0&&u.push('<div data-sm-cluster-expand=\"1\" style=\"width:100%;height:100%;aspect-ratio:1/1;border-radius:6px;background:rgba(17,24,39,0.9);display:flex;align-items:center;justify-content:center;color:#e5e7eb;font-size:13px;font-weight:600;cursor:pointer;\">+'+c+\" \\u66F4\\u591A</div>\");const m=Math.min(3,Math.max(1,u.length)),p=N.clusterTitleTemplate||\"{count} \\u5F20\\u7167\\u7247\";return'<div data-sm-cluster-card=\"1\" style=\"padding:0.75rem;\"><div class=\"spinning-momo-popup-title\">'+String(p).replace(/\\{count\\}/g,String(e.length))+\"</div>\"+_e(u,m,a)+\"</div>\"},Ne=e=>{const t=e.reduce((d,g)=>d+Number(g.lat||0),0)/e.length,o=e.reduce((d,g)=>d+Number(g.lng||0),0)/e.length,i=e.length,c=e.map(d=>String(d.assetId??d.name??String(d.lat)+\",\"+String(d.lng))).join(\"|\"),a=96,u=d=>{const g=e.length,x=9,M=g>x?x-1:Math.min(g,x);if(Math.)SM_MAP_JS\"\n    LR\"SM_MAP_JS(max(0,g-M)<=0)return;const _=d.querySelector(\"[data-sm-cluster-grid-root]\");if(!_||_.dataset.smClusterExpanded===\"true\")return;const ee=_.querySelector(\"[data-sm-cluster-expand]\");if(!ee)return;ee.remove();const te=e.slice(M).map(y=>W(y)).join(\"\");te&&_.insertAdjacentHTML(\"beforeend\",te),_.style.gridTemplateColumns=\"repeat(3,\"+a+\"px)\";const R=_.closest(\"[data-sm-cluster-card]\");R&&(R.style.display=\"inline-block\",R.style.maxWidth=\"calc(100vw - 32px)\");const G=_.parentElement;if(G&&G.getAttribute(\"data-sm-cluster-scroll\")!==\"1\"){const y=document.createElement(\"div\");y.setAttribute(\"data-sm-cluster-scroll\",\"1\"),y.style.maxHeight=\"min(60vh, 420px)\",y.style.overflowY=\"auto\",y.style.overscrollBehavior=\"contain\",G.insertBefore(y,_),y.appendChild(_)}_.dataset.smClusterExpanded=\"true\",D(d);const w=d.querySelector(\"[data-sm-cluster-scroll]\");w&&w.dataset.smClusterScrollWheelBound!==\"true\"&&(w.dataset.smClusterScrollWheelBound=\"true\",w.addEventListener(\"wheel\",y=>{y.stopPropagation()},{passive:!0})),ve()},m=Te(i),p=s.marker([t,o],{icon:m,pane:T,interactive:!0}).addTo(n.clusterLayer),h={markerHovered:!1,popupHovered:!1,openTimer:null,closeTimer:null},I=d=>{if(!d||!d.querySelector)return;const g=d.querySelector(\"[data-sm-cluster-expand]\");g&&!g.dataset.smClusterExpandBound&&(g.dataset.smClusterExpandBound=\"true\",g.addEventListener(\"click\",M=>{M.preventDefault(),M.stopPropagation(),h.popupHovered=!0;const A=n.hoverCardRoot;A&&u(A)}));const x=d.querySelector(\"[data-sm-cluster-scroll]\");x&&x.dataset.smClusterScrollWheelBound!==\"true\"&&(x.dataset.smClusterScrollWheelBound=\"true\",x.addEventListener(\"wheel\",M=>{M.stopPropagation()},{passive:!0}))};p.on(\"mouseover\",()=>{h.markerHovered=!0;const d=p.getElement?p.getElement():null;d&&(d.style.cursor=\"pointer\"),H&&q(h,{ownerId:c,latLng:[t,o],contentHtml:ye(e),afterOpen:I})}),p.on(\"mouseout\",()=>{h.markerHovered=!1,H&&(h.popupHovered||k(h,()=>{O(c)}))}),p.on(\"click\",()=>{const d=Math.min((r.getZoom?r.getZoom():6)+2,r.getMaxZoom?r.getMaxZoom():18);r.flyTo&&r.flyTo([t,o],d)})},xe=\"#infinitynikki-map-oversea + div > div > div:nth-child(2)\",Y=\"spinning-momo-marker-toggle-button\",Me=\"#F5DCB1E6\",Ce=\"#4D3E2AE6\",Ie=e=>{if(!e)return;const t=n.runtimeOptions||{},o=n.renderOptions||{},i=t.markersVisible!==!1,c=o.markerIconUrl||\"\";e.style.backgroundColor=i?Me:Ce,e.setAttribute(\"aria-pressed\",i?\"true\":\"false\"),e.title=i?\"\\u9690\\u85CF\\u7167\\u7247\\u6807\\u70B9\":\"\\u663E\\u793A\\u7167\\u7247\\u6807\\u70B9\";let a=e.querySelector(\"img\");c?(a||(a=document.createElement(\"img\"),a.alt=\"\",a.setAttribute(\"aria-hidden\",\"true\"),a.style.width=\"32px\",a.style.height=\"32px\",a.style.objectFit=\"contain\",a.style.pointerEvents=\"none\",e.appendChild(a)),a.src=c):a&&a.parentNode===e&&(a.remove(),a=null),a?e.textContent&&(e.textContent=\"\",e.appendChild(a)):(e.textContent=\"\\u2022\",e.style.color=\"#FFF7EA\",e.style.fontSize=\"18px\",e.style.lineHeight=\"1\")},K=()=>{const e=document.querySelector(xe);if(!e)return!1;let t=document.getElementById(Y);return t||(t=document.createElement(\"button\"),t.id=Y,t.type=\"button\",t.style.display=\"flex\",t.style.alignItems=\"center\",t.style.justifyContent=\"center\",t.style.boxSizing=\"border-box\",t.style.width=\"39px\",t.style.height=\"39px\",t.style.padding=\"8.125px\",t.style.margin=\"0\",t.style.border=\"none\",t.style.borderRadius=\"6.5px\",t.style.cursor=\"pointer\",t.style.webkitTapHighlightColor=\"rgba(0, 0, 0, 0)\",t.style.fontFamily='FZYASHJW_ZHUN, system-ui, -apple-system, \"Segoe UI\", Roboto, Ubuntu, Cantarell, \"Noto Sans\", sans-serif, \"STHeiti SC\", \"Microsoft YaHei\", \"Helvetica Neue\", Helvetica, Arial',t.style.fontSize=\"14px\",t.style.flexShrink=\"0\",t.addEventListener(\"click\",o=>{o.preventDefault(),o.stopPropagation();const c=(n.runtimeOptions||{}).markersVisible===!1;window.parent&&window.parent!==window&&window.parent.postMessage({action:\"SPINNING_MOMO_SET_MARKERS_VISIBLE\",payload:{markersVisible:c}},\"*\")})),t.parentElement!==e&&e.appendChild(t),Ie(t),!0};(()=>{if(K()||n.markerToggleMountTimer)return;let e=0;const t=40;n.markerToggleMountTimer=setInterval(()=>{e+=1,(K()||e>=t)&&(clearInterval(n.markerToggleMountTimer),n)SM_MAP_JS\"\n    LR\"SM_MAP_JS(.markerToggleMountTimer=null)},100)})();const Oe=()=>{const e=r&&r.getContainer?r.getContainer():null;if(!e)return;const t=v.mapBackgroundColor||\"#C7BFA7\";e.style.backgroundColor=String(t)},we=v.markerPinBackgroundUrl||\"https://assets.papegames.com/nikkiweb/infinitynikki/infinitynikki-map/img/58ca045d59db0f9cd8ad.png\",C=36,Z=v.markerIconSize||[24,24],E=Number(Z[0]),L=Number(Z[1]),X=Number.isFinite(E)&&E>0?E:24,Se=Number.isFinite(L)&&L>0?L:X,J=e=>{if(!s.divIcon)return null;const t='<div class=\"spinning-momo-pin-root\" style=\"width:'+C+\"px;height:\"+C+`px;position:relative;overflow:visible;\"><div style=\"position:absolute;inset:0;background-image:url('`+we+`');background-size:contain;background-repeat:no-repeat;background-position:center;pointer-events:none;\"></div>`+e+\"</div>\";return s.divIcon({className:\"spinning-momo-composite-pin\",html:t,iconSize:[C,C],iconAnchor:[C/2,C]})},He=()=>{const e=v.markerIconUrl||\"\",t=e?'<img src=\"'+e+'\" alt=\"\" style=\"position:absolute;left:50%;top:40%;transform:translate(calc(-50% - 0.25px),calc(-50% + 1.8px));width:'+X+\"px;height:\"+Se+'px;object-fit:contain;pointer-events:none;z-index:1;\" />':\"\";return J(t)},Te=e=>{const t='<div style=\"position:absolute;left:0;top:0;right:0;bottom:0;display:flex;align-items:center;justify-content:center;color:#fff;font-weight:700;font-size:15px;line-height:1;text-shadow:0 1px 3px rgba(0,0,0,0.85);pointer-events:none;transform:translateY(-3px);\">'+e+\"</div>\";return J(t)},Q=e=>{const t=He(),o=\"marker:\"+String(e.assetId??e.name??String(e.lat)+\",\"+String(e.lng)),i={pane:T,interactive:!0};t&&(i.icon=t);const c=s.marker([e.lat,e.lng],i).addTo(n.markerLayer),a={markerHovered:!1,popupHovered:!1,openTimer:null,closeTimer:null};c.on(\"add\",()=>{const u=c.getElement?c.getElement():null;u&&(u.style.cursor=\"pointer\",u.style.pointerEvents=\"auto\")}),H&&(c.on(\"mouseover\",()=>{a.markerHovered=!0,q(a,{ownerId:o,latLng:[e.lat,e.lng],contentHtml:be(e)})}),F&&c.on(\"mouseout\",()=>{a.markerHovered=!1,me(a),a.popupHovered||k(a,()=>O(o))}))},$=()=>{if(Oe(),O(),n.markerLayer.clearLayers(),n.clusterLayer.clearLayers(),N.markersVisible!==!1){if(!ae||f.length<=1||!r.latLngToLayerPoint)f.forEach(Q);else{const t=new Map;for(const o of f){if(o.lat===void 0||o.lng===void 0)continue;const i=r.latLngToLayerPoint([o.lat,o.lng]),c=Math.round(i.x/z),a=Math.round(i.y/z),u=c+\":\"+a;t.has(u)||t.set(u,[]),t.get(u).push(o)}t.forEach(o=>{if(o.length<=1){Q(o[0]);return}Ne(o)})}if(ie&&f.length>0){const t=f[0];t?.lat!==void 0&&t?.lng!==void 0&&r.flyTo&&r.flyTo([t.lat,t.lng],6)}}};n.render=$,$(),n.boundRecluster||(n.boundRecluster=()=>{n.render&&n.render()},r.on&&(r.on(\"zoomend\",n.boundRecluster),r.on(\"moveend\",n.boundRecluster)))},ne=l=>{if(!l||typeof l!=\"object\")return{};const s={...l};return(!Array.isArray(s.markerIconSize)||s.markerIconSize.length!==2)&&(s.markerIconSize=void 0),(!Array.isArray(s.markerIconAnchor)||s.markerIconAnchor.length!==2)&&(s.markerIconAnchor=void 0),s},oe=l=>{window.__SPINNING_MOMO_RENDER_OPTIONS__=ne(l)},re=l=>{if(!l||typeof l!=\"object\"){window.__SPINNING_MOMO_CLUSTER_OPTIONS__={};return}window.__SPINNING_MOMO_CLUSTER_OPTIONS__={...l}},S=(l={})=>{const s=window.__SPINNING_MOMO_MAP__,r=window.L;!s||!r||typeof B!=\"function\"||B({L:r,map:s,markers:window.__SPINNING_MOMO_PENDING_MARKERS__,renderOptions:window.__SPINNING_MOMO_RENDER_OPTIONS__||{},runtimeOptions:window.__SPINNING_MOMO_CLUSTER_OPTIONS__||{},flyToFirst:l.flyToFirst===!0})};Object.defineProperty(window,\"L\",{get:function(){return b},set:function(l){if(b=l,b&&b.Map&&!b.Map.__SPINNING_MOMO_PATCHED__){const s=b.Map;b.Map=function(...r){const f=new s(...r);return window.__SPINNING_MOMO_MAP__=f,S(),f},b.Map.prototype=s.prototype,Object.assign(b.Map,s),b.Map.__SPINNING_MOMO_PATCHED__=!0}},configurable:!0,enumerable:!0}),window.addEventListener(\"message\",l=>{if(l.data){if(l.data.action===\"SPINNING_MOMO_SYNC_RUNTIME\"){const s=l.data.payload||{},r=Array.isArray(s.markers)?s.markers:[];window.__SPINNING_MOMO_PENDING_MARKERS__=r,oe(s.renderOptions||{}),re(s.runtimeOptions||{}),S();return}if(l.data.action===\"EVAL_SCRIPT\"){if(!window.__)SM_MAP_JS\"\n    LR\"SM_MAP_JS(SPINNING_MOMO_ALLOW_DEV_EVAL__)return;const s=l.data.payload||{};if(typeof s.script!=\"string\"||s.script.length===0)return;try{new Function(s.script)()}catch(r){console.error(\"[SpinningMomo] Failed to eval dev script:\",r)}return}if(l.data.action===\"ADD_MARKER\"){const s=l.data.payload;if(!s)return;const r=window.__SPINNING_MOMO_PENDING_MARKERS__;window.__SPINNING_MOMO_PENDING_MARKERS__=[...r,s],S({flyToFirst:!0})}}})})SM_MAP_JS\";\n\n}  // namespace Extensions::InfinityNikki::Generated\n"
  },
  {
    "path": "src/extensions/infinity_nikki/map_service.cpp",
    "content": "module;\n\nmodule Extensions.InfinityNikki.MapService;\n\nimport std;\nimport Core.State;\nimport Core.WebView;\nimport Extensions.InfinityNikki.Generated.MapInjectionScript;\nimport Utils.Logger;\n\nnamespace Extensions::InfinityNikki::MapService {\n\nauto register_from_settings(Core::State::AppState& app_state) -> void {\n#ifdef NDEBUG\n  constexpr bool allow_dev_eval = false;\n#else\n  constexpr bool allow_dev_eval = true;\n#endif\n\n  std::wstring script = std::wstring(Extensions::InfinityNikki::Generated::map_bridge_script);\n\n  const std::wstring allow_dev_eval_literal = allow_dev_eval ? L\"true\" : L\"false\";\n  std::wstring script_with_eval_flag = script;\n  const std::wstring placeholder = L\"__ALLOW_DEV_EVAL__\";\n  if (const std::size_t pos = script_with_eval_flag.find(placeholder); pos != std::wstring::npos) {\n    script_with_eval_flag.replace(pos, placeholder.length(), allow_dev_eval_literal);\n  }\n\n  Core::WebView::register_document_created_script(\n      app_state, \"extensions.infinity_nikki.map_service.bridge\", script_with_eval_flag);\n  Logger().info(\"InfinityNikki map WebView bridge script registered\");\n}\n\n}  // namespace Extensions::InfinityNikki::MapService\n"
  },
  {
    "path": "src/extensions/infinity_nikki/map_service.ixx",
    "content": "module;\n\nexport module Extensions.InfinityNikki.MapService;\n\nimport Core.State;\n\nnamespace Extensions::InfinityNikki::MapService {\n\n// 注册 Infinity Nikki 官方地图页面所需的 WebView 注入脚本。\nexport auto register_from_settings(Core::State::AppState& app_state) -> void;\n\n}  // namespace Extensions::InfinityNikki::MapService\n"
  },
  {
    "path": "src/extensions/infinity_nikki/photo_extract/infra.cpp",
    "content": "module;\n\n#include <asio.hpp>\n\nmodule Extensions.InfinityNikki.PhotoExtract.Infra;\n\nimport std;\nimport Core.Database;\nimport Core.Database.Types;\nimport Core.State;\nimport Core.HttpClient;\nimport Core.HttpClient.Types;\nimport Features.Gallery.Folder.Repository;\nimport Extensions.InfinityNikki.PhotoExtract.Scan;\nimport Extensions.InfinityNikki.Types;\nimport <rfl/json.hpp>;\n\nnamespace Extensions::InfinityNikki::PhotoExtract::Infra {\n\nstruct ExtractApiRequestBody {\n  std::string uid;\n  std::vector<std::array<std::string, 2>> photos;\n  std::optional<bool> raw_data = true;\n  std::optional<bool> raw_id = true;\n};\n\nstruct Nuan5Light {\n  std::optional<std::int64_t> l;\n  std::optional<double> s;\n};\n\nstruct Nuan5RawTime {\n  std::optional<std::int64_t> d;\n  std::optional<std::int64_t> h;\n  std::optional<std::int64_t> m;\n  std::optional<double> s;\n};\n\nstruct Nuan5RawPos {\n  std::optional<double> x;\n  std::optional<double> y;\n  std::optional<double> z;\n};\n\nstruct Nuan5RawData {\n  std::optional<Nuan5RawTime> time;\n  std::optional<Nuan5RawPos> pos;\n};\n\nstruct Nuan5Filter {\n  std::optional<std::int64_t> f;\n  std::optional<double> s;\n};\n\nstruct Nuan5DiyEntry {\n  std::optional<std::int64_t> i;\n  std::optional<std::string> t;\n  std::optional<std::int64_t> grid;\n};\n\nstruct Nuan5DecodedPhoto {\n  std::optional<double> focal;\n  std::optional<double> apeture;\n  std::optional<double> rotation;\n  std::optional<double> vignette;\n  std::optional<Nuan5Filter> filter;\n  std::optional<std::int64_t> pose;\n  std::optional<Nuan5Light> light;\n  std::optional<bool> hide_nikki;\n  std::optional<std::string> camera_params;\n  std::optional<bool> vertical;\n  std::optional<double> bloomIntensity;\n  std::optional<double> bloomThreshold;\n  std::optional<double> brightness;\n  std::optional<double> exposure;\n  std::optional<double> contrast;\n  std::optional<double> saturation;\n  std::optional<double> vibrance;\n  std::optional<double> highlights;\n  std::optional<double> shadow;\n  std::optional<std::vector<std::int64_t>> clothes;\n  std::optional<std::unordered_map<std::string, std::vector<Nuan5DiyEntry>>> diy;\n  std::optional<Nuan5RawData> raw;\n};\n\nconstexpr std::string_view kExtractApiUrl = \"https://nuan5.pro/api/decode-photo\";\nconstexpr auto kNuan5MinRequestInterval = std::chrono::milliseconds(500);\nconstexpr std::size_t kMaxNuan5RateLimitRetries = 2;\n\nstruct HttpJsonResponse {\n  int status_code = 0;\n  std::string body;\n};\n\nauto to_parsed_record(const Nuan5DecodedPhoto& photo) -> ParsedPhotoParamsRecord {\n  ParsedPhotoParamsRecord record;\n  record.camera_params = photo.camera_params;\n  record.camera_focal_length = photo.focal;\n  record.rotation = photo.rotation;\n  record.aperture_value = photo.apeture;\n  if (photo.filter.has_value()) {\n    record.filter_id = photo.filter->f;\n    record.filter_strength = photo.filter->s;\n  }\n  record.vignette_intensity = photo.vignette;\n  record.vertical = photo.vertical;\n  record.bloom_intensity = photo.bloomIntensity;\n  record.bloom_threshold = photo.bloomThreshold;\n  record.brightness = photo.brightness;\n  record.exposure = photo.exposure;\n  record.contrast = photo.contrast;\n  record.saturation = photo.saturation;\n  record.vibrance = photo.vibrance;\n  record.highlights = photo.highlights;\n  record.shadow = photo.shadow;\n  record.nikki_hidden = photo.hide_nikki;\n  record.pose_id = photo.pose;\n\n  if (photo.light.has_value()) {\n    record.light_id = photo.light->l;\n    record.light_strength = photo.light->s;\n  }\n\n  if (photo.raw.has_value()) {\n    if (photo.raw->time.has_value()) {\n      record.time_hour = photo.raw->time->h;\n      record.time_min = photo.raw->time->m;\n    }\n    if (photo.raw->pos.has_value()) {\n      record.nikki_loc_x = photo.raw->pos->x;\n      record.nikki_loc_y = photo.raw->pos->y;\n      record.nikki_loc_z = photo.raw->pos->z;\n    }\n  }\n  if (photo.clothes.has_value()) {\n    record.nikki_clothes = *photo.clothes;\n  }\n  if (photo.diy.has_value()) {\n    record.nikki_diy_json = rfl::json::write(*photo.diy);\n  }\n\n  return record;\n}\n\nauto http_post_json(Core::State::AppState& app_state, const std::string& url_utf8,\n                    const std::string& request_body_utf8)\n    -> asio::awaitable<std::expected<HttpJsonResponse, std::string>> {\n  Core::HttpClient::Types::Request request{\n      .method = \"POST\",\n      .url = url_utf8,\n      .headers =\n          {\n              Core::HttpClient::Types::Header{.name = \"Content-Type\", .value = \"application/json\"},\n              Core::HttpClient::Types::Header{.name = \"X-Client\", .value = \"SpinningMomo\"},\n          },\n      .body = request_body_utf8,\n  };\n\n  auto response = co_await Core::HttpClient::fetch(app_state, request);\n  if (!response) {\n    co_return std::unexpected(\"Failed to send HTTP request: \" + response.error());\n  }\n\n  co_return HttpJsonResponse{\n      .status_code = response->status_code,\n      .body = std::move(response->body),\n  };\n}\n\nauto acquire_nuan5_send_slot() -> asio::awaitable<void> {\n  static std::mutex gate_mutex;\n  static auto next_allowed_at = std::chrono::steady_clock::time_point::min();\n\n  auto executor = co_await asio::this_coro::executor;\n  asio::steady_timer timer(executor);\n\n  while (true) {\n    auto wait_duration = std::chrono::steady_clock::duration::zero();\n    {\n      std::lock_guard<std::mutex> lock(gate_mutex);\n      auto now = std::chrono::steady_clock::now();\n      if (now >= next_allowed_at) {\n        next_allowed_at = now + kNuan5MinRequestInterval;\n        co_return;\n      }\n      wait_duration = next_allowed_at - now;\n    }\n\n    timer.expires_after(wait_duration);\n    std::error_code wait_error;\n    co_await timer.async_wait(asio::redirect_error(asio::use_awaitable, wait_error));\n  }\n}\n\nauto parse_photo_params_records_from_response(const std::string& response_body)\n    -> std::expected<std::vector<ExtractBatchPhotoParamsRecord>, std::string> {\n  auto response = rfl::json::read<std::vector<std::variant<std::string, Nuan5DecodedPhoto>>,\n                                  rfl::DefaultIfMissing>(response_body);\n\n  if (!response) {\n    return std::unexpected(\"Invalid JSON response: \" + response.error().what());\n  }\n  std::vector<ExtractBatchPhotoParamsRecord> records;\n  records.reserve(response->size());\n  for (const auto& item : *response) {\n    if (std::holds_alternative<std::string>(item)) {\n      records.push_back(ExtractBatchPhotoParamsRecord{\n          .record = std::nullopt,\n          .error_message = std::get<std::string>(item),\n      });\n      continue;\n    }\n    records.push_back(ExtractBatchPhotoParamsRecord{\n        .record = to_parsed_record(std::get<Nuan5DecodedPhoto>(item)),\n        .error_message = std::nullopt,\n    });\n  }\n  return records;\n}\n\ntemplate <typename T>\n  requires std::same_as<T, std::string> || std::same_as<T, std::int64_t> ||\n           std::same_as<T, double> || std::same_as<T, bool>\nauto to_db_param(const std::optional<T>& value) -> Core::Database::Types::DbParam {\n  if (!value) {\n    return Core::Database::Types::DbParam{std::monostate{}};\n  }\n\n  if constexpr (std::same_as<T, bool>) {\n    return Core::Database::Types::DbParam{static_cast<std::int64_t>(*value)};\n  } else {\n    return Core::Database::Types::DbParam{*value};\n  }\n}\n\nauto normalize_path_for_like_match(std::string path) -> std::string {\n  std::replace(path.begin(), path.end(), '\\\\', '/');\n  return path;\n}\n\nauto load_candidate_assets(\n    Core::State::AppState& app_state,\n    const Extensions::InfinityNikki::InfinityNikkiExtractPhotoParamsRequest& request)\n    -> std::expected<std::vector<Scan::CandidateAssetRow>, std::string> {\n  auto only_missing = request.only_missing.value_or(true);\n\n  if (request.folder_id.has_value()) {\n    auto folder_result =\n        Features::Gallery::Folder::Repository::get_folder_by_id(app_state, *request.folder_id);\n    if (!folder_result) {\n      return std::unexpected(\"Failed to query folder for manual extract: \" + folder_result.error());\n    }\n    if (!folder_result->has_value()) {\n      return std::unexpected(\"Folder not found for manual extract: \" +\n                             std::to_string(*request.folder_id));\n    }\n\n    auto normalized_folder_path = normalize_path_for_like_match(folder_result->value().path);\n    std::string sql = R\"(\n      SELECT a.id, a.path\n      FROM assets a\n      LEFT JOIN asset_infinity_nikki_params p ON p.asset_id = a.id\n      WHERE a.type = 'photo'\n        AND (\n          replace(a.path, '\\\\', '/') = ?\n          OR replace(a.path, '\\\\', '/') LIKE ?\n        )\n        AND (lower(coalesce(a.extension, '')) IN ('.jpg', '.jpeg')\n             OR lower(a.path) LIKE '%.jpg'\n             OR lower(a.path) LIKE '%.jpeg')\n    )\";\n\n    std::vector<Core::Database::Types::DbParam> params = {\n        normalized_folder_path,\n        normalized_folder_path + \"/%\",\n    };\n\n    if (only_missing) {\n      sql += \" AND p.asset_id IS NULL\";\n    }\n\n    sql += \" ORDER BY COALESCE(a.file_modified_at, a.created_at) DESC, a.id DESC\";\n\n    auto query_result =\n        Core::Database::query<Scan::CandidateAssetRow>(*app_state.database, sql, params);\n    if (!query_result) {\n      return std::unexpected(\"Failed to query manual extract candidate assets: \" +\n                             query_result.error());\n    }\n\n    return query_result.value();\n  }\n\n  std::string sql = R\"(\n    SELECT a.id, a.path\n    FROM assets a\n    LEFT JOIN asset_infinity_nikki_params p ON p.asset_id = a.id\n    WHERE a.type = 'photo'\n      AND instr(replace(a.path, '\\\\', '/'), '/X6Game/Saved/GamePlayPhotos/') > 0\n      AND instr(replace(a.path, '\\\\', '/'), '/NikkiPhotos_HighQuality/') > 0\n      AND (lower(coalesce(a.extension, '')) IN ('.jpg', '.jpeg')\n           OR lower(a.path) LIKE '%.jpg'\n           OR lower(a.path) LIKE '%.jpeg')\n  )\";\n\n  if (only_missing) {\n    sql += \" AND p.asset_id IS NULL\";\n  }\n\n  sql += \" ORDER BY COALESCE(a.file_modified_at, a.created_at) DESC, a.id DESC\";\n\n  auto query_result = Core::Database::query<Scan::CandidateAssetRow>(*app_state.database, sql, {});\n  if (!query_result) {\n    return std::unexpected(\"Failed to query candidate assets: \" + query_result.error());\n  }\n\n  return query_result.value();\n}\n\nauto extract_batch_photo_params(Core::State::AppState& app_state,\n                                const std::vector<Scan::PreparedPhotoExtractEntry>& entries)\n    -> asio::awaitable<std::expected<std::vector<ExtractBatchPhotoParamsRecord>, std::string>> {\n  if (entries.empty()) {\n    co_return std::vector<ExtractBatchPhotoParamsRecord>{};\n  }\n\n  const auto& uid = entries.front().uid;\n  for (const auto& entry : entries) {\n    if (entry.uid != uid) {\n      co_return std::unexpected(\"Batch contains multiple UIDs\");\n    }\n  }\n\n  // 远端接口按“同一个 UID 的多张照片”来解析，\n  // 所以这里明确要求一个 batch 内只能有一个 UID。\n  ExtractApiRequestBody request_body{\n      .uid = uid,\n      .photos = {},\n  };\n  request_body.photos.reserve(entries.size());\n  for (const auto& entry : entries) {\n    request_body.photos.push_back({entry.md5, entry.encoded});\n  }\n\n  auto request_json = rfl::json::write(request_body);\n  std::expected<std::string, std::string> response_body =\n      std::unexpected(\"HTTP response is unavailable\");\n  for (std::size_t attempt = 0; attempt <= kMaxNuan5RateLimitRetries; ++attempt) {\n    co_await acquire_nuan5_send_slot();\n\n    auto response_result =\n        co_await http_post_json(app_state, std::string(kExtractApiUrl), request_json);\n    if (!response_result) {\n      co_return std::unexpected(\"HTTP error: \" + response_result.error());\n    }\n\n    if (response_result->status_code == 429) {\n      if (attempt >= kMaxNuan5RateLimitRetries) {\n        co_return std::unexpected(\n            std::format(\"HTTP error: 429 (rate limit), retries exhausted after {}\", attempt));\n      }\n      continue;\n    }\n\n    if (response_result->status_code < 200 || response_result->status_code >= 300) {\n      co_return std::unexpected(\"HTTP error: \" + std::to_string(response_result->status_code));\n    }\n\n    response_body = std::move(response_result->body);\n    break;\n  }\n\n  if (!response_body) {\n    co_return std::unexpected(response_body.error());\n  }\n\n  auto parsed_result = parse_photo_params_records_from_response(response_body.value());\n  if (!parsed_result) {\n    co_return std::unexpected(parsed_result.error());\n  }\n\n  auto records = std::move(parsed_result.value());\n  if (records.size() != entries.size()) {\n    co_return std::unexpected(\n        std::format(\"response count mismatch: got {} for {}\", records.size(), entries.size()));\n  }\n\n  co_return records;\n}\n\nauto upsert_photo_params_batch(Core::State::AppState& app_state, const std::string& uid,\n                               const std::vector<ParsedPhotoParamsBatchItem>& items)\n    -> std::expected<std::int32_t, std::string> {\n  if (items.empty()) {\n    return 0;\n  }\n\n  auto transaction_result = Core::Database::execute_transaction(\n      *app_state.database, [&](auto& db_state) -> std::expected<std::int32_t, std::string> {\n        // 这里故意使用“整个 batch 一个事务”。\n        // 目标不是减少 SQL 条数到极致，而是先把最贵的事务提交次数降下来。\n        std::string upsert_sql = R\"(\n          INSERT INTO asset_infinity_nikki_params (\n            asset_id, uid, camera_params,\n            time_hour, time_min,\n            camera_focal_length, rotation, aperture_value,\n            filter_id, filter_strength, vignette_intensity,\n            light_id, light_strength,\n            vertical, bloom_intensity, bloom_threshold, brightness, exposure, contrast, saturation,\n            vibrance, highlights, shadow,\n            nikki_loc_x, nikki_loc_y, nikki_loc_z,\n            nikki_hidden, pose_id, nikki_diy_json\n          ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n          ON CONFLICT(asset_id) DO UPDATE SET\n            uid = excluded.uid,\n            camera_params = excluded.camera_params,\n            time_hour = excluded.time_hour,\n            time_min = excluded.time_min,\n            camera_focal_length = excluded.camera_focal_length,\n            rotation = excluded.rotation,\n            aperture_value = excluded.aperture_value,\n            filter_id = excluded.filter_id,\n            filter_strength = excluded.filter_strength,\n            vignette_intensity = excluded.vignette_intensity,\n            light_id = excluded.light_id,\n            light_strength = excluded.light_strength,\n            vertical = excluded.vertical,\n            bloom_intensity = excluded.bloom_intensity,\n            bloom_threshold = excluded.bloom_threshold,\n            brightness = excluded.brightness,\n            exposure = excluded.exposure,\n            contrast = excluded.contrast,\n            saturation = excluded.saturation,\n            vibrance = excluded.vibrance,\n            highlights = excluded.highlights,\n            shadow = excluded.shadow,\n            nikki_loc_x = excluded.nikki_loc_x,\n            nikki_loc_y = excluded.nikki_loc_y,\n            nikki_loc_z = excluded.nikki_loc_z,\n            nikki_hidden = excluded.nikki_hidden,\n            pose_id = excluded.pose_id,\n            nikki_diy_json = excluded.nikki_diy_json\n        )\";\n\n        std::int32_t inserted_clothes = 0;\n        for (const auto& item : items) {\n          // 每张照片仍然各自 upsert / clear clothes / insert clothes，\n          // 但它们现在被包在同一个事务里，整体成本会低很多。\n          const auto& record = item.record;\n\n          std::vector<Core::Database::Types::DbParam> params = {\n              item.asset_id,\n              uid,\n              to_db_param(record.camera_params),\n              to_db_param(record.time_hour),\n              to_db_param(record.time_min),\n              to_db_param(record.camera_focal_length),\n              to_db_param(record.rotation),\n              to_db_param(record.aperture_value),\n              to_db_param(record.filter_id),\n              to_db_param(record.filter_strength),\n              to_db_param(record.vignette_intensity),\n              to_db_param(record.light_id),\n              to_db_param(record.light_strength),\n              to_db_param(record.vertical),\n              to_db_param(record.bloom_intensity),\n              to_db_param(record.bloom_threshold),\n              to_db_param(record.brightness),\n              to_db_param(record.exposure),\n              to_db_param(record.contrast),\n              to_db_param(record.saturation),\n              to_db_param(record.vibrance),\n              to_db_param(record.highlights),\n              to_db_param(record.shadow),\n              to_db_param(record.nikki_loc_x),\n              to_db_param(record.nikki_loc_y),\n              to_db_param(record.nikki_loc_z),\n              to_db_param(record.nikki_hidden),\n              to_db_param(record.pose_id),\n              to_db_param(record.nikki_diy_json),\n          };\n\n          auto upsert_result = Core::Database::execute(db_state, upsert_sql, params);\n          if (!upsert_result) {\n            return std::unexpected(\"Failed to upsert Infinity Nikki params: \" +\n                                   upsert_result.error());\n          }\n\n          auto insert_cloth_result = Core::Database::execute(\n              db_state, \"DELETE FROM asset_infinity_nikki_clothes WHERE asset_id = ?\",\n              {item.asset_id});\n          if (!insert_cloth_result) {\n            return std::unexpected(\"Failed to clear existing clothes: \" +\n                                   insert_cloth_result.error());\n          }\n\n          for (const auto cloth_id : record.nikki_clothes) {\n            auto insert_cloth_result = Core::Database::execute(\n                db_state,\n                \"INSERT INTO asset_infinity_nikki_clothes (asset_id, cloth_id) VALUES (?, ?)\",\n                {item.asset_id, cloth_id});\n            if (!insert_cloth_result) {\n              return std::unexpected(\"Failed to insert cloth mapping: \" +\n                                     insert_cloth_result.error());\n            }\n            inserted_clothes++;\n          }\n        }\n\n        return inserted_clothes;\n      });\n\n  return transaction_result;\n}\n\n}  // namespace Extensions::InfinityNikki::PhotoExtract::Infra\n"
  },
  {
    "path": "src/extensions/infinity_nikki/photo_extract/infra.ixx",
    "content": "module;\n\n#include <asio.hpp>\n\nexport module Extensions.InfinityNikki.PhotoExtract.Infra;\n\nimport std;\nimport Core.State;\nimport Extensions.InfinityNikki.PhotoExtract.Scan;\nimport Extensions.InfinityNikki.Types;\n\nexport namespace Extensions::InfinityNikki::PhotoExtract::Infra {\n\nstruct ParsedPhotoParamsRecord {\n  std::optional<std::string> camera_params;\n  std::optional<std::int64_t> time_hour;\n  std::optional<std::int64_t> time_min;\n  std::optional<double> camera_focal_length;\n  std::optional<double> rotation;\n  std::optional<double> aperture_value;\n  std::optional<std::int64_t> filter_id;\n  std::optional<double> filter_strength;\n  std::optional<double> vignette_intensity;\n  std::optional<std::int64_t> light_id;\n  std::optional<double> light_strength;\n  std::optional<bool> vertical;\n  std::optional<double> bloom_intensity;\n  std::optional<double> bloom_threshold;\n  std::optional<double> brightness;\n  std::optional<double> exposure;\n  std::optional<double> contrast;\n  std::optional<double> saturation;\n  std::optional<double> vibrance;\n  std::optional<double> highlights;\n  std::optional<double> shadow;\n  std::optional<double> nikki_loc_x;\n  std::optional<double> nikki_loc_y;\n  std::optional<double> nikki_loc_z;\n  std::optional<bool> nikki_hidden;\n  std::optional<std::int64_t> pose_id;\n  std::optional<std::string> nikki_diy_json;\n  std::vector<std::int64_t> nikki_clothes;\n};\n\nstruct ParsedPhotoParamsBatchItem {\n  std::int64_t asset_id;\n  ParsedPhotoParamsRecord record;\n};\n\nstruct ExtractBatchPhotoParamsRecord {\n  std::optional<ParsedPhotoParamsRecord> record;\n  std::optional<std::string> error_message;\n};\n\nauto load_candidate_assets(\n    Core::State::AppState& app_state,\n    const Extensions::InfinityNikki::InfinityNikkiExtractPhotoParamsRequest& request)\n    -> std::expected<std::vector<Scan::CandidateAssetRow>, std::string>;\n\nauto extract_batch_photo_params(Core::State::AppState& app_state,\n                                const std::vector<Scan::PreparedPhotoExtractEntry>& entries)\n    -> asio::awaitable<std::expected<std::vector<ExtractBatchPhotoParamsRecord>, std::string>>;\n\nauto upsert_photo_params_batch(Core::State::AppState& app_state, const std::string& uid,\n                               const std::vector<ParsedPhotoParamsBatchItem>& items)\n    -> std::expected<std::int32_t, std::string>;\n\n}  // namespace Extensions::InfinityNikki::PhotoExtract::Infra\n"
  },
  {
    "path": "src/extensions/infinity_nikki/photo_extract/photo_extract.cpp",
    "content": "module;\n\n#include <asio.hpp>\n\nmodule Extensions.InfinityNikki.PhotoExtract;\n\nimport std;\nimport Core.State;\nimport Core.WorkerPool;\nimport Extensions.InfinityNikki.PhotoExtract.Infra;\nimport Extensions.InfinityNikki.PhotoExtract.Scan;\nimport Extensions.InfinityNikki.Types;\nimport Utils.Logger;\n\nnamespace Extensions::InfinityNikki::PhotoExtract {\n\nconstexpr std::size_t kMaxErrorMessages = 50;\nconstexpr std::size_t kExtractBatchSize = 50;\nconstexpr std::int64_t kMinProgressReportIntervalMillis = 200;\nconstexpr std::int64_t kPollIntervalMillis = 50;\nconstexpr double kPreparingPercent = 2.0;\nconstexpr double kProcessingStartPercent = 5.0;\nconstexpr double kProcessingEndPercent = 99.0;\n\nstruct ExtractProgressState {\n  std::int64_t total_candidates = 0;\n  std::int64_t scanned_count = 0;\n  std::int64_t finalized_count = 0;\n  int last_reported_percent = -1;\n  std::int64_t last_report_millis = 0;\n};\n\nstruct PrepareTaskOutcome {\n  std::optional<Scan::PreparedPhotoExtractEntry> entry;\n  std::optional<std::string> error;\n};\n\nstruct BatchExtractOutcome {\n  std::expected<std::vector<Infra::ExtractBatchPhotoParamsRecord>, std::string> result =\n      std::unexpected(\"Batch result unavailable\");\n};\n\nauto add_error(std::vector<std::string>& errors, std::string message) -> void {\n  if (errors.size() >= kMaxErrorMessages) {\n    return;\n  }\n  errors.push_back(std::move(message));\n}\n\nauto steady_clock_millis() -> std::int64_t {\n  return std::chrono::duration_cast<std::chrono::milliseconds>(\n             std::chrono::steady_clock::now().time_since_epoch())\n      .count();\n}\n\nauto report_extract_progress(\n    const std::function<void(const InfinityNikkiExtractPhotoParamsProgress&)>& progress_callback,\n    std::string stage, std::int64_t current, std::int64_t total,\n    std::optional<double> percent = std::nullopt, std::optional<std::string> message = std::nullopt)\n    -> void {\n  if (!progress_callback) {\n    return;\n  }\n\n  if (!percent.has_value() && total > 0) {\n    percent = (static_cast<double>(current) * 100.0) / static_cast<double>(total);\n  }\n\n  if (percent.has_value()) {\n    percent = std::clamp(*percent, 0.0, 100.0);\n  }\n\n  progress_callback(InfinityNikkiExtractPhotoParamsProgress{\n      .stage = std::move(stage),\n      .current = current,\n      .total = total,\n      .percent = percent,\n      .message = std::move(message),\n  });\n}\n\nauto calculate_processing_percent(const ExtractProgressState& progress) -> double {\n  if (progress.total_candidates <= 0) {\n    return 100.0;\n  }\n\n  auto total = static_cast<double>(progress.total_candidates);\n  auto scanned_ratio = std::clamp(static_cast<double>(progress.scanned_count) / total, 0.0, 1.0);\n  auto finalized_ratio =\n      std::clamp(static_cast<double>(progress.finalized_count) / total, 0.0, 1.0);\n  auto overall_ratio = (scanned_ratio + finalized_ratio) / 2.0;\n\n  return kProcessingStartPercent +\n         overall_ratio * (kProcessingEndPercent - kProcessingStartPercent);\n}\n\nauto build_processing_message(const ExtractProgressState& progress,\n                              const InfinityNikkiExtractPhotoParamsResult& result) -> std::string {\n  return std::format(\"Scanned {} / {}, finalized {} / {}, saved {}, skipped {}, failed {}\",\n                     progress.scanned_count, progress.total_candidates, progress.finalized_count,\n                     progress.total_candidates, result.saved_count, result.skipped_count,\n                     result.failed_count);\n}\n\nauto report_processing_progress(\n    const std::function<void(const InfinityNikkiExtractPhotoParamsProgress&)>& progress_callback,\n    ExtractProgressState& progress, const InfinityNikkiExtractPhotoParamsResult& result,\n    bool force = false, std::optional<std::string> message = std::nullopt) -> void {\n  if (!progress_callback || progress.total_candidates <= 0) {\n    return;\n  }\n\n  auto percent = calculate_processing_percent(progress);\n  auto rounded_percent = static_cast<int>(std::floor(percent));\n  auto now = steady_clock_millis();\n\n  if (!force) {\n    if (rounded_percent <= progress.last_reported_percent) {\n      return;\n    }\n\n    if (now - progress.last_report_millis < kMinProgressReportIntervalMillis) {\n      return;\n    }\n  }\n\n  progress.last_reported_percent = std::max(progress.last_reported_percent, rounded_percent);\n  progress.last_report_millis = now;\n\n  if (!message.has_value()) {\n    message = build_processing_message(progress, result);\n  }\n\n  report_extract_progress(progress_callback, \"processing\", progress.finalized_count,\n                          progress.total_candidates, percent, std::move(message));\n}\n\nauto mark_candidate_skipped(InfinityNikkiExtractPhotoParamsResult& result,\n                            ExtractProgressState& progress, std::int64_t asset_id,\n                            const std::string& reason) -> void {\n  result.skipped_count++;\n  result.processed_count++;\n  progress.finalized_count++;\n  add_error(result.errors, std::format(\"asset_id {} skipped: {}\", asset_id, reason));\n}\n\nauto mark_candidate_failed(InfinityNikkiExtractPhotoParamsResult& result,\n                           ExtractProgressState& progress, std::int64_t asset_id,\n                           const std::string& reason) -> void {\n  result.failed_count++;\n  result.processed_count++;\n  progress.finalized_count++;\n  add_error(result.errors, std::format(\"asset_id {} failed: {}\", asset_id, reason));\n}\n\nauto mark_candidates_saved(InfinityNikkiExtractPhotoParamsResult& result,\n                           ExtractProgressState& progress, std::size_t saved_count,\n                           std::int32_t clothes_rows_written) -> void {\n  result.saved_count += static_cast<std::int32_t>(saved_count);\n  result.processed_count += static_cast<std::int32_t>(saved_count);\n  result.clothes_rows_written += clothes_rows_written;\n  progress.finalized_count += static_cast<std::int64_t>(saved_count);\n}\n\nauto wait_for_slot_ready(\n    std::atomic<bool>& slot_ready, std::atomic<std::size_t>& completed_prepare,\n    ExtractProgressState& progress, InfinityNikkiExtractPhotoParamsResult& result,\n    const std::function<void(const InfinityNikkiExtractPhotoParamsProgress&)>& progress_callback)\n    -> asio::awaitable<void> {\n  auto executor = co_await asio::this_coro::executor;\n  asio::steady_timer timer(executor);\n\n  while (!slot_ready.load(std::memory_order_acquire)) {\n    progress.scanned_count =\n        static_cast<std::int64_t>(completed_prepare.load(std::memory_order_relaxed));\n    report_processing_progress(progress_callback, progress, result);\n\n    timer.expires_after(std::chrono::milliseconds(kPollIntervalMillis));\n    std::error_code wait_error;\n    co_await timer.async_wait(asio::redirect_error(asio::use_awaitable, wait_error));\n  }\n\n  progress.scanned_count =\n      static_cast<std::int64_t>(completed_prepare.load(std::memory_order_relaxed));\n  report_processing_progress(progress_callback, progress, result);\n}\n\nauto apply_batch_result(\n    Core::State::AppState& app_state, const std::vector<Scan::PreparedPhotoExtractEntry>& entries,\n    BatchExtractOutcome& batch_outcome, InfinityNikkiExtractPhotoParamsResult& result,\n    ExtractProgressState& progress,\n    const std::function<void(const InfinityNikkiExtractPhotoParamsProgress&)>& progress_callback)\n    -> void {\n  // 第四阶段：消费远端返回结果。\n  // 能识别的记录收集起来做一次批量落库；识别失败的单条记为 failed。\n  if (entries.empty()) {\n    return;\n  }\n\n  auto fail_all = [&](const std::string& reason) {\n    Logger().warn(\"apply_batch_result: batch failed (uid={}, count={}): {}\", entries.front().uid,\n                  entries.size(), reason);\n    for (const auto& entry : entries) {\n      mark_candidate_failed(result, progress, entry.asset_id, reason);\n    }\n    report_processing_progress(progress_callback, progress, result, true);\n  };\n\n  if (!batch_outcome.result) {\n    Logger().error(\"apply_batch_result: extract batch failed: {}\", batch_outcome.result.error());\n    fail_all(batch_outcome.result.error());\n    return;\n  }\n\n  std::vector<Infra::ParsedPhotoParamsBatchItem> items_to_save;\n  items_to_save.reserve(entries.size());\n\n  auto& records = batch_outcome.result.value();\n  for (std::size_t index = 0; index < entries.size(); ++index) {\n    const auto& entry = entries[index];\n    if (!records[index].record.has_value()) {\n      auto reason = records[index].error_message.value_or(\"photo params unrecognized\");\n      mark_candidate_failed(result, progress, entry.asset_id,\n                            std::format(\"API returned null ({})\", reason));\n      continue;\n    }\n\n    items_to_save.push_back(Infra::ParsedPhotoParamsBatchItem{\n        .asset_id = entry.asset_id,\n        .record = std::move(*records[index].record),\n    });\n  }\n\n  if (items_to_save.empty()) {\n    report_processing_progress(progress_callback, progress, result, true);\n    return;\n  }\n\n  auto save_result =\n      Infra::upsert_photo_params_batch(app_state, entries.front().uid, items_to_save);\n  if (!save_result) {\n    Logger().error(\"apply_batch_result: DB batch upsert failed (uid={}, count={}): {}\",\n                   entries.front().uid, items_to_save.size(), save_result.error());\n    for (const auto& item : items_to_save) {\n      mark_candidate_failed(result, progress, item.asset_id, save_result.error());\n    }\n    report_processing_progress(progress_callback, progress, result, true);\n    return;\n  }\n\n  mark_candidates_saved(result, progress, items_to_save.size(), save_result.value());\n  report_processing_progress(progress_callback, progress, result, true);\n}\n\nauto send_extract_batch(\n    Core::State::AppState& app_state, std::vector<Scan::PreparedPhotoExtractEntry> batch,\n    InfinityNikkiExtractPhotoParamsResult& result, ExtractProgressState& progress,\n    const std::function<void(const InfinityNikkiExtractPhotoParamsProgress&)>& progress_callback)\n    -> asio::awaitable<void> {\n  if (batch.empty()) {\n    co_return;\n  }\n  Logger().debug(\"extract_photo_params: sending batch (uid={}, count={})\", batch.front().uid,\n                 batch.size());\n  BatchExtractOutcome batch_outcome;\n  batch_outcome.result = co_await Infra::extract_batch_photo_params(app_state, batch);\n  apply_batch_result(app_state, batch, batch_outcome, result, progress, progress_callback);\n}\n\nauto extract_photo_params(\n    Core::State::AppState& app_state, const InfinityNikkiExtractPhotoParamsRequest& request,\n    const std::function<void(const InfinityNikkiExtractPhotoParamsProgress&)>& progress_callback)\n    -> asio::awaitable<std::expected<InfinityNikkiExtractPhotoParamsResult, std::string>> {\n  // 整个流程：\n  // 1) 查候选照片\n  // 2) 并发准备本地提取数据（WorkerPool），按候选顺序等待每个 slot\n  // 3) 边准备边分批请求远端（同 UID、每批最多 kExtractBatchSize），顺序落库\n  InfinityNikkiExtractPhotoParamsResult result;\n\n  if (!app_state.database) {\n    co_return std::unexpected(\"Database is not initialized\");\n  }\n\n  auto only_missing = request.only_missing.value_or(true);\n  auto uid_override = request.uid_override;\n  if (uid_override.has_value() && uid_override->empty()) {\n    co_return std::unexpected(\"UID override is empty\");\n  }\n\n  report_extract_progress(progress_callback, \"preparing\", 0, 0, kPreparingPercent,\n                          \"Loading candidate assets\");\n\n  auto candidates_result = Infra::load_candidate_assets(app_state, request);\n  if (!candidates_result) {\n    co_return std::unexpected(candidates_result.error());\n  }\n\n  auto candidates = std::move(candidates_result.value());\n  result.candidate_count = static_cast<std::int32_t>(candidates.size());\n\n  Logger().info(\"extract_photo_params: found {} candidate assets (only_missing={})\",\n                result.candidate_count, only_missing);\n\n  if (candidates.empty()) {\n    report_extract_progress(progress_callback, \"completed\", 0, 0, 100.0, \"No candidate assets\");\n    co_return result;\n  }\n\n  ExtractProgressState progress{\n      .total_candidates = result.candidate_count,\n  };\n  report_processing_progress(progress_callback, progress, result, true,\n                             std::format(\"Loaded {} candidate photos\", result.candidate_count));\n\n  report_processing_progress(progress_callback, progress, result, true,\n                             std::format(\"Preparing {} candidate photos\", result.candidate_count));\n\n  if (!app_state.worker_pool || !Core::WorkerPool::is_running(*app_state.worker_pool)) {\n    co_return std::unexpected(\"Worker pool is not available for photo extract preparation\");\n  }\n\n  const auto candidate_count = candidates.size();\n  auto outcomes = std::make_shared<std::vector<PrepareTaskOutcome>>(candidate_count);\n  auto completed_prepare = std::make_shared<std::atomic<std::size_t>>(0);\n  auto slot_ready = std::make_unique<std::atomic<bool>[]>(candidate_count);\n  for (std::size_t i = 0; i < candidate_count; ++i) {\n    slot_ready[i].store(false, std::memory_order_relaxed);\n  }\n\n  auto* slot_ready_ptr = slot_ready.get();\n\n  for (std::size_t index = 0; index < candidate_count; ++index) {\n    auto submitted = Core::WorkerPool::submit_task(\n        *app_state.worker_pool, [outcomes, completed_prepare, slot_ready_ptr,\n                                 candidate = candidates[index], uid_override, index]() mutable {\n          auto prepared_result = Scan::prepare_photo_extract_entry(candidate, uid_override);\n          if (prepared_result) {\n            (*outcomes)[index].entry = std::move(prepared_result.value());\n          } else {\n            (*outcomes)[index].error = prepared_result.error();\n          }\n\n          slot_ready_ptr[index].store(true, std::memory_order_release);\n          completed_prepare->fetch_add(1, std::memory_order_relaxed);\n        });\n\n    if (!submitted) {\n      (*outcomes)[index].error = \"Failed to submit prepare task to worker pool\";\n      slot_ready_ptr[index].store(true, std::memory_order_release);\n      completed_prepare->fetch_add(1, std::memory_order_relaxed);\n    }\n  }\n\n  std::vector<Scan::PreparedPhotoExtractEntry> pending_batch;\n  pending_batch.reserve(kExtractBatchSize);\n\n  for (std::size_t index = 0; index < candidate_count; ++index) {\n    co_await wait_for_slot_ready(slot_ready_ptr[index], *completed_prepare, progress, result,\n                                 progress_callback);\n\n    auto& outcome = (*outcomes)[index];\n    if (!outcome.entry.has_value()) {\n      mark_candidate_skipped(result, progress, candidates[index].id,\n                             outcome.error.value_or(\"unknown prepare error\"));\n      continue;\n    }\n\n    auto entry = std::move(*outcome.entry);\n\n    if (!pending_batch.empty() && pending_batch.front().uid != entry.uid) {\n      co_await send_extract_batch(app_state, std::move(pending_batch), result, progress,\n                                  progress_callback);\n      pending_batch.clear();\n      pending_batch.reserve(kExtractBatchSize);\n    }\n\n    pending_batch.push_back(std::move(entry));\n\n    if (pending_batch.size() >= kExtractBatchSize) {\n      co_await send_extract_batch(app_state, std::move(pending_batch), result, progress,\n                                  progress_callback);\n      pending_batch.clear();\n      pending_batch.reserve(kExtractBatchSize);\n    }\n  }\n\n  progress.scanned_count = static_cast<std::int64_t>(candidate_count);\n  report_processing_progress(progress_callback, progress, result, true);\n\n  co_await send_extract_batch(app_state, std::move(pending_batch), result, progress,\n                              progress_callback);\n\n  Logger().info(\n      \"extract_photo_params: completed. candidates={}, processed={}, saved={}, skipped={}, \"\n      \"failed={}, clothes_rows={}\",\n      result.candidate_count, result.processed_count, result.saved_count, result.skipped_count,\n      result.failed_count, result.clothes_rows_written);\n\n  report_extract_progress(progress_callback, \"completed\", result.processed_count,\n                          result.candidate_count, 100.0,\n                          std::format(\"Done: saved={}, skipped={}, failed={}\", result.saved_count,\n                                      result.skipped_count, result.failed_count));\n\n  co_return result;\n}\n\n}  // namespace Extensions::InfinityNikki::PhotoExtract\n"
  },
  {
    "path": "src/extensions/infinity_nikki/photo_extract/photo_extract.ixx",
    "content": "module;\n\n#include <asio.hpp>\n\nexport module Extensions.InfinityNikki.PhotoExtract;\n\nimport std;\nimport Core.State;\nimport Extensions.InfinityNikki.Types;\n\nnamespace Extensions::InfinityNikki::PhotoExtract {\n\nexport auto extract_photo_params(\n    Core::State::AppState& app_state, const InfinityNikkiExtractPhotoParamsRequest& request,\n    const std::function<void(const InfinityNikkiExtractPhotoParamsProgress&)>& progress_callback)\n    -> asio::awaitable<std::expected<InfinityNikkiExtractPhotoParamsResult, std::string>>;\n\n}  // namespace Extensions::InfinityNikki::PhotoExtract\n"
  },
  {
    "path": "src/extensions/infinity_nikki/photo_extract/scan.cpp",
    "content": "module;\n\n#include <windows.h>\n\n#include <bcrypt.h>\n\nmodule Extensions.InfinityNikki.PhotoExtract.Scan;\n\nimport std;\nimport Utils.String;\n\nnamespace Extensions::InfinityNikki::PhotoExtract::Scan {\n\nauto to_filesystem_path(const std::string& utf8_path) -> std::filesystem::path {\n  return std::filesystem::path(Utils::String::FromUtf8(utf8_path));\n}\n\nauto normalize_path_for_matching(std::string path) -> std::string {\n  std::replace(path.begin(), path.end(), '\\\\', '/');\n  return path;\n}\n\nauto extract_uid_from_asset_path(const std::string& asset_path) -> std::optional<std::string> {\n  auto normalized = normalize_path_for_matching(asset_path);\n  constexpr std::string_view kPrefix = \"/X6Game/Saved/GamePlayPhotos/\";\n\n  auto prefix_pos = normalized.find(kPrefix);\n  if (prefix_pos == std::string::npos) {\n    return std::nullopt;\n  }\n\n  auto uid_start = prefix_pos + kPrefix.size();\n  auto uid_end = normalized.find('/', uid_start);\n  if (uid_end == std::string::npos || uid_end <= uid_start) {\n    return std::nullopt;\n  }\n\n  return normalized.substr(uid_start, uid_end - uid_start);\n}\n\nauto is_base64_text(std::string_view text) -> bool {\n  if (text.empty() || text.size() % 4 != 0) {\n    return false;\n  }\n\n  for (unsigned char ch : text) {\n    if (std::isalnum(ch) || ch == '+' || ch == '/' || ch == '=') {\n      continue;\n    }\n    return false;\n  }\n  return true;\n}\n\nauto trim_ascii_whitespace(const std::string& value) -> std::string {\n  auto start = value.find_first_not_of(\" \\t\\r\\n\");\n  if (start == std::string::npos) {\n    return {};\n  }\n  auto end = value.find_last_not_of(\" \\t\\r\\n\");\n  return value.substr(start, end - start + 1);\n}\n\nauto read_file_bytes(const std::filesystem::path& file_path)\n    -> std::expected<std::vector<std::uint8_t>, std::string> {\n  std::ifstream file(file_path, std::ios::binary);\n  if (!file) {\n    return std::unexpected(\"Failed to open photo file\");\n  }\n\n  std::error_code file_size_error;\n  auto file_size = std::filesystem::file_size(file_path, file_size_error);\n  if (file_size_error) {\n    return std::unexpected(\"Failed to get photo file size: \" + file_size_error.message());\n  }\n  if (file_size > static_cast<std::uint64_t>((std::numeric_limits<std::size_t>::max)())) {\n    return std::unexpected(\"Photo file is too large\");\n  }\n\n  std::vector<std::uint8_t> payload(static_cast<std::size_t>(file_size));\n  file.read(reinterpret_cast<char*>(payload.data()), static_cast<std::streamsize>(payload.size()));\n  if (file.gcount() != static_cast<std::streamsize>(payload.size())) {\n    return std::unexpected(\"Failed to read photo payload\");\n  }\n\n  return payload;\n}\n\nauto find_roi(const std::vector<std::uint8_t>& payload, std::size_t cursor = 0) -> std::ptrdiff_t {\n  if (payload.size() < 2) {\n    return -1;\n  }\n\n  std::ptrdiff_t index =\n      static_cast<std::ptrdiff_t>(cursor == 0 ? payload.size() : std::min(cursor, payload.size()));\n  index -= 2;\n  for (; index >= 0; --index) {\n    if (payload[static_cast<std::size_t>(index)] == 0xFF &&\n        payload[static_cast<std::size_t>(index + 1)] == 0xD9) {\n      return index;\n    }\n  }\n  return -1;\n}\n\nauto to_chars(const std::vector<std::uint8_t>& bytes) -> std::vector<char> {\n  std::vector<char> chars;\n  chars.reserve(bytes.size());\n  for (auto byte : bytes) {\n    chars.push_back(static_cast<char>(byte));\n  }\n  return chars;\n}\n\nauto is_nt_success(NTSTATUS status) -> bool { return status >= 0; }\n\nauto make_ntstatus_error(std::string_view api_name, NTSTATUS status) -> std::string {\n  return std::format(\"{} failed, NTSTATUS=0x{:08X}\", api_name, static_cast<unsigned long>(status));\n}\n\nauto compute_md5_hex(const std::vector<std::uint8_t>& input, std::size_t length)\n    -> std::expected<std::string, std::string> {\n  if (length > input.size()) {\n    return std::unexpected(\"MD5 input length exceeds payload size\");\n  }\n\n  BCRYPT_ALG_HANDLE algorithm_handle = nullptr;\n  BCRYPT_HASH_HANDLE hash_handle = nullptr;\n  auto cleanup = [&]() {\n    if (hash_handle != nullptr) {\n      BCryptDestroyHash(hash_handle);\n      hash_handle = nullptr;\n    }\n    if (algorithm_handle != nullptr) {\n      BCryptCloseAlgorithmProvider(algorithm_handle, 0);\n      algorithm_handle = nullptr;\n    }\n  };\n\n  auto open_status =\n      BCryptOpenAlgorithmProvider(&algorithm_handle, BCRYPT_MD5_ALGORITHM, nullptr, 0);\n  if (!is_nt_success(open_status)) {\n    cleanup();\n    return std::unexpected(make_ntstatus_error(\"BCryptOpenAlgorithmProvider\", open_status));\n  }\n\n  DWORD hash_object_size = 0;\n  DWORD bytes_result = 0;\n  auto object_size_status = BCryptGetProperty(algorithm_handle, BCRYPT_OBJECT_LENGTH,\n                                              reinterpret_cast<PUCHAR>(&hash_object_size),\n                                              sizeof(hash_object_size), &bytes_result, 0);\n  if (!is_nt_success(object_size_status)) {\n    cleanup();\n    return std::unexpected(\n        make_ntstatus_error(\"BCryptGetProperty(BCRYPT_OBJECT_LENGTH)\", object_size_status));\n  }\n\n  DWORD hash_value_size = 0;\n  auto hash_size_status = BCryptGetProperty(algorithm_handle, BCRYPT_HASH_LENGTH,\n                                            reinterpret_cast<PUCHAR>(&hash_value_size),\n                                            sizeof(hash_value_size), &bytes_result, 0);\n  if (!is_nt_success(hash_size_status)) {\n    cleanup();\n    return std::unexpected(\n        make_ntstatus_error(\"BCryptGetProperty(BCRYPT_HASH_LENGTH)\", hash_size_status));\n  }\n\n  std::vector<UCHAR> hash_object(hash_object_size);\n  std::vector<UCHAR> hash_value(hash_value_size);\n  auto create_hash_status = BCryptCreateHash(algorithm_handle, &hash_handle, hash_object.data(),\n                                             static_cast<ULONG>(hash_object.size()), nullptr, 0, 0);\n  if (!is_nt_success(create_hash_status)) {\n    cleanup();\n    return std::unexpected(make_ntstatus_error(\"BCryptCreateHash\", create_hash_status));\n  }\n\n  auto hash_data_status =\n      BCryptHashData(hash_handle, const_cast<PUCHAR>(reinterpret_cast<const UCHAR*>(input.data())),\n                     static_cast<ULONG>(length), 0);\n  if (!is_nt_success(hash_data_status)) {\n    cleanup();\n    return std::unexpected(make_ntstatus_error(\"BCryptHashData\", hash_data_status));\n  }\n\n  auto finish_status =\n      BCryptFinishHash(hash_handle, hash_value.data(), static_cast<ULONG>(hash_value.size()), 0);\n  if (!is_nt_success(finish_status)) {\n    cleanup();\n    return std::unexpected(make_ntstatus_error(\"BCryptFinishHash\", finish_status));\n  }\n\n  cleanup();\n\n  std::string hex;\n  hex.reserve(hash_value.size() * 2);\n  for (auto byte : hash_value) {\n    hex += std::format(\"{:02x}\", byte);\n  }\n  return hex;\n}\n\nauto build_photo_tuple(const std::vector<std::uint8_t>& payload, const std::string& uid)\n    -> std::expected<std::pair<std::string, std::string>, std::string> {\n  auto roi1 = find_roi(payload);\n  if (roi1 < 0) {\n    return std::unexpected(\"Missing ROI1 marker\");\n  }\n  auto roi2 = find_roi(payload, static_cast<std::size_t>(roi1));\n  if (roi2 < 0) {\n    return std::unexpected(\"Missing ROI2 marker\");\n  }\n  if (roi2 >= roi1) {\n    return std::unexpected(\"Invalid ROI boundaries\");\n  }\n  if (uid.empty()) {\n    return std::unexpected(\"UID is empty\");\n  }\n\n  auto bhash = trim_ascii_whitespace(std::string(\n      reinterpret_cast<const char*>(payload.data() + roi1 + 2), payload.size() - (roi1 + 2)));\n  auto bdata = trim_ascii_whitespace(\n      std::string(reinterpret_cast<const char*>(payload.data() + roi2 + 2), (roi1 - 2) - roi2));\n\n  if (!is_base64_text(bhash) || !is_base64_text(bdata)) {\n    return std::unexpected(\"Embedded segments are not valid Base64 text\");\n  }\n\n  auto hash_buf_chars = Utils::String::FromBase64(bhash);\n  auto data_buf_chars = Utils::String::FromBase64(bdata);\n  if (hash_buf_chars.empty() || data_buf_chars.empty()) {\n    return std::unexpected(\"Failed to decode embedded Base64 segments\");\n  }\n\n  std::vector<std::uint8_t> hash_buf;\n  hash_buf.reserve(hash_buf_chars.size());\n  for (char ch : hash_buf_chars) {\n    hash_buf.push_back(static_cast<std::uint8_t>(ch));\n  }\n  std::vector<std::uint8_t> data_buf;\n  data_buf.reserve(data_buf_chars.size());\n  for (char ch : data_buf_chars) {\n    data_buf.push_back(static_cast<std::uint8_t>(ch));\n  }\n\n  std::vector<std::uint8_t> buf;\n  buf.reserve(hash_buf.size() + data_buf.size());\n  buf.insert(buf.end(), hash_buf.begin(), hash_buf.end());\n  buf.insert(buf.end(), data_buf.begin(), data_buf.end());\n\n  auto hash_len = hash_buf.size();\n  for (std::size_t i = hash_len; i < buf.size(); ++i) {\n    buf[i] ^= buf[i % hash_len];\n  }\n  for (std::size_t i = 0; i < hash_len; ++i) {\n    buf[i] ^= static_cast<std::uint8_t>(uid[i % uid.size()]);\n  }\n\n  auto encoded = Utils::String::ToBase64(to_chars(buf));\n  auto md5_result = compute_md5_hex(payload, static_cast<std::size_t>(roi1));\n  if (!md5_result) {\n    return std::unexpected(md5_result.error());\n  }\n  return std::pair{std::move(md5_result.value()), std::move(encoded)};\n}\n\nauto prepare_photo_extract_entry(const CandidateAssetRow& candidate,\n                                 const std::optional<std::string>& uid_override)\n    -> std::expected<PreparedPhotoExtractEntry, std::string> {\n  std::string uid;\n  if (uid_override.has_value()) {\n    if (uid_override->empty()) {\n      return std::unexpected(\"UID override is empty\");\n    }\n    uid = *uid_override;\n  } else {\n    auto uid_result = extract_uid_from_asset_path(candidate.path);\n    if (!uid_result.has_value()) {\n      return std::unexpected(\"cannot parse UID from path\");\n    }\n    uid = std::move(uid_result.value());\n  }\n\n  auto payload_result = read_file_bytes(to_filesystem_path(candidate.path));\n  if (!payload_result) {\n    return std::unexpected(payload_result.error());\n  }\n\n  auto tuple_result = build_photo_tuple(payload_result.value(), uid);\n  if (!tuple_result) {\n    return std::unexpected(tuple_result.error());\n  }\n\n  return PreparedPhotoExtractEntry{\n      .asset_id = candidate.id,\n      .uid = std::move(uid),\n      .md5 = std::move(tuple_result->first),\n      .encoded = std::move(tuple_result->second),\n  };\n}\n\n}  // namespace Extensions::InfinityNikki::PhotoExtract::Scan\n"
  },
  {
    "path": "src/extensions/infinity_nikki/photo_extract/scan.ixx",
    "content": "module;\n\nexport module Extensions.InfinityNikki.PhotoExtract.Scan;\n\nimport std;\n\nexport namespace Extensions::InfinityNikki::PhotoExtract::Scan {\n\nstruct CandidateAssetRow {\n  std::int64_t id;\n  std::string path;\n};\n\nstruct PreparedPhotoExtractEntry {\n  std::int64_t asset_id;\n  std::string uid;\n  std::string md5;\n  std::string encoded;\n};\n\nauto prepare_photo_extract_entry(const CandidateAssetRow& candidate,\n                                 const std::optional<std::string>& uid_override = std::nullopt)\n    -> std::expected<PreparedPhotoExtractEntry, std::string>;\n\n}  // namespace Extensions::InfinityNikki::PhotoExtract::Scan\n"
  },
  {
    "path": "src/extensions/infinity_nikki/photo_service.cpp",
    "content": "module;\n\nmodule Extensions.InfinityNikki.PhotoService;\n\nimport std;\nimport Core.State;\nimport Core.Tasks;\nimport Core.WorkerPool;\nimport Features.Gallery;\nimport Features.Gallery.Folder.Service;\nimport Features.Gallery.Ignore.Repository;\nimport Features.Gallery.Watcher;\nimport Features.Gallery.Types;\nimport Features.Settings.State;\nimport Extensions.InfinityNikki.TaskService;\nimport Extensions.InfinityNikki.ScreenshotHardlinks;\nimport Extensions.InfinityNikki.Types;\nimport Utils.Logger;\nimport Utils.Path;\n\nnamespace Extensions::InfinityNikki::PhotoService {\n\nstruct ServiceState {\n  std::mutex mutex;\n  std::filesystem::path current_watch_path;\n};\n\nauto service_state() -> ServiceState& {\n  static ServiceState instance;\n  return instance;\n}\n\n// 生成《无限暖暖》照片专用的忽略推断规则\nauto make_infinity_nikki_ignore_rules() -> std::vector<Features::Gallery::Types::ScanIgnoreRule> {\n  using Features::Gallery::Types::ScanIgnoreRule;\n\n  return {\n      ScanIgnoreRule{.pattern = \"^.*$\", .pattern_type = \"regex\", .rule_type = \"exclude\"},\n      ScanIgnoreRule{.pattern = \"^[0-9]+/NikkiPhotos_HighQuality(/.*)?$\",\n                     .pattern_type = \"regex\",\n                     .rule_type = \"include\"},\n  };\n}\n\n// 确保监听根目录的过滤规则在数据库中存在并生效\nauto ensure_watch_root_ignore_rules(Core::State::AppState& app_state,\n                                    const std::filesystem::path& watch_root)\n    -> std::expected<void, std::string> {\n  auto normalized_watch_root_result = Utils::Path::NormalizePath(watch_root);\n  if (!normalized_watch_root_result) {\n    return std::unexpected(\"Failed to normalize GamePlayPhotos root folder: \" +\n                           normalized_watch_root_result.error());\n  }\n\n  auto normalized_watch_root = normalized_watch_root_result.value();\n  std::vector<std::filesystem::path> root_paths = {normalized_watch_root};\n  auto folder_mapping_result =\n      Features::Gallery::Folder::Service::batch_create_folders_for_paths(app_state, root_paths);\n  if (!folder_mapping_result) {\n    return std::unexpected(\"Failed to create GamePlayPhotos root folder: \" +\n                           folder_mapping_result.error());\n  }\n\n  auto root_key = normalized_watch_root.string();\n  auto root_it = folder_mapping_result->find(root_key);\n  if (root_it == folder_mapping_result->end()) {\n    return std::unexpected(\"Failed to resolve GamePlayPhotos root folder id\");\n  }\n\n  auto persist_result = Features::Gallery::Ignore::Repository::replace_rules_by_folder_id(\n      app_state, root_it->second, make_infinity_nikki_ignore_rules());\n  if (!persist_result) {\n    return std::unexpected(\"Failed to persist InfinityNikki ignore rules: \" +\n                           persist_result.error());\n  }\n\n  return {};\n}\n\n// 每次画廊扫描完毕后触发的回调处理，包含同步 ScreenShot 硬链接和提取照片参数\nauto on_gallery_scan_complete(Core::State::AppState& app_state,\n                              const Features::Gallery::Types::ScanResult& result) -> void {\n  if (!app_state.settings) {\n    return;\n  }\n\n  const auto& config = app_state.settings->raw.extensions.infinity_nikki;\n\n  if (config.manage_screenshot_hardlinks) {\n    if (Core::Tasks::has_active_task_of_type(\n            app_state, \"extensions.infinityNikki.initializeScreenshotHardlinks\")) {\n      Logger().debug(\"Skip InfinityNikki screenshot hardlink sync: initialization task is active\");\n    } else {\n      // 增量 watcher 会携带逐文件 changes；只有拿不到变化集时才回退到全量 sync。\n      auto runtime_changes = result.changes;\n      bool submitted = Core::WorkerPool::submit_task(\n          *app_state.worker_pool, [&app_state, runtime_changes = std::move(runtime_changes)]() {\n            auto sync_result =\n                runtime_changes.empty()\n                    ? Extensions::InfinityNikki::ScreenshotHardlinks::sync(app_state)\n                    : Extensions::InfinityNikki::ScreenshotHardlinks::apply_runtime_changes(\n                          app_state, runtime_changes);\n            if (!sync_result) {\n              Logger().warn(\"InfinityNikki screenshot hardlinks sync failed: {}\",\n                            sync_result.error());\n            } else {\n              const auto& r = sync_result.value();\n              Logger().info(\n                  \"InfinityNikki screenshot hardlinks synced: source={}, created={}, updated={}, \"\n                  \"removed={}, ignored={}\",\n                  r.source_count, r.created_count, r.updated_count, r.removed_count,\n                  r.ignored_count);\n            }\n          });\n      if (!submitted) {\n        Logger().warn(\"InfinityNikki hardlinks sync: failed to submit worker task\");\n      }\n    }\n  }\n\n  if (config.allow_online_photo_metadata_extract && result.new_items > 0) {\n    Extensions::InfinityNikki::TaskService::schedule_silent_extract_photo_params(\n        app_state, InfinityNikkiExtractPhotoParamsRequest{.only_missing = true});\n  }\n}\n\nauto make_initial_scan_options(const std::filesystem::path& directory)\n    -> Features::Gallery::Types::ScanOptions {\n  Features::Gallery::Types::ScanOptions options;\n  options.directory = directory.string();\n  options.ignore_rules = make_infinity_nikki_ignore_rules();\n  return options;\n}\n\n// 根据当前的系统设置，注册《无限暖暖》照片服务的监听目录与回调。\nauto register_impl(Core::State::AppState& app_state, bool start_immediately) -> void {\n  auto& state = service_state();\n  std::lock_guard<std::mutex> lock(state.mutex);\n\n  auto stop_current_watcher = [&]() {\n    if (state.current_watch_path.empty()) {\n      return;\n    }\n    auto remove_result = Features::Gallery::Watcher::remove_watcher_for_directory(\n        app_state, state.current_watch_path);\n    if (!remove_result) {\n      Logger().warn(\"Failed to remove InfinityNikki gallery watcher for '{}': {}\",\n                    state.current_watch_path.string(), remove_result.error());\n    }\n    state.current_watch_path.clear();\n  };\n\n  if (!app_state.settings) {\n    stop_current_watcher();\n    return;\n  }\n\n  const auto& config = app_state.settings->raw.extensions.infinity_nikki;\n  if (!config.enable || config.game_dir.empty()) {\n    stop_current_watcher();\n    return;\n  }\n\n  if (!config.gallery_guide_seen) {\n    Logger().info(\"Skip InfinityNikki gallery watcher until gallery guide is completed\");\n    stop_current_watcher();\n    return;\n  }\n\n  auto dir_result =\n      Extensions::InfinityNikki::ScreenshotHardlinks::resolve_watch_directory(app_state);\n  if (!dir_result) {\n    Logger().warn(\"Skip InfinityNikki gallery watcher: {}\", dir_result.error());\n    stop_current_watcher();\n    return;\n  }\n\n  auto new_watch_path = dir_result.value();\n  auto requires_initial_scan =\n      state.current_watch_path.empty() || state.current_watch_path != new_watch_path;\n  if (!state.current_watch_path.empty() && state.current_watch_path != new_watch_path) {\n    stop_current_watcher();\n  }\n\n  auto rules_result = ensure_watch_root_ignore_rules(app_state, new_watch_path);\n  if (!rules_result) {\n    Logger().warn(\"Failed to prepare InfinityNikki gallery rules for '{}': {}\",\n                  new_watch_path.string(), rules_result.error());\n    return;\n  }\n\n  auto callback = [&app_state](const Features::Gallery::Types::ScanResult& result) {\n    on_gallery_scan_complete(app_state, result);\n  };\n\n  auto register_result =\n      Features::Gallery::Watcher::register_watcher_for_directory(app_state, new_watch_path);\n  if (!register_result) {\n    Logger().warn(\"Failed to register InfinityNikki gallery watcher for '{}': {}\",\n                  new_watch_path.string(), register_result.error());\n    return;\n  }\n\n  auto callback_result = Features::Gallery::Watcher::set_post_scan_callback_for_directory(\n      app_state, new_watch_path, std::move(callback));\n  if (!callback_result) {\n    Logger().warn(\"Failed to set InfinityNikki gallery watcher callback for '{}': {}\",\n                  new_watch_path.string(), callback_result.error());\n    return;\n  }\n\n  if (start_immediately) {\n    if (requires_initial_scan) {\n      auto task_result = Extensions::InfinityNikki::TaskService::start_initial_scan_task(\n          app_state, make_initial_scan_options(new_watch_path),\n          [&app_state](const Features::Gallery::Types::ScanResult& result) {\n            on_gallery_scan_complete(app_state, result);\n          });\n      if (!task_result) {\n        Logger().warn(\"Failed to start InfinityNikki initial scan task for '{}': {}\",\n                      new_watch_path.string(), task_result.error());\n        return;\n      }\n      Logger().info(\"InfinityNikki initial scan task started: {}\", task_result.value());\n    } else {\n      auto start_result =\n          Features::Gallery::Watcher::start_watcher_for_directory(app_state, new_watch_path, false);\n      if (!start_result) {\n        Logger().warn(\"Failed to start InfinityNikki gallery watcher for '{}': {}\",\n                      new_watch_path.string(), start_result.error());\n        return;\n      }\n    }\n  }\n\n  state.current_watch_path = new_watch_path;\n  Logger().info(\"InfinityNikki gallery watcher registered: {}\", new_watch_path.string());\n}\n\nauto register_from_settings(Core::State::AppState& app_state) -> void {\n  register_impl(app_state, false);\n}\n\n// 根据当前的系统设置，动态刷新照片服务的监听行为\nauto refresh_from_settings(Core::State::AppState& app_state) -> void {\n  register_impl(app_state, true);\n}\n\n// 在程序退出时执行清理和释放工作\nauto shutdown(Core::State::AppState& app_state) -> void {\n  auto& state = service_state();\n  std::lock_guard<std::mutex> lock(state.mutex);\n  if (!state.current_watch_path.empty()) {\n    auto remove_result = Features::Gallery::Watcher::remove_watcher_for_directory(\n        app_state, state.current_watch_path);\n    if (!remove_result) {\n      Logger().warn(\"Failed to remove InfinityNikki gallery watcher during shutdown for '{}': {}\",\n                    state.current_watch_path.string(), remove_result.error());\n    }\n    state.current_watch_path.clear();\n  }\n}\n\n}  // namespace Extensions::InfinityNikki::PhotoService\n"
  },
  {
    "path": "src/extensions/infinity_nikki/photo_service.ixx",
    "content": "module;\n\nexport module Extensions.InfinityNikki.PhotoService;\n\nimport std;\nimport Core.State;\n\nnamespace Extensions::InfinityNikki::PhotoService {\n\n// 根据当前设置决定是否向 Gallery.Watcher 注册无限暖暖目录监听。\n// 监听触发扫描后，回调驱动 ScreenShot 硬链接同步与照片元数据提取。\n// 须在 Features::Gallery::initialize 之后调用。\nexport auto register_from_settings(Core::State::AppState& app_state) -> void;\n\nexport auto refresh_from_settings(Core::State::AppState& app_state) -> void;\n\nexport auto shutdown(Core::State::AppState& app_state) -> void;\n\n}  // namespace Extensions::InfinityNikki::PhotoService\n"
  },
  {
    "path": "src/extensions/infinity_nikki/screenshot_hardlinks.cpp",
    "content": "module;\n\nmodule Extensions.InfinityNikki.ScreenshotHardlinks;\n\nimport std;\nimport Core.State;\nimport Features.Gallery.Types;\nimport Features.Settings.State;\nimport Extensions.InfinityNikki.Types;\nimport Utils.String;\n\nnamespace Extensions::InfinityNikki::ScreenshotHardlinks {\n\n// 单次同步最多收集的错误条数，避免在大量文件出错时撑爆内存\nconstexpr std::size_t kMaxErrorMessages = 50;\n// 游戏高清照片的存储路径结构：GamePlayPhotos/<uid>/NikkiPhotos_HighQuality/<文件名>\nconstexpr wchar_t kHighQualityFolderName[] = L\"NikkiPhotos_HighQuality\";\nconstexpr wchar_t kGamePlayPhotosFolderName[] = L\"GamePlayPhotos\";\n// 游戏截图目录：本功能会将此目录管理为高清原图的硬链接镜像\nconstexpr wchar_t kScreenShotFolderName[] = L\"ScreenShot\";\nconstexpr std::array<std::string_view, 7> kSupportedExtensions = {\".jpg\",  \".jpeg\", \".png\", \".bmp\",\n                                                                  \".webp\", \".tiff\", \".tif\"};\n\n// 本功能管理的所有关键路径，由设置中的游戏目录派生而来\nstruct ManagedPaths {\n  std::filesystem::path game_dir;\n  std::filesystem::path x6game_dir;\n  std::filesystem::path gameplay_photos_dir;  // 扫描高清原图的根目录\n  std::filesystem::path screenshot_dir;       // 放置硬链接镜像的目录\n};\n\n// 一张高清原图及其对应的硬链接信息\nstruct SourceAsset {\n  std::filesystem::path target_path;  // 高清原图的绝对路径（硬链接目标）\n  std::string original_filename;      // 原始文件名（不含路径）\n  std::filesystem::path link_path;    // 将在 ScreenShot 目录中创建的硬链接路径\n};\n\nenum class LinkWriteAction {\n  kNone,\n  kCreated,\n  kUpdated,\n};\n\nauto add_error(std::vector<std::string>& errors, std::string message) -> void {\n  if (errors.size() >= kMaxErrorMessages) {\n    return;\n  }\n  errors.push_back(std::move(message));\n}\n\nauto report_progress(\n    const std::function<void(const InfinityNikkiInitializeScreenshotHardlinksProgress&)>&\n        progress_callback,\n    std::string stage, std::int64_t current, std::int64_t total,\n    std::optional<double> percent = std::nullopt, std::optional<std::string> message = std::nullopt)\n    -> void {\n  if (!progress_callback) {\n    return;\n  }\n\n  if (percent.has_value()) {\n    percent = std::clamp(*percent, 0.0, 100.0);\n  }\n\n  progress_callback(InfinityNikkiInitializeScreenshotHardlinksProgress{\n      .stage = std::move(stage),\n      .current = current,\n      .total = total,\n      .percent = percent,\n      .message = std::move(message),\n  });\n}\n\n// 路径规范化：若路径已存在则用 weakly_canonical 解析符号链接和 . / ..，\n// 否则仅做词法规范化（不访问文件系统），确保路径可用于比较\nauto normalize_existing_path(const std::filesystem::path& path) -> std::filesystem::path {\n  std::error_code ec;\n  if (std::filesystem::exists(path, ec) && !ec) {\n    auto normalized = std::filesystem::weakly_canonical(path, ec);\n    if (!ec) {\n      return normalized;\n    }\n  }\n  return path.lexically_normal();\n}\n\n// 生成用于路径相等比较的规范化键：统一为正斜杠（generic）并转小写，\n// 使路径比较在 Windows NTFS 上不区分大小写和路径分隔符\nauto make_path_compare_key(const std::filesystem::path& path) -> std::string {\n  auto normalized = normalize_existing_path(path).generic_wstring();\n  std::transform(normalized.begin(), normalized.end(), normalized.begin(),\n                 [](wchar_t ch) { return static_cast<wchar_t>(std::towlower(ch)); });\n  return Utils::String::ToUtf8(normalized);\n}\n\nauto path_has_high_quality_segment(const std::filesystem::path& relative_path) -> bool {\n  for (const auto& part : relative_path) {\n    if (part.wstring() == kHighQualityFolderName) {\n      return true;\n    }\n  }\n  return false;\n}\n\nauto is_supported_image_extension(const std::filesystem::path& file_path) -> bool {\n  auto extension = Utils::String::ToLowerAscii(file_path.extension().string());\n  return std::ranges::any_of(kSupportedExtensions, [&extension](std::string_view candidate) {\n    return extension == candidate;\n  });\n}\n\n// 判断文件是否为本功能管理的高清原图：需满足普通文件、图片扩展名、\n// 且路径中包含 NikkiPhotos_HighQuality 目录段\nauto is_hq_photo_file(const ManagedPaths& paths, const std::filesystem::path& file_path) -> bool {\n  std::error_code ec;\n  if (!std::filesystem::is_regular_file(file_path, ec) || ec) {\n    return false;\n  }\n\n  if (!is_supported_image_extension(file_path)) {\n    return false;\n  }\n\n  auto relative_path = std::filesystem::relative(file_path, paths.gameplay_photos_dir, ec);\n  if (ec || relative_path.empty()) {\n    return false;\n  }\n\n  return path_has_high_quality_segment(relative_path);\n}\n\n// 从设置中的游戏目录派生所有受管路径，并验证 GamePlayPhotos 目录实际存在\nauto resolve_managed_paths(Core::State::AppState& app_state)\n    -> std::expected<ManagedPaths, std::string> {\n  if (!app_state.settings) {\n    return std::unexpected(\"Settings state is not initialized\");\n  }\n\n  const auto& game_dir_utf8 = app_state.settings->raw.extensions.infinity_nikki.game_dir;\n  if (game_dir_utf8.empty()) {\n    return std::unexpected(\"Infinity Nikki game directory is empty\");\n  }\n\n  auto game_dir = std::filesystem::path(Utils::String::FromUtf8(game_dir_utf8));\n  if (game_dir.empty()) {\n    return std::unexpected(\"Failed to resolve Infinity Nikki game directory\");\n  }\n\n  auto x6game_dir = game_dir / L\"X6Game\";\n  auto gameplay_photos_dir = x6game_dir / L\"Saved\" / kGamePlayPhotosFolderName;\n  auto screenshot_dir = x6game_dir / kScreenShotFolderName;\n\n  std::error_code ec;\n  if (!std::filesystem::exists(gameplay_photos_dir, ec) || ec) {\n    return std::unexpected(\"GamePlayPhotos directory does not exist\");\n  }\n\n  // screenshot_dir 在此阶段可能尚不存在，仅做词法规范化\n  return ManagedPaths{\n      .game_dir = normalize_existing_path(game_dir),\n      .x6game_dir = normalize_existing_path(x6game_dir),\n      .gameplay_photos_dir = normalize_existing_path(gameplay_photos_dir),\n      .screenshot_dir = screenshot_dir.lexically_normal(),\n  };\n}\n\nauto make_link_path(const ManagedPaths& paths, const std::filesystem::path& target_path)\n    -> std::filesystem::path {\n  // 领域约束：Infinity Nikki 的照片文件名视为全局唯一，\n  // ScreenShot 中的受管文件名直接等于高清原图文件名本身。\n  return paths.screenshot_dir / target_path.filename();\n}\n\nauto is_managed_hq_photo_path(const ManagedPaths& paths, const std::filesystem::path& file_path)\n    -> bool {\n  if (!is_supported_image_extension(file_path)) {\n    return false;\n  }\n\n  std::error_code ec;\n  auto relative_path = std::filesystem::relative(file_path, paths.gameplay_photos_dir, ec);\n  if (ec || relative_path.empty()) {\n    return false;\n  }\n\n  return path_has_high_quality_segment(relative_path);\n}\n\n// 扫描 GamePlayPhotos 目录下所有高清原图，并直接使用原始文件名生成 ScreenShot 硬链接路径。\n// 若发现重名文件，则视为违反《无限暖暖》文件名全局唯一的领域约束，直接报错。\nauto collect_source_assets(\n    const ManagedPaths& paths,\n    const std::function<void(const InfinityNikkiInitializeScreenshotHardlinksProgress&)>&\n        progress_callback,\n    InfinityNikkiInitializeScreenshotHardlinksResult& result)\n    -> std::expected<std::vector<SourceAsset>, std::string> {\n  std::vector<SourceAsset> sources;\n  std::unordered_map<std::string, std::filesystem::path> filename_to_path;\n  std::error_code ec;\n\n  report_progress(progress_callback, \"preparing\", 0, 0, 5.0, \"Scanning NikkiPhotos_HighQuality\");\n\n  std::filesystem::recursive_directory_iterator end_iter;\n  for (std::filesystem::recursive_directory_iterator iter(\n           paths.gameplay_photos_dir, std::filesystem::directory_options::skip_permission_denied,\n           ec);\n       iter != end_iter; iter.increment(ec)) {\n    if (ec) {\n      add_error(result.errors, \"Failed to iterate GamePlayPhotos: \" + ec.message());\n      ec.clear();\n      continue;\n    }\n\n    if (!iter->is_regular_file(ec) || ec) {\n      ec.clear();\n      continue;\n    }\n\n    auto file_path = iter->path();\n    if (!is_hq_photo_file(paths, file_path)) {\n      continue;\n    }\n\n    auto normalized_target = normalize_existing_path(file_path);\n    auto original_filename = Utils::String::ToUtf8(normalized_target.filename().wstring());\n    auto filename_key = Utils::String::ToLowerAscii(original_filename);\n\n    if (auto it = filename_to_path.find(filename_key); it != filename_to_path.end()) {\n      return std::unexpected(\"Duplicate Infinity Nikki screenshot filename detected: '\" +\n                             original_filename + \"' from '\" +\n                             Utils::String::ToUtf8(it->second.wstring()) + \"' and '\" +\n                             Utils::String::ToUtf8(normalized_target.wstring()) + \"'\");\n    }\n    filename_to_path.emplace(filename_key, normalized_target);\n\n    sources.push_back(SourceAsset{\n        .target_path = normalized_target,\n        .original_filename = original_filename,\n        .link_path = make_link_path(paths, normalized_target),\n    });\n  }\n\n  // 按硬链接文件名排序，使游戏内截图列表顺序稳定\n  std::ranges::sort(sources, [](const SourceAsset& lhs, const SourceAsset& rhs) {\n    return lhs.link_path.filename().wstring() < rhs.link_path.filename().wstring();\n  });\n\n  result.source_count = static_cast<std::int32_t>(sources.size());\n  return std::vector<SourceAsset>(std::move(sources));\n}\n\nauto ensure_directory_exists(const std::filesystem::path& path)\n    -> std::expected<void, std::string> {\n  std::error_code ec;\n  std::filesystem::create_directories(path, ec);\n  if (ec) {\n    return std::unexpected(\"Failed to create directory '\" + Utils::String::ToUtf8(path.wstring()) +\n                           \"': \" + ec.message());\n  }\n  return {};\n}\n\nauto are_equivalent_entries(const std::filesystem::path& lhs, const std::filesystem::path& rhs)\n    -> bool {\n  std::error_code ec;\n  auto equivalent = std::filesystem::equivalent(lhs, rhs, ec);\n  return !ec && equivalent;\n}\n\nauto ensure_hardlink(const std::filesystem::path& link_path,\n                     const std::filesystem::path& target_path)\n    -> std::expected<LinkWriteAction, std::string> {\n  if (make_path_compare_key(link_path) == make_path_compare_key(target_path)) {\n    return std::unexpected(\"Managed hard link path must be different from target path: '\" +\n                           Utils::String::ToUtf8(link_path.wstring()) + \"'\");\n  }\n\n  auto ensure_result = ensure_directory_exists(link_path.parent_path());\n  if (!ensure_result) {\n    return std::unexpected(ensure_result.error());\n  }\n\n  std::error_code ec;\n  auto link_exists = std::filesystem::exists(link_path, ec);\n  if (ec) {\n    return std::unexpected(\"Failed to inspect ScreenShot entry '\" +\n                           Utils::String::ToUtf8(link_path.wstring()) + \"': \" + ec.message());\n  }\n\n  if (link_exists && are_equivalent_entries(link_path, target_path)) {\n    return LinkWriteAction::kNone;\n  }\n\n  auto action = link_exists ? LinkWriteAction::kUpdated : LinkWriteAction::kCreated;\n\n  if (link_exists) {\n    std::filesystem::remove_all(link_path, ec);\n    if (ec) {\n      return std::unexpected(\"Failed to replace existing ScreenShot entry '\" +\n                             Utils::String::ToUtf8(link_path.wstring()) + \"': \" + ec.message());\n    }\n  }\n\n  std::filesystem::create_hard_link(target_path, link_path, ec);\n  if (ec) {\n    auto detail = ec.message();\n    if (ec == std::make_error_code(std::errc::cross_device_link)) {\n      detail += \" (hard links require source and destination on the same volume)\";\n    }\n\n    return std::unexpected(\"Failed to create hard link '\" +\n                           Utils::String::ToUtf8(link_path.wstring()) + \"' -> '\" +\n                           Utils::String::ToUtf8(target_path.wstring()) + \"': \" + detail);\n  }\n\n  return action;\n}\n\n// 硬链接同步的核心逻辑，分两阶段执行：\n// 1. 清理阶段：遍历 ScreenShot 目录，删除不在期望集合内的受管目录项\n// 2. 同步阶段：遍历 sources，确保每个期望文件名都存在并指向目标原图\nauto sync_hardlinks_internal(\n    Core::State::AppState& app_state,\n    const std::function<void(const InfinityNikkiInitializeScreenshotHardlinksProgress&)>&\n        progress_callback)\n    -> std::expected<InfinityNikkiInitializeScreenshotHardlinksResult, std::string> {\n  auto paths_result = resolve_managed_paths(app_state);\n  if (!paths_result) {\n    return std::unexpected(paths_result.error());\n  }\n  const auto paths = paths_result.value();\n\n  InfinityNikkiInitializeScreenshotHardlinksResult result;\n\n  auto ensure_result = ensure_directory_exists(paths.screenshot_dir);\n  if (!ensure_result) {\n    return std::unexpected(ensure_result.error());\n  }\n\n  auto sources_result = collect_source_assets(paths, progress_callback, result);\n  if (!sources_result) {\n    return std::unexpected(sources_result.error());\n  }\n  auto sources = std::move(sources_result.value());\n  report_progress(progress_callback, \"syncing\", 0, result.source_count, 20.0,\n                  std::format(\"Found {} high-quality photos\", result.source_count));\n\n  // 以期望硬链接路径为键建立索引，用于清理阶段判断某个目录项是否应保留\n  std::unordered_set<std::string> expected_link_keys;\n  expected_link_keys.reserve(sources.size());\n  for (const auto& source : sources) {\n    expected_link_keys.insert(make_path_compare_key(source.link_path));\n  }\n\n  // 清理阶段：删除 ScreenShot 目录中不在期望集合内的内容\n  std::error_code ec;\n  for (const auto& entry : std::filesystem::directory_iterator(paths.screenshot_dir, ec)) {\n    if (ec) {\n      return std::unexpected(\"Failed to enumerate ScreenShot directory: \" + ec.message());\n    }\n\n    auto entry_key = make_path_compare_key(entry.path());\n    if (expected_link_keys.contains(entry_key)) {\n      continue;\n    }\n\n    std::filesystem::remove_all(entry.path(), ec);\n    if (!ec) {\n      result.removed_count++;\n    } else {\n      add_error(result.errors, \"Failed to remove obsolete ScreenShot entry '\" +\n                                   Utils::String::ToUtf8(entry.path().wstring()) +\n                                   \"': \" + ec.message());\n      ec.clear();\n    }\n  }\n\n  // 同步阶段：确保每个 source 都有对应的硬链接\n  for (std::size_t index = 0; index < sources.size(); ++index) {\n    const auto& source = sources[index];\n    report_progress(\n        progress_callback, \"syncing\", static_cast<std::int64_t>(index), result.source_count,\n        20.0 + (static_cast<double>(index) * 75.0 / std::max<std::int32_t>(result.source_count, 1)),\n        std::format(\"Syncing {}\", source.original_filename));\n\n    auto ensure_result = ensure_hardlink(source.link_path, source.target_path);\n    if (!ensure_result) {\n      add_error(result.errors, ensure_result.error());\n      continue;\n    }\n\n    switch (ensure_result.value()) {\n      case LinkWriteAction::kCreated:\n        result.created_count++;\n        break;\n      case LinkWriteAction::kUpdated:\n        result.updated_count++;\n        break;\n      case LinkWriteAction::kNone:\n      default:\n        break;\n    }\n  }\n\n  report_progress(\n      progress_callback, \"completed\", result.source_count, result.source_count, 100.0,\n      std::format(\"Done: {} created, {} updated, {} removed, {} ignored\", result.created_count,\n                  result.updated_count, result.removed_count, result.ignored_count));\n  return result;\n}\n\nauto remove_managed_link(const std::filesystem::path& link_path)\n    -> std::expected<bool, std::string> {\n  std::error_code ec;\n  if (!std::filesystem::exists(link_path, ec)) {\n    if (ec) {\n      return std::unexpected(\"Failed to inspect ScreenShot entry '\" +\n                             Utils::String::ToUtf8(link_path.wstring()) + \"': \" + ec.message());\n    }\n    return false;\n  }\n\n  std::filesystem::remove_all(link_path, ec);\n  if (ec) {\n    return std::unexpected(\"Failed to remove ScreenShot entry '\" +\n                           Utils::String::ToUtf8(link_path.wstring()) + \"': \" + ec.message());\n  }\n\n  return true;\n}\n\nauto apply_runtime_changes(Core::State::AppState& app_state,\n                           const std::vector<Features::Gallery::Types::ScanChange>& changes)\n    -> std::expected<InfinityNikkiInitializeScreenshotHardlinksResult, std::string> {\n  auto paths_result = resolve_managed_paths(app_state);\n  if (!paths_result) {\n    return std::unexpected(paths_result.error());\n  }\n  const auto paths = paths_result.value();\n\n  InfinityNikkiInitializeScreenshotHardlinksResult result;\n  // 这里的 source_count 语义不再是“全量源图数量”，而是“本次收到的运行时变化条数”。\n  // 保留该字段是为了复用现有的结果结构与日志格式。\n  result.source_count = static_cast<std::int32_t>(changes.size());\n\n  for (const auto& change : changes) {\n    auto changed_path = normalize_existing_path(std::filesystem::path(change.path));\n    // 变化集来自通用 Gallery watcher，这里只消费 Infinity Nikki 管辖的高清原图路径。\n    if (!is_managed_hq_photo_path(paths, changed_path)) {\n      result.ignored_count++;\n      continue;\n    }\n\n    auto link_path = make_link_path(paths, changed_path);\n\n    switch (change.action) {\n      case Features::Gallery::Types::ScanChangeAction::UPSERT: {\n        std::error_code ec;\n        if (!std::filesystem::is_regular_file(changed_path, ec) || ec) {\n          if (ec) {\n            add_error(result.errors, \"Failed to inspect runtime source '\" +\n                                         Utils::String::ToUtf8(changed_path.wstring()) +\n                                         \"': \" + ec.message());\n          } else {\n            result.ignored_count++;\n          }\n          break;\n        }\n\n        auto ensure_result = ensure_hardlink(link_path, changed_path);\n        if (!ensure_result) {\n          add_error(result.errors, ensure_result.error());\n          break;\n        }\n\n        switch (ensure_result.value()) {\n          case LinkWriteAction::kCreated:\n            result.created_count++;\n            break;\n          case LinkWriteAction::kUpdated:\n            result.updated_count++;\n            break;\n          case LinkWriteAction::kNone:\n          default:\n            break;\n        }\n        break;\n      }\n      case Features::Gallery::Types::ScanChangeAction::REMOVE: {\n        // 运行时删除只按“同名映射”撤销对应受管硬链接，不再做全目录清理。\n        auto remove_result = remove_managed_link(link_path);\n        if (!remove_result) {\n          add_error(result.errors, remove_result.error());\n          break;\n        }\n        if (remove_result.value()) {\n          result.removed_count++;\n        }\n        break;\n      }\n      default:\n        result.ignored_count++;\n        break;\n    }\n  }\n\n  return result;\n}\n\nauto initialize(\n    Core::State::AppState& app_state,\n    const std::function<void(const InfinityNikkiInitializeScreenshotHardlinksProgress&)>&\n        progress_callback)\n    -> std::expected<InfinityNikkiInitializeScreenshotHardlinksResult, std::string> {\n  return sync_hardlinks_internal(app_state, progress_callback);\n}\n\nauto sync(Core::State::AppState& app_state)\n    -> std::expected<InfinityNikkiInitializeScreenshotHardlinksResult, std::string> {\n  return sync_hardlinks_internal(app_state, nullptr);\n}\n\nauto resolve_watch_directory(Core::State::AppState& app_state)\n    -> std::expected<std::filesystem::path, std::string> {\n  auto paths_result = resolve_managed_paths(app_state);\n  if (!paths_result) {\n    return std::unexpected(paths_result.error());\n  }\n  return paths_result->gameplay_photos_dir;\n}\n\n}  // namespace Extensions::InfinityNikki::ScreenshotHardlinks\n"
  },
  {
    "path": "src/extensions/infinity_nikki/screenshot_hardlinks.ixx",
    "content": "module;\n\nexport module Extensions.InfinityNikki.ScreenshotHardlinks;\n\nimport std;\nimport Core.State;\nimport Extensions.InfinityNikki.Types;\nimport Features.Gallery.Types;\n\nnamespace Extensions::InfinityNikki::ScreenshotHardlinks {\n\nexport auto initialize(\n    Core::State::AppState& app_state,\n    const std::function<void(const InfinityNikkiInitializeScreenshotHardlinksProgress&)>&\n        progress_callback = nullptr)\n    -> std::expected<InfinityNikkiInitializeScreenshotHardlinksResult, std::string>;\n\nexport auto sync(Core::State::AppState& app_state)\n    -> std::expected<InfinityNikkiInitializeScreenshotHardlinksResult, std::string>;\n\n// 运行时增量同步入口：只根据 Gallery watcher 产出的变化集修正受影响的硬链接。\n// 与 initialize()/sync() 的全量重建不同，这里不再重新扫描整个 GamePlayPhotos。\nexport auto apply_runtime_changes(Core::State::AppState& app_state,\n                                  const std::vector<Features::Gallery::Types::ScanChange>& changes)\n    -> std::expected<InfinityNikkiInitializeScreenshotHardlinksResult, std::string>;\n\n// 返回 PhotoService 应监听的目录（GamePlayPhotos 目录），供 Gallery.Watcher 注册使用\nexport auto resolve_watch_directory(Core::State::AppState& app_state)\n    -> std::expected<std::filesystem::path, std::string>;\n\n}  // namespace Extensions::InfinityNikki::ScreenshotHardlinks\n"
  },
  {
    "path": "src/extensions/infinity_nikki/task_service.cpp",
    "content": "module;\n\n#include <asio.hpp>\n\n// TaskService 实现：把无限暖暖相关的重活挂到 Asio 协程上，并对接 Core::Tasks（前端「后台任务」）。\n// 约定：对外只暴露 start_* / schedule_silent_*；launch_* 为内部 co_spawn\n// 入口，不校验「是否已有同类任务」 （重复校验在各自的 start_* 里通过 has_active_task_of_type +\n// create_task 完成）。\nmodule Extensions.InfinityNikki.TaskService;\n\nimport std;\nimport Core.Async;\nimport Core.RPC.NotificationHub;\nimport Core.State;\nimport Core.Tasks;\nimport Features.Gallery;\nimport Features.Gallery.Types;\nimport Features.Settings;\nimport Features.Settings.State;\nimport Extensions.InfinityNikki.PhotoExtract;\nimport Extensions.InfinityNikki.ScreenshotHardlinks;\nimport Extensions.InfinityNikki.Types;\nimport Utils.Logger;\n\nnamespace Extensions::InfinityNikki::TaskService {\n\n// 与前端 AppHeader / taskStore 的 type 字段一致，勿随意改名。\nconstexpr auto kInitialScanTaskType = \"extensions.infinityNikki.initialScan\";\nconstexpr auto kExtractPhotoParamsTaskType = \"extensions.infinityNikki.extractPhotoParams\";\nconstexpr auto kInitializeScreenshotHardlinksTaskType =\n    \"extensions.infinityNikki.initializeScreenshotHardlinks\";\n\n// 硬链接初始化进度上报节流，避免 task.updated 过于频繁。\nconstexpr auto kProgressEmitInterval = std::chrono::milliseconds(250);\n// 任务失败摘要里附带的明细条数上限。\nconstexpr std::size_t kMaxTaskErrorDetails = 3;\n\n// --- 内部辅助（错误文案、进度映射）---\n\n// 手动按文件夹解析时，UID 必须为纯数字字符串。\nauto is_numeric_uid(std::string_view uid) -> bool {\n  return !uid.empty() &&\n         std::ranges::all_of(uid, [](unsigned char ch) { return std::isdigit(ch) != 0; });\n}\n\n// 图库 scan_directory 的进度结构 -> 任务系统统一字段。\nauto make_task_progress(const Features::Gallery::Types::ScanProgress& progress)\n    -> Core::Tasks::TaskProgress {\n  return Core::Tasks::TaskProgress{\n      .stage = progress.stage,\n      .current = progress.current,\n      .total = progress.total,\n      .percent = progress.percent,\n      .message = progress.message,\n  };\n}\n\n// 任务失败时 errorMessage 尾部追加若干条明细，避免单条消息过长。\nauto append_task_error_details(std::string& message, const std::vector<std::string>& errors)\n    -> void {\n  if (errors.empty()) {\n    return;\n  }\n\n  message += \" Details: \";\n\n  auto detail_count = std::min(errors.size(), kMaxTaskErrorDetails);\n  for (std::size_t i = 0; i < detail_count; ++i) {\n    if (i > 0) {\n      message += \" | \";\n    }\n    message += errors[i];\n  }\n\n  if (errors.size() > detail_count) {\n    message += std::format(\" | ... and {} more\", errors.size() - detail_count);\n  }\n}\n\nauto make_screenshot_hardlinks_task_error_message(\n    const Extensions::InfinityNikki::InfinityNikkiInitializeScreenshotHardlinksResult& summary)\n    -> std::string {\n  auto message = std::format(\n      \"Infinity Nikki screenshot hardlinks initialize failed: encountered {} error(s) while \"\n      \"processing {} source file(s)\",\n      summary.errors.size(), summary.source_count);\n  append_task_error_details(message, summary.errors);\n  return message;\n}\n\n// ---------------------------------------------------------------------------\n// launch_*：已由 start_* 创建好 task_id，此处只负责丢进 io_context 并驱动到完成/失败。\n\nauto launch_initial_scan_task(\n    Core::State::AppState& app_state, const Features::Gallery::Types::ScanOptions& options,\n    const std::string& task_id,\n    std::function<void(const Features::Gallery::Types::ScanResult&)> post_scan_callback) -> void {\n  if (!app_state.async) {\n    Core::Tasks::complete_task_failed(app_state, task_id, \"Async state is not initialized\");\n    return;\n  }\n\n  auto* io_context = Core::Async::get_io_context(*app_state.async);\n  if (!io_context) {\n    Core::Tasks::complete_task_failed(app_state, task_id, \"Async runtime is not available\");\n    return;\n  }\n\n  asio::co_spawn(\n      *io_context,\n      [&app_state, options, task_id,\n       post_scan_callback = std::move(post_scan_callback)]() -> asio::awaitable<void> {\n        co_await asio::post(asio::use_awaitable);\n\n        Core::Tasks::mark_task_running(app_state, task_id);\n\n        auto progress_callback =\n            [&app_state, &task_id](const Features::Gallery::Types::ScanProgress& progress) {\n              Core::Tasks::update_task_progress(app_state, task_id, make_task_progress(progress));\n            };\n\n        auto scan_result = Features::Gallery::scan_directory(app_state, options, progress_callback);\n        if (!scan_result) {\n          auto error_message = \"Infinity Nikki initial scan failed: \" + scan_result.error();\n          Logger().error(\"{}\", error_message);\n          Core::Tasks::complete_task_failed(app_state, task_id, error_message);\n          co_return;\n        }\n\n        const auto& result = scan_result.value();\n        Core::Tasks::update_task_progress(\n            app_state, task_id,\n            Core::Tasks::TaskProgress{\n                .stage = \"completed\",\n                .current = result.total_files,\n                .total = result.total_files,\n                .percent = 100.0,\n                .message =\n                    std::format(\"Scanned {}, new {}, updated {}, deleted {}\", result.total_files,\n                                result.new_items, result.updated_items, result.deleted_items),\n            });\n        Core::Tasks::complete_task_success(app_state, task_id);\n        Core::RPC::NotificationHub::send_notification(app_state, \"gallery.changed\");\n\n        if (post_scan_callback) {\n          post_scan_callback(result);\n        }\n      },\n      asio::detached);\n}\n\n// 走 PhotoExtract::extract_photo_params；有进度回调写任务；成功发\n// gallery.changed；部分失败整任务算失败。\nauto launch_extract_photo_params_task(\n    Core::State::AppState& app_state,\n    const Extensions::InfinityNikki::InfinityNikkiExtractPhotoParamsRequest& request,\n    const std::string& task_id) -> void {\n  if (!app_state.async) {\n    Core::Tasks::complete_task_failed(app_state, task_id, \"Async state is not initialized\");\n    return;\n  }\n\n  auto* io_context = Core::Async::get_io_context(*app_state.async);\n  if (!io_context) {\n    Core::Tasks::complete_task_failed(app_state, task_id, \"Async runtime is not available\");\n    return;\n  }\n\n  asio::co_spawn(\n      *io_context,\n      [&app_state, request, task_id]() -> asio::awaitable<void> {\n        co_await asio::post(asio::use_awaitable);\n        Core::Tasks::mark_task_running(app_state, task_id);\n\n        auto progress_callback =\n            [&app_state,\n             &task_id](const Extensions::InfinityNikki::InfinityNikkiExtractPhotoParamsProgress&\n                           progress) {\n              Core::Tasks::TaskProgress task_progress{\n                  .stage = progress.stage,\n                  .current = progress.current,\n                  .total = progress.total,\n                  .percent = progress.percent,\n                  .message = progress.message,\n              };\n              Core::Tasks::update_task_progress(app_state, task_id, task_progress);\n            };\n\n        auto extract_result =\n            co_await Extensions::InfinityNikki::PhotoExtract::extract_photo_params(\n                app_state, request, progress_callback);\n        if (!extract_result) {\n          auto error_message =\n              \"Infinity Nikki photo params extract failed: \" + extract_result.error();\n          Logger().error(\"{}\", error_message);\n          Core::Tasks::complete_task_failed(app_state, task_id, error_message);\n          co_return;\n        }\n\n        const auto& summary = extract_result.value();\n        Core::Tasks::update_task_progress(\n            app_state, task_id,\n            Core::Tasks::TaskProgress{\n                .stage = \"completed\",\n                .current = summary.processed_count,\n                .total = summary.candidate_count,\n                .percent = 100.0,\n                .message =\n                    std::format(\"Candidates {}, processed {}, saved {}, skipped {}, failed {}\",\n                                summary.candidate_count, summary.processed_count,\n                                summary.saved_count, summary.skipped_count, summary.failed_count),\n            });\n\n        if (summary.saved_count > 0) {\n          Core::RPC::NotificationHub::send_notification(app_state, \"gallery.changed\");\n        }\n\n        if (summary.failed_count > 0) {\n          auto warning_message = std::format(\n              \"Infinity Nikki photo params extract completed with warnings: failed {} / \"\n              \"processed {}\",\n              summary.failed_count, summary.processed_count);\n          append_task_error_details(warning_message, summary.errors);\n          Logger().warn(\"{}\", warning_message);\n        }\n\n        Core::Tasks::complete_task_success(app_state, task_id);\n      },\n      asio::detached);\n}\n\n// 与 launch_extract_photo_params_task 共用同一套解析逻辑，但不绑定 task_id、无进度条；仅日志 +\n// gallery.changed。\nauto schedule_silent_extract_photo_params(\n    Core::State::AppState& app_state,\n    Extensions::InfinityNikki::InfinityNikkiExtractPhotoParamsRequest request) -> void {\n  if (!app_state.async) {\n    Logger().warn(\n        \"Silent Infinity Nikki photo params extract skipped: async state is not initialized\");\n    return;\n  }\n\n  auto* io_context = Core::Async::get_io_context(*app_state.async);\n  if (!io_context) {\n    Logger().warn(\n        \"Silent Infinity Nikki photo params extract skipped: async runtime is not available\");\n    return;\n  }\n\n  asio::co_spawn(\n      *io_context,\n      [&app_state, request = std::move(request)]() -> asio::awaitable<void> {\n        co_await asio::post(asio::use_awaitable);\n\n        if (Core::Tasks::has_active_task_of_type(app_state, kExtractPhotoParamsTaskType)) {\n          Logger().debug(\n              \"Silent Infinity Nikki photo params extract skipped: user-initiated extract task is \"\n              \"active\");\n          co_return;\n        }\n\n        auto extract_result =\n            co_await Extensions::InfinityNikki::PhotoExtract::extract_photo_params(app_state,\n                                                                                   request, {});\n\n        if (!extract_result) {\n          Logger().error(\"Silent Infinity Nikki photo params extract failed: {}\",\n                         extract_result.error());\n          co_return;\n        }\n\n        const auto& summary = extract_result.value();\n        if (summary.failed_count > 0) {\n          auto warning_message = std::format(\n              \"Silent Infinity Nikki photo params extract completed with warnings: failed {} / \"\n              \"processed {}\",\n              summary.failed_count, summary.processed_count);\n          append_task_error_details(warning_message, summary.errors);\n          Logger().warn(\"{}\", warning_message);\n        } else {\n          Logger().info(\n              \"Silent Infinity Nikki photo params extract completed: candidates={}, \"\n              \"processed={}, saved={}, skipped={}, failed={}\",\n              summary.candidate_count, summary.processed_count, summary.saved_count,\n              summary.skipped_count, summary.failed_count);\n        }\n\n        if (summary.saved_count > 0) {\n          Core::RPC::NotificationHub::send_notification(app_state, \"gallery.changed\");\n        }\n\n        co_return;\n      },\n      asio::detached);\n}\n\n// 全量建立/校正游戏 ScreenShot 与图库侧的硬链接；完成后可能把设置里 manage_screenshot_hardlinks\n// 置为 true。\nauto launch_initialize_screenshot_hardlinks_task(Core::State::AppState& app_state,\n                                                 const std::string& task_id) -> void {\n  if (!app_state.async) {\n    Core::Tasks::complete_task_failed(app_state, task_id, \"Async state is not initialized\");\n    return;\n  }\n\n  auto* io_context = Core::Async::get_io_context(*app_state.async);\n  if (!io_context) {\n    Core::Tasks::complete_task_failed(app_state, task_id, \"Async runtime is not available\");\n    return;\n  }\n\n  asio::co_spawn(\n      *io_context,\n      [&app_state, task_id]() -> asio::awaitable<void> {\n        co_await asio::post(asio::use_awaitable);\n        Core::Tasks::mark_task_running(app_state, task_id);\n\n        auto last_emit_at = std::chrono::steady_clock::time_point{};\n        auto last_percent = -1;\n        auto progress_callback =\n            [&app_state, &task_id, &last_emit_at, &last_percent](\n                const Extensions::InfinityNikki::InfinityNikkiInitializeScreenshotHardlinksProgress&\n                    progress) {\n              auto percent = static_cast<int>(std::floor(progress.percent.value_or(0.0)));\n              auto now = std::chrono::steady_clock::now();\n              bool should_emit = false;\n              if (progress.stage == \"completed\") {\n                should_emit = true;\n              } else if (last_emit_at == std::chrono::steady_clock::time_point{}) {\n                should_emit = true;\n              } else if (percent > last_percent && now - last_emit_at >= kProgressEmitInterval) {\n                should_emit = true;\n              }\n\n              if (!should_emit) {\n                return;\n              }\n\n              last_emit_at = now;\n              last_percent = std::max(last_percent, percent);\n              Core::Tasks::TaskProgress task_progress{\n                  .stage = progress.stage,\n                  .current = progress.current,\n                  .total = progress.total,\n                  .percent = progress.percent,\n                  .message = progress.message,\n              };\n              Core::Tasks::update_task_progress(app_state, task_id, task_progress);\n            };\n\n        auto initialize_result = Extensions::InfinityNikki::ScreenshotHardlinks::initialize(\n            app_state, progress_callback);\n        if (!initialize_result) {\n          auto error_message =\n              \"Infinity Nikki screenshot hardlinks initialize failed: \" + initialize_result.error();\n          Logger().error(\"{}\", error_message);\n          Core::Tasks::complete_task_failed(app_state, task_id, error_message);\n          co_return;\n        }\n\n        const auto& summary = initialize_result.value();\n        Core::Tasks::update_task_progress(\n            app_state, task_id,\n            Core::Tasks::TaskProgress{\n                .stage = \"completed\",\n                .current = summary.source_count,\n                .total = summary.source_count,\n                .percent = 100.0,\n                .message =\n                    std::format(\"Source {}, created {}, updated {}, removed {}, ignored {}\",\n                                summary.source_count, summary.created_count, summary.updated_count,\n                                summary.removed_count, summary.ignored_count),\n            });\n\n        if (!summary.errors.empty()) {\n          auto error_message = make_screenshot_hardlinks_task_error_message(summary);\n          Logger().error(\"{}\", error_message);\n          Core::Tasks::complete_task_failed(app_state, task_id, error_message);\n          co_return;\n        }\n\n        if (app_state.settings &&\n            !app_state.settings->raw.extensions.infinity_nikki.manage_screenshot_hardlinks) {\n          auto next_settings = app_state.settings->raw;\n          next_settings.extensions.infinity_nikki.manage_screenshot_hardlinks = true;\n          if (auto save_result = Features::Settings::update_settings(app_state, next_settings);\n              !save_result) {\n            Logger().warn(\"Failed to persist Infinity Nikki screenshot hardlink setting: {}\",\n                          save_result.error());\n          }\n        }\n\n        Core::Tasks::complete_task_success(app_state, task_id);\n      },\n      asio::detached);\n}\n\n// ---------------------------------------------------------------------------\n// start_*：对外 API；先防重（同类任务已活跃则直接报错），再 create_task，最后 launch_*。\n\nauto start_initial_scan_task(\n    Core::State::AppState& app_state, const Features::Gallery::Types::ScanOptions& options,\n    std::function<void(const Features::Gallery::Types::ScanResult&)> post_scan_callback)\n    -> std::expected<std::string, std::string> {\n  if (Core::Tasks::has_active_task_of_type(app_state, kInitialScanTaskType)) {\n    return std::unexpected(\"Another Infinity Nikki initial scan task is already running\");\n  }\n\n  auto task_id = Core::Tasks::create_task(app_state, kInitialScanTaskType, options.directory);\n  if (task_id.empty()) {\n    return std::unexpected(\"Failed to create Infinity Nikki initial scan task\");\n  }\n\n  launch_initial_scan_task(app_state, options, task_id, std::move(post_scan_callback));\n  return task_id;\n}\n\n// 与 schedule_silent_extract_photo_params 互斥体现在：静默路径会查 has_active_task_of_type，\n// 此处则禁止两个「带任务的解析」并行；不阻止静默协程与任务解析在理论上重叠（接受偶尔重复解析）。\nauto start_extract_photo_params_task(\n    Core::State::AppState& app_state,\n    const Extensions::InfinityNikki::InfinityNikkiExtractPhotoParamsRequest& request)\n    -> std::expected<std::string, std::string> {\n  if (Core::Tasks::has_active_task_of_type(app_state, kExtractPhotoParamsTaskType)) {\n    return std::unexpected(\"Another Infinity Nikki extract task is already running\");\n  }\n\n  auto task_id = Core::Tasks::create_task(app_state, kExtractPhotoParamsTaskType);\n  if (task_id.empty()) {\n    return std::unexpected(\"Failed to create Infinity Nikki extract task\");\n  }\n\n  launch_extract_photo_params_task(app_state, request, task_id);\n  return task_id;\n}\n\n// RPC / 图库菜单「提取元数据」入口；业务参数收敛到 InfinityNikkiExtractPhotoParamsRequest。\nauto start_extract_photo_params_for_folder_task(\n    Core::State::AppState& app_state,\n    const Extensions::InfinityNikki::InfinityNikkiExtractPhotoParamsForFolderRequest& request)\n    -> std::expected<std::string, std::string> {\n  if (request.folder_id <= 0) {\n    return std::unexpected(\"Invalid folder id for manual Infinity Nikki extract\");\n  }\n\n  if (!is_numeric_uid(request.uid)) {\n    return std::unexpected(\"UID must be a non-empty numeric string\");\n  }\n\n  return start_extract_photo_params_task(\n      app_state, Extensions::InfinityNikki::InfinityNikkiExtractPhotoParamsRequest{\n                     .only_missing = request.only_missing,\n                     .folder_id = request.folder_id,\n                     .uid_override = request.uid,\n                 });\n}\n\nauto start_initialize_screenshot_hardlinks_task(Core::State::AppState& app_state)\n    -> std::expected<std::string, std::string> {\n  if (Core::Tasks::has_active_task_of_type(app_state, kInitializeScreenshotHardlinksTaskType)) {\n    return std::unexpected(\"Another Infinity Nikki screenshot hardlink task is already running\");\n  }\n\n  auto task_id = Core::Tasks::create_task(app_state, kInitializeScreenshotHardlinksTaskType);\n  if (task_id.empty()) {\n    return std::unexpected(\"Failed to create Infinity Nikki screenshot hardlink task\");\n  }\n\n  launch_initialize_screenshot_hardlinks_task(app_state, task_id);\n  return task_id;\n}\n\n}  // namespace Extensions::InfinityNikki::TaskService\n"
  },
  {
    "path": "src/extensions/infinity_nikki/task_service.ixx",
    "content": "module;\n\nexport module Extensions.InfinityNikki.TaskService;\n\nimport std;\nimport Core.State;\nimport Features.Gallery.Types;\nimport Extensions.InfinityNikki.Types;\n\nnamespace Extensions::InfinityNikki::TaskService {\n\n// 首次注册暖暖相册监听目录后的全量扫描；任务类型 initialScan。调用方：PhotoService 等。\nexport auto start_initial_scan_task(\n    Core::State::AppState& app_state, const Features::Gallery::Types::ScanOptions& options,\n    std::function<void(const Features::Gallery::Types::ScanResult&)> post_scan_callback = {})\n    -> std::expected<std::string, std::string>;\n\n// 用户显式 / RPC 触发的照片参数解析（会出现在后台任务列表）。同类任务同时只能有一个在\n// queued/running。 调用方：extensions RPC、内部再被「按文件夹解析」封装。\nexport auto start_extract_photo_params_task(\n    Core::State::AppState& app_state,\n    const Extensions::InfinityNikki::InfinityNikkiExtractPhotoParamsRequest& request)\n    -> std::expected<std::string, std::string>;\n\n// 图库扫描回调触发的自动解析：不 create_task、不发 task.updated，只打\n// Logger；行为上接近硬链接增量同步。\n// 若当前已有同类型的用户解析任务（extractPhotoParams）在跑，则跳过，避免与显式任务叠跑。\n// 调用方：PhotoService::on_gallery_scan_complete。\nexport auto schedule_silent_extract_photo_params(\n    Core::State::AppState& app_state,\n    Extensions::InfinityNikki::InfinityNikkiExtractPhotoParamsRequest request) -> void;\n\n// 图库 UI「按文件夹解析」：校验 folder_id、UID 为数字串后，转调 start_extract_photo_params_task。\nexport auto start_extract_photo_params_for_folder_task(\n    Core::State::AppState& app_state,\n    const Extensions::InfinityNikki::InfinityNikkiExtractPhotoParamsForFolderRequest& request)\n    -> std::expected<std::string, std::string>;\n\n// ScreenShot 目录硬链接初始化（引导 / 设置里的一次性重操作）。任务类型\n// initializeScreenshotHardlinks。\nexport auto start_initialize_screenshot_hardlinks_task(Core::State::AppState& app_state)\n    -> std::expected<std::string, std::string>;\n\n}  // namespace Extensions::InfinityNikki::TaskService\n"
  },
  {
    "path": "src/extensions/infinity_nikki/types.ixx",
    "content": "module;\n\nexport module Extensions.InfinityNikki.Types;\n\nimport std;\n\nexport namespace Extensions::InfinityNikki {\n\nstruct InfinityNikkiGameDirResult {\n  std::optional<std::string> game_dir;  // 游戏目录，null 表示未找到\n  bool config_found{false};             // 配置文件是否存在\n  bool game_dir_found{false};           // gameDir 字段是否存在\n  std::string message;                  // 状态描述信息\n};\n\nstruct InfinityNikkiExtractPhotoParamsRequest {\n  std::optional<bool> only_missing = true;\n  std::optional<std::int64_t> folder_id;\n  std::optional<std::string> uid_override;\n};\n\nstruct InfinityNikkiExtractPhotoParamsForFolderRequest {\n  std::int64_t folder_id = 0;\n  std::string uid;\n  std::optional<bool> only_missing = false;\n};\n\nstruct InfinityNikkiExtractPhotoParamsProgress {\n  std::string stage;\n  std::int64_t current = 0;\n  std::int64_t total = 0;\n  std::optional<double> percent;\n  std::optional<std::string> message;\n};\n\nstruct InfinityNikkiExtractPhotoParamsResult {\n  std::int32_t candidate_count = 0;\n  std::int32_t processed_count = 0;\n  std::int32_t saved_count = 0;\n  std::int32_t skipped_count = 0;\n  std::int32_t failed_count = 0;\n  std::int32_t clothes_rows_written = 0;\n  std::vector<std::string> errors = {};\n};\n\nstruct InfinityNikkiInitializeScreenshotHardlinksProgress {\n  std::string stage;\n  std::int64_t current = 0;\n  std::int64_t total = 0;\n  std::optional<double> percent;\n  std::optional<std::string> message;\n};\n\nstruct InfinityNikkiInitializeScreenshotHardlinksResult {\n  std::int32_t source_count = 0;\n  std::int32_t created_count = 0;\n  std::int32_t updated_count = 0;\n  std::int32_t removed_count = 0;\n  std::int32_t ignored_count = 0;\n  std::vector<std::string> errors = {};\n};\n\n}  // namespace Extensions::InfinityNikki\n"
  },
  {
    "path": "src/features/gallery/asset/infinity_nikki_metadata_dict.cpp",
    "content": "module;\n\nmodule Features.Gallery.Asset.InfinityNikkiMetadataDict;\n\nimport std;\nimport Core.State;\nimport Core.HttpClient;\nimport Core.HttpClient.Types;\nimport Features.Gallery.Types;\nimport Utils.Logger;\nimport <asio.hpp>;\nimport <rfl/json.hpp>;\n\nnamespace Features::Gallery::Asset::InfinityNikkiMetadataDict {\n\n// 远端在线字典地址：不随客户端打包发布。\nconstexpr std::string_view kDictionaryUrl =\n    \"https://api.infinitymomo.com/api/v1/camera-spinning-momo.json\";\n// 字典在内存中的有效期，过期后会尝试后台刷新。\nconstexpr auto kDictionaryTtl = std::chrono::hours(6);\n\nstruct MetadataItem {\n  std::optional<std::string> zh;\n  std::optional<std::string> en;\n};\n\nstruct MetadataPayload {\n  // 与远端 JSON 顶层键保持一致（poses / filters / lights）\n  std::optional<std::unordered_map<std::string, MetadataItem>> poses;\n  std::optional<std::unordered_map<std::string, MetadataItem>> filters;\n  std::optional<std::unordered_map<std::string, MetadataItem>> lights;\n};\n\nstruct MetadataDictionary {\n  std::unordered_map<std::string, MetadataItem> poses;\n  std::unordered_map<std::string, MetadataItem> filters;\n  std::unordered_map<std::string, MetadataItem> lights;\n};\n\nstruct MetadataDictionaryCache {\n  std::mutex mutex;\n  std::optional<MetadataDictionary> cached_dictionary;\n  std::chrono::steady_clock::time_point last_updated = std::chrono::steady_clock::time_point::min();\n};\n\nauto dictionary_cache() -> MetadataDictionaryCache& {\n  // 进程级单例缓存：整个应用共享一份在线字典。\n  static MetadataDictionaryCache cache;\n  return cache;\n}\n\nauto is_cached_dictionary_fresh(const MetadataDictionaryCache& cache) -> bool {\n  if (!cache.cached_dictionary.has_value()) {\n    return false;\n  }\n  return (std::chrono::steady_clock::now() - cache.last_updated) < kDictionaryTtl;\n}\n\nauto resolve_name(const std::unordered_map<std::string, MetadataItem>& dictionary,\n                  const std::optional<std::int64_t>& id, std::string_view locale_key)\n    -> std::optional<std::string> {\n  if (!id.has_value()) {\n    return std::nullopt;\n  }\n\n  auto it = dictionary.find(std::to_string(*id));\n  if (it == dictionary.end()) {\n    return std::nullopt;\n  }\n\n  const auto& item = it->second;\n  // 优先返回当前语言；若缺失则回退到另一语言，尽量避免空展示。\n  if (locale_key == \"zh\") {\n    if (item.zh.has_value() && !item.zh->empty()) return item.zh;\n    if (item.en.has_value() && !item.en->empty()) return item.en;\n    return std::nullopt;\n  }\n\n  if (item.en.has_value() && !item.en->empty()) return item.en;\n  if (item.zh.has_value() && !item.zh->empty()) return item.zh;\n  return std::nullopt;\n}\n\nauto to_locale_key(std::optional<std::string> locale) -> std::string_view {\n  // 后端只需要区分中/英文两档，简化映射逻辑。\n  if (locale.has_value() && locale->rfind(\"zh\", 0) == 0) {\n    return \"zh\";\n  }\n  return \"en\";\n}\n\nauto parse_dictionary_payload(const std::string& body)\n    -> std::expected<MetadataDictionary, std::string> {\n  auto parsed = rfl::json::read<MetadataPayload, rfl::DefaultIfMissing>(body);\n  if (!parsed) {\n    return std::unexpected(\"Failed to parse camera metadata dictionary: \" + parsed.error().what());\n  }\n\n  MetadataDictionary dictionary;\n  // 使用空 map 兜底，保证下游 lookup 总是可执行。\n  dictionary.poses =\n      std::move(parsed->poses.value_or(std::unordered_map<std::string, MetadataItem>{}));\n  dictionary.filters =\n      std::move(parsed->filters.value_or(std::unordered_map<std::string, MetadataItem>{}));\n  dictionary.lights =\n      std::move(parsed->lights.value_or(std::unordered_map<std::string, MetadataItem>{}));\n  return dictionary;\n}\n\nauto fetch_and_parse_dictionary(Core::State::AppState& app_state)\n    -> asio::awaitable<std::expected<MetadataDictionary, std::string>> {\n  // 只读取公开 JSON，不带业务副作用。\n  Core::HttpClient::Types::Request request{\n      .method = \"GET\",\n      .url = std::string(kDictionaryUrl),\n      .headers = {Core::HttpClient::Types::Header{.name = \"Accept\", .value = \"application/json\"}},\n  };\n\n  auto response = co_await Core::HttpClient::fetch(app_state, request);\n  if (!response) {\n    co_return std::unexpected(\"Failed to fetch camera metadata dictionary: \" + response.error());\n  }\n\n  if (response->status_code < 200 || response->status_code >= 300) {\n    // 非 2xx 统一视为失败，交给上层做缓存回退。\n    co_return std::unexpected(\"Camera metadata dictionary returned non-2xx response: \" +\n                              std::to_string(response->status_code));\n  }\n\n  co_return parse_dictionary_payload(response->body);\n}\n\nauto load_dictionary(Core::State::AppState& app_state)\n    -> asio::awaitable<std::expected<MetadataDictionary, std::string>> {\n  auto& cache = dictionary_cache();\n\n  // 热路径：优先返回新鲜缓存，避免在详情面板频繁切换时反复请求远端字典。\n  {\n    std::lock_guard<std::mutex> lock(cache.mutex);\n    if (is_cached_dictionary_fresh(cache)) {\n      co_return *cache.cached_dictionary;\n    }\n  }\n\n  auto fetched = co_await fetch_and_parse_dictionary(app_state);\n  if (!fetched) {\n    std::lock_guard<std::mutex> lock(cache.mutex);\n    // 网络失败时优先回退旧缓存，保证 UI 至少能继续显示上一次可用映射。\n    if (cache.cached_dictionary.has_value()) {\n      Logger().warn(\"Use stale Infinity Nikki metadata dictionary because refresh failed: {}\",\n                    fetched.error());\n      co_return *cache.cached_dictionary;\n    }\n    co_return std::unexpected(fetched.error());\n  }\n\n  {\n    std::lock_guard<std::mutex> lock(cache.mutex);\n    // 刷新成功后原子替换缓存并更新时间戳。\n    cache.cached_dictionary = fetched.value();\n    cache.last_updated = std::chrono::steady_clock::now();\n    co_return *cache.cached_dictionary;\n  }\n}\n\nauto resolve_metadata_names(Core::State::AppState& app_state,\n                            const Types::GetInfinityNikkiMetadataNamesParams& params)\n    -> asio::awaitable<std::expected<Types::InfinityNikkiMetadataNames, std::string>> {\n  // 统一入口：先拿字典，再按传入 id + locale 做“最小响应”映射。\n  auto dictionary = co_await load_dictionary(app_state);\n  if (!dictionary) {\n    co_return std::unexpected(dictionary.error());\n  }\n\n  auto locale_key = to_locale_key(params.locale);\n\n  co_return Types::InfinityNikkiMetadataNames{\n      .filter_name = resolve_name(dictionary->filters, params.filter_id, locale_key),\n      .pose_name = resolve_name(dictionary->poses, params.pose_id, locale_key),\n      .light_name = resolve_name(dictionary->lights, params.light_id, locale_key),\n  };\n}\n\n}  // namespace Features::Gallery::Asset::InfinityNikkiMetadataDict\n"
  },
  {
    "path": "src/features/gallery/asset/infinity_nikki_metadata_dict.ixx",
    "content": "module;\n\n#include <asio.hpp>\n\nexport module Features.Gallery.Asset.InfinityNikkiMetadataDict;\n\nimport std;\nimport Core.State;\nimport Features.Gallery.Types;\n\nnamespace Features::Gallery::Asset::InfinityNikkiMetadataDict {\n\nexport auto resolve_metadata_names(Core::State::AppState& app_state,\n                                   const Types::GetInfinityNikkiMetadataNamesParams& params)\n    -> asio::awaitable<std::expected<Types::InfinityNikkiMetadataNames, std::string>>;\n\n}  // namespace Features::Gallery::Asset::InfinityNikkiMetadataDict\n"
  },
  {
    "path": "src/features/gallery/asset/repository.cpp",
    "content": "module;\n\nmodule Features.Gallery.Asset.Repository;\n\nimport std;\nimport Core.State;\nimport Core.Database;\nimport Core.Database.State;\nimport Core.Database.Types;\nimport Features.Gallery.Types;\nimport Features.Gallery.State;\nimport Utils.Logger;\nimport Utils.Time;\nimport Utils.LRUCache;\nimport <rfl.hpp>;\n\nnamespace Features::Gallery::Asset::Repository::Detail {\n\nauto escape_like_pattern(const std::string& input) -> std::string {\n  // SQLite 的 LIKE 会把 % 和 _ 当成通配符。\n  // 这里把路径里的特殊字符转义掉，确保查询按“真实路径文本”匹配，\n  // 而不是把目录名误当成模糊匹配规则。\n  std::string escaped;\n  escaped.reserve(input.size());\n  for (char ch : input) {\n    if (ch == '\\\\' || ch == '%' || ch == '_') {\n      escaped.push_back('\\\\');\n    }\n    escaped.push_back(ch);\n  }\n  return escaped;\n}\n\n}  // namespace Features::Gallery::Asset::Repository::Detail\n\nnamespace Features::Gallery::Asset::Repository {\n\nauto create_asset(Core::State::AppState& app_state, const Types::Asset& item)\n    -> std::expected<int64_t, std::string> {\n  std::string sql = R\"(\n            INSERT INTO assets (\n                name, path, type,\n                description, width, height, size, extension, mime_type, hash, folder_id,\n                file_created_at, file_modified_at\n            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n        )\";\n\n  std::vector<Core::Database::Types::DbParam> params;\n  params.push_back(item.name);\n  params.push_back(item.path);\n  params.push_back(item.type);\n\n  params.push_back(item.description.has_value()\n                       ? Core::Database::Types::DbParam{item.description.value()}\n                       : Core::Database::Types::DbParam{std::monostate{}});\n\n  params.push_back(item.width.has_value()\n                       ? Core::Database::Types::DbParam{static_cast<int64_t>(item.width.value())}\n                       : Core::Database::Types::DbParam{std::monostate{}});\n  params.push_back(item.height.has_value()\n                       ? Core::Database::Types::DbParam{static_cast<int64_t>(item.height.value())}\n                       : Core::Database::Types::DbParam{std::monostate{}});\n  params.push_back(item.size.has_value() ? Core::Database::Types::DbParam{item.size.value()}\n                                         : Core::Database::Types::DbParam{std::monostate{}});\n\n  params.push_back(item.extension.has_value()\n                       ? Core::Database::Types::DbParam{item.extension.value()}\n                       : Core::Database::Types::DbParam{std::monostate{}});\n\n  params.push_back(item.mime_type);\n\n  params.push_back(item.hash.has_value() ? Core::Database::Types::DbParam{item.hash.value()}\n                                         : Core::Database::Types::DbParam{std::monostate{}});\n\n  params.push_back(item.folder_id.has_value()\n                       ? Core::Database::Types::DbParam{item.folder_id.value()}\n                       : Core::Database::Types::DbParam{std::monostate{}});\n\n  params.push_back(item.file_created_at.has_value()\n                       ? Core::Database::Types::DbParam{item.file_created_at.value()}\n                       : Core::Database::Types::DbParam{std::monostate{}});\n  params.push_back(item.file_modified_at.has_value()\n                       ? Core::Database::Types::DbParam{item.file_modified_at.value()}\n                       : Core::Database::Types::DbParam{std::monostate{}});\n\n  auto result = Core::Database::execute(*app_state.database, sql, params);\n  if (!result) {\n    return std::unexpected(\"Failed to insert asset item: \" + result.error());\n  }\n\n  // 获取插入的 ID\n  auto id_result =\n      Core::Database::query_scalar<int64_t>(*app_state.database, \"SELECT last_insert_rowid()\");\n  if (!id_result) {\n    return std::unexpected(\"Failed to get inserted ID: \" + id_result.error());\n  }\n\n  return id_result->value_or(0);\n}\n\nauto get_asset_by_id(Core::State::AppState& app_state, int64_t id)\n    -> std::expected<std::optional<Types::Asset>, std::string> {\n  std::string sql = R\"(\n            SELECT id, name, path, type,\n                   NULL AS dominant_color_hex,\n                   rating, review_flag,\n                   description, width, height, size, extension, mime_type, hash,\n                   NULL AS root_id, NULL AS relative_path, folder_id,\n                   file_created_at, file_modified_at,\n                   created_at, updated_at\n            FROM assets\n            WHERE id = ?\n        )\";\n\n  std::vector<Core::Database::Types::DbParam> params = {id};\n\n  auto result = Core::Database::query_single<Types::Asset>(*app_state.database, sql, params);\n\n  if (!result) {\n    return std::unexpected(\"Failed to get asset by id: \" + result.error());\n  }\n\n  return result.value();\n}\n\nauto get_asset_by_path(Core::State::AppState& app_state, const std::string& path)\n    -> std::expected<std::optional<Types::Asset>, std::string> {\n  std::string sql = R\"(\n            SELECT id, name, path, type,\n                   NULL AS dominant_color_hex,\n                   rating, review_flag,\n                   description, width, height, size, extension, mime_type, hash,\n                   NULL AS root_id, NULL AS relative_path, folder_id,\n                   file_created_at, file_modified_at,\n                   created_at, updated_at\n            FROM assets\n            WHERE path = ?\n        )\";\n\n  std::vector<Core::Database::Types::DbParam> params = {path};\n\n  auto result = Core::Database::query_single<Types::Asset>(*app_state.database, sql, params);\n  if (!result) {\n    return std::unexpected(\"Failed to query asset item by path: \" + result.error());\n  }\n\n  return result.value();\n}\n\nauto has_assets_under_path_prefix(Core::State::AppState& app_state, const std::string& path_prefix)\n    -> std::expected<bool, std::string> {\n  // 这里不是查“这个目录本身是否有一条 folder 记录”，\n  // 而是查 assets 表里是否已经存在任何文件路径落在该目录下面。\n  // 例如 path_prefix = C:/A/B 时，我们要匹配的是 C:/A/B/xxx.jpg。\n  auto normalized_prefix = path_prefix;\n  if (!normalized_prefix.empty() && normalized_prefix.ends_with('/')) {\n    normalized_prefix.pop_back();\n  }\n\n  auto escaped_prefix = Detail::escape_like_pattern(normalized_prefix);\n  std::string sql = R\"(\n            SELECT EXISTS(\n                SELECT 1\n                FROM assets\n                WHERE path LIKE ? ESCAPE '\\'\n            )\n        )\";\n\n  // 这里拼成 \"prefix/%\"，只匹配“这个目录的子内容”，\n  // 不会把名称相似但不在该目录下的路径误算进去。\n  std::vector<Core::Database::Types::DbParam> params = {escaped_prefix + \"/%\"};\n\n  auto result = Core::Database::query_scalar<std::int64_t>(*app_state.database, sql, params);\n  if (!result) {\n    return std::unexpected(\"Failed to query assets by path prefix: \" + result.error());\n  }\n\n  return result->value_or(0) != 0;\n}\n\nauto update_asset(Core::State::AppState& app_state, const Types::Asset& item)\n    -> std::expected<void, std::string> {\n  std::string sql = R\"(\n            UPDATE assets SET\n                name = ?, path = ?, type = ?,\n                description = ?, width = ?, height = ?, size = ?, extension = ?, mime_type = ?, hash = ?, folder_id = ?,\n                file_created_at = ?, file_modified_at = ?\n            WHERE id = ?\n        )\";\n\n  std::vector<Core::Database::Types::DbParam> params;\n  params.push_back(item.name);\n  params.push_back(item.path);\n  params.push_back(item.type);\n\n  params.push_back(item.description.has_value()\n                       ? Core::Database::Types::DbParam{item.description.value()}\n                       : Core::Database::Types::DbParam{std::monostate{}});\n\n  params.push_back(item.width.has_value()\n                       ? Core::Database::Types::DbParam{static_cast<int64_t>(item.width.value())}\n                       : Core::Database::Types::DbParam{std::monostate{}});\n  params.push_back(item.height.has_value()\n                       ? Core::Database::Types::DbParam{static_cast<int64_t>(item.height.value())}\n                       : Core::Database::Types::DbParam{std::monostate{}});\n  params.push_back(item.size.has_value() ? Core::Database::Types::DbParam{item.size.value()}\n                                         : Core::Database::Types::DbParam{std::monostate{}});\n\n  params.push_back(item.extension.has_value()\n                       ? Core::Database::Types::DbParam{item.extension.value()}\n                       : Core::Database::Types::DbParam{std::monostate{}});\n\n  params.push_back(item.mime_type);\n\n  params.push_back(item.hash.has_value() ? Core::Database::Types::DbParam{item.hash.value()}\n                                         : Core::Database::Types::DbParam{std::monostate{}});\n\n  params.push_back(item.folder_id.has_value()\n                       ? Core::Database::Types::DbParam{item.folder_id.value()}\n                       : Core::Database::Types::DbParam{std::monostate{}});\n\n  params.push_back(item.file_created_at.has_value()\n                       ? Core::Database::Types::DbParam{item.file_created_at.value()}\n                       : Core::Database::Types::DbParam{std::monostate{}});\n  params.push_back(item.file_modified_at.has_value()\n                       ? Core::Database::Types::DbParam{item.file_modified_at.value()}\n                       : Core::Database::Types::DbParam{std::monostate{}});\n\n  params.push_back(item.id);\n\n  auto result = Core::Database::execute(*app_state.database, sql, params);\n  if (!result) {\n    return std::unexpected(\"Failed to update asset item: \" + result.error());\n  }\n\n  return {};\n}\n\nauto delete_asset(Core::State::AppState& app_state, int64_t id)\n    -> std::expected<void, std::string> {\n  std::string sql = \"DELETE FROM assets WHERE id = ?\";\n  std::vector<Core::Database::Types::DbParam> params = {id};\n\n  auto result = Core::Database::execute(*app_state.database, sql, params);\n  if (!result) {\n    return std::unexpected(\"Failed to delete asset item: \" + result.error());\n  }\n\n  return {};\n}\n\nauto batch_delete_assets_by_ids(Core::State::AppState& app_state,\n                                const std::vector<std::int64_t>& ids)\n    -> std::expected<void, std::string> {\n  if (ids.empty()) {\n    return {};\n  }\n\n  std::unordered_set<std::int64_t> unique_ids(ids.begin(), ids.end());\n  return Core::Database::execute_transaction(\n      *app_state.database,\n      [&unique_ids](\n          Core::Database::State::DatabaseState& db_state) -> std::expected<void, std::string> {\n        constexpr std::string_view sql = \"DELETE FROM assets WHERE id = ?\";\n        for (auto id : unique_ids) {\n          auto result = Core::Database::execute(db_state, std::string(sql), {id});\n          if (!result) {\n            return std::unexpected(\"Failed to delete asset item (id=\" + std::to_string(id) +\n                                   \"): \" + result.error());\n          }\n        }\n\n        return {};\n      });\n}\n\nauto batch_create_asset(Core::State::AppState& app_state, const std::vector<Types::Asset>& items)\n    -> std::expected<std::vector<std::int64_t>, std::string> {\n  if (items.empty()) {\n    return std::vector<int64_t>{};\n  }\n\n  std::string insert_prefix = R\"(\n    INSERT INTO assets (\n      name, path, type,\n      description, width, height, size, extension, mime_type, hash, folder_id,\n      file_created_at, file_modified_at\n    ) VALUES \n  )\";\n\n  std::string values_placeholder = \"(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\";\n\n  // 参数提取器，将Asset对象转换为参数列表\n  auto param_extractor =\n      [](const Types::Asset& item) -> std::vector<Core::Database::Types::DbParam> {\n    std::vector<Core::Database::Types::DbParam> params;\n    params.reserve(13);  // 13个字段\n\n    params.push_back(item.name);\n    params.push_back(item.path);\n    params.push_back(item.type);\n\n    params.push_back(item.description.has_value()\n                         ? Core::Database::Types::DbParam{item.description.value()}\n                         : Core::Database::Types::DbParam{std::monostate{}});\n    params.push_back(item.width.has_value()\n                         ? Core::Database::Types::DbParam{static_cast<int64_t>(item.width.value())}\n                         : Core::Database::Types::DbParam{std::monostate{}});\n    params.push_back(item.height.has_value()\n                         ? Core::Database::Types::DbParam{static_cast<int64_t>(item.height.value())}\n                         : Core::Database::Types::DbParam{std::monostate{}});\n    params.push_back(item.size.has_value() ? Core::Database::Types::DbParam{item.size.value()}\n                                           : Core::Database::Types::DbParam{std::monostate{}});\n\n    params.push_back(item.extension.has_value()\n                         ? Core::Database::Types::DbParam{item.extension.value()}\n                         : Core::Database::Types::DbParam{std::monostate{}});\n\n    params.push_back(item.mime_type);\n\n    params.push_back(item.hash.has_value() ? Core::Database::Types::DbParam{item.hash.value()}\n                                           : Core::Database::Types::DbParam{std::monostate{}});\n\n    params.push_back(item.folder_id.has_value()\n                         ? Core::Database::Types::DbParam{item.folder_id.value()}\n                         : Core::Database::Types::DbParam{std::monostate{}});\n\n    params.push_back(item.file_created_at.has_value()\n                         ? Core::Database::Types::DbParam{item.file_created_at.value()}\n                         : Core::Database::Types::DbParam{std::monostate{}});\n    params.push_back(item.file_modified_at.has_value()\n                         ? Core::Database::Types::DbParam{item.file_modified_at.value()}\n                         : Core::Database::Types::DbParam{std::monostate{}});\n\n    return params;\n  };\n\n  // 使用批量插入接口，自动处理分批\n  return Core::Database::execute_batch_insert(*app_state.database, insert_prefix,\n                                              values_placeholder, items, param_extractor);\n}\n\nauto batch_update_asset(Core::State::AppState& app_state, const std::vector<Types::Asset>& items)\n    -> std::expected<void, std::string> {\n  if (items.empty()) {\n    return {};\n  }\n\n  // 使用事务批量执行update（SQLite不支持bulk update）\n  return Core::Database::execute_transaction(\n      *app_state.database,\n      [&](Core::Database::State::DatabaseState& db_state) -> std::expected<void, std::string> {\n        std::string sql = R\"(\n          UPDATE assets SET\n            name = ?, path = ?, type = ?,\n            description = ?, width = ?, height = ?, size = ?, extension = ?, mime_type = ?, hash = ?, folder_id = ?,\n            file_created_at = ?, file_modified_at = ?\n          WHERE id = ?\n        )\";\n\n        // 提取参数的lambda，避免在循环中重复代码\n        auto extract_params = [](const Types::Asset& item) {\n          std::vector<Core::Database::Types::DbParam> params;\n          params.reserve(14);  // 13个更新字段 + 1个WHERE条件\n\n          params.push_back(item.name);\n          params.push_back(item.path);\n          params.push_back(item.type);\n\n          params.push_back(item.description.has_value()\n                               ? Core::Database::Types::DbParam{item.description.value()}\n                               : Core::Database::Types::DbParam{std::monostate{}});\n\n          params.push_back(\n              item.width.has_value()\n                  ? Core::Database::Types::DbParam{static_cast<int64_t>(item.width.value())}\n                  : Core::Database::Types::DbParam{std::monostate{}});\n          params.push_back(\n              item.height.has_value()\n                  ? Core::Database::Types::DbParam{static_cast<int64_t>(item.height.value())}\n                  : Core::Database::Types::DbParam{std::monostate{}});\n          params.push_back(item.size.has_value()\n                               ? Core::Database::Types::DbParam{item.size.value()}\n                               : Core::Database::Types::DbParam{std::monostate{}});\n\n          params.push_back(item.extension.has_value()\n                               ? Core::Database::Types::DbParam{item.extension.value()}\n                               : Core::Database::Types::DbParam{std::monostate{}});\n\n          params.push_back(item.mime_type);\n\n          params.push_back(item.hash.has_value()\n                               ? Core::Database::Types::DbParam{item.hash.value()}\n                               : Core::Database::Types::DbParam{std::monostate{}});\n\n          params.push_back(item.folder_id.has_value()\n                               ? Core::Database::Types::DbParam{item.folder_id.value()}\n                               : Core::Database::Types::DbParam{std::monostate{}});\n\n          params.push_back(item.file_created_at.has_value()\n                               ? Core::Database::Types::DbParam{item.file_created_at.value()}\n                               : Core::Database::Types::DbParam{std::monostate{}});\n          params.push_back(item.file_modified_at.has_value()\n                               ? Core::Database::Types::DbParam{item.file_modified_at.value()}\n                               : Core::Database::Types::DbParam{std::monostate{}});\n\n          params.push_back(item.id);  // WHERE id = ?\n\n          return params;\n        };\n\n        // 执行批量更新\n        for (const auto& item : items) {\n          auto params = extract_params(item);\n          auto result = Core::Database::execute(db_state, sql, params);\n          if (!result) {\n            return std::unexpected(\"Failed to update asset item (id=\" + std::to_string(item.id) +\n                                   \"): \" + result.error());\n          }\n        }\n\n        return {};\n      });\n}\n\n}  // namespace Features::Gallery::Asset::Repository\n"
  },
  {
    "path": "src/features/gallery/asset/repository.ixx",
    "content": "module;\n\nexport module Features.Gallery.Asset.Repository;\n\nimport std;\nimport Core.State;\nimport Core.Database.Types;\nimport Features.Gallery.Types;\n\nnamespace Features::Gallery::Asset::Repository {\n\n// 基本操作\nexport auto create_asset(Core::State::AppState& app_state, const Types::Asset& item)\n    -> std::expected<std::int64_t, std::string>;\n\nexport auto get_asset_by_id(Core::State::AppState& app_state, std::int64_t id)\n    -> std::expected<std::optional<Types::Asset>, std::string>;\n\nexport auto get_asset_by_path(Core::State::AppState& app_state, const std::string& path)\n    -> std::expected<std::optional<Types::Asset>, std::string>;\n\nexport auto has_assets_under_path_prefix(Core::State::AppState& app_state,\n                                         const std::string& path_prefix)\n    -> std::expected<bool, std::string>;\n\nexport auto update_asset(Core::State::AppState& app_state, const Types::Asset& item)\n    -> std::expected<void, std::string>;\n\nexport auto delete_asset(Core::State::AppState& app_state, std::int64_t id)\n    -> std::expected<void, std::string>;\nexport auto batch_delete_assets_by_ids(Core::State::AppState& app_state,\n                                       const std::vector<std::int64_t>& ids)\n    -> std::expected<void, std::string>;\n\n// 批量操作\nexport auto batch_create_asset(Core::State::AppState& app_state,\n                               const std::vector<Types::Asset>& items)\n    -> std::expected<std::vector<std::int64_t>, std::string>;\n\nexport auto batch_update_asset(Core::State::AppState& app_state,\n                               const std::vector<Types::Asset>& items)\n    -> std::expected<void, std::string>;\n\n}  // namespace Features::Gallery::Asset::Repository\n"
  },
  {
    "path": "src/features/gallery/asset/service.cpp",
    "content": "module;\n\nmodule Features.Gallery.Asset.Service;\n\nimport std;\nimport Core.State;\nimport Core.Database;\nimport Core.Database.Types;\nimport Features.Gallery.OriginalLocator;\nimport Features.Gallery.Types;\nimport Features.Gallery.Asset.Repository;\nimport Features.Gallery.Asset.InfinityNikkiMetadataDict;\nimport Features.Gallery.Color.Filter;\nimport Features.Gallery.Color.Repository;\nimport Utils.Logger;\nimport <asio.hpp>;\n\nnamespace Features::Gallery::Asset::Service {\n\n// ============= 内部辅助函数 =============\n\n// 验证月份格式（YYYY-MM）\nauto validate_month_format(const std::string& month) -> bool {\n  if (month.length() != 7 || month[4] != '-') {\n    return false;\n  }\n\n  // 简单验证：前4位和后2位是否为数字\n  for (size_t i = 0; i < month.length(); ++i) {\n    if (i == 4) continue;  // 跳过 '-'\n    if (!std::isdigit(static_cast<unsigned char>(month[i]))) {\n      return false;\n    }\n  }\n\n  // 验证月份范围 01-12\n  int month_num = std::stoi(month.substr(5, 2));\n  return month_num >= 1 && month_num <= 12;\n}\n\n// 将时间戳转换为月份字符串\nauto timestamp_to_month(std::int64_t timestamp_ms) -> std::string {\n  // 将毫秒时间戳转换为 system_clock::time_point\n  auto time_point = std::chrono::system_clock::time_point{std::chrono::milliseconds{timestamp_ms}};\n\n  // 转换为 year_month_day\n  auto ymd = std::chrono::year_month_day{std::chrono::floor<std::chrono::days>(time_point)};\n\n  // 格式化为 YYYY-MM\n  return std::format(\"{:%Y-%m}\", ymd);\n}\n\nauto build_in_clause_placeholders(std::size_t count) -> std::string {\n  if (count == 0) {\n    return \"\";\n  }\n\n  std::string placeholders;\n  placeholders.reserve(count * 2 - 1);\n  for (std::size_t i = 0; i < count; ++i) {\n    if (i > 0) {\n      placeholders += \",\";\n    }\n    placeholders += \"?\";\n  }\n\n  return placeholders;\n}\n\nauto qualify_asset_column(std::string_view column, std::string_view asset_table_alias)\n    -> std::string {\n  if (asset_table_alias.empty()) {\n    return std::string(column);\n  }\n\n  return std::string(asset_table_alias) + \".\" + std::string(column);\n}\n\nauto is_valid_review_flag(const std::string& review_flag) -> bool {\n  return review_flag == \"none\" || review_flag == \"picked\" || review_flag == \"rejected\";\n}\n\nauto trim_ascii_copy(std::string_view value) -> std::string {\n  auto is_space = [](unsigned char ch) { return std::isspace(ch) != 0; };\n\n  std::size_t start = 0;\n  while (start < value.size() && is_space(static_cast<unsigned char>(value[start]))) {\n    ++start;\n  }\n\n  std::size_t end = value.size();\n  while (end > start && is_space(static_cast<unsigned char>(value[end - 1]))) {\n    --end;\n  }\n\n  return std::string(value.substr(start, end - start));\n}\n\nauto build_unified_where_clause(const Types::QueryAssetsFilters& filters,\n                                std::string_view asset_table_alias = \"\")\n    -> std::expected<std::pair<std::string, std::vector<Core::Database::Types::DbParam>>,\n                     std::string>;\n\nstruct QueryOrderConfig {\n  std::string sort_by;\n  std::string sort_order;\n  std::string asset_order_clause;\n  // 给 ROW_NUMBER() 用的排序子句；需要基于中间列名而不是 assets 原始表达式。\n  std::string indexed_order_clause;\n};\n\nauto build_query_order_config(std::optional<std::string> sort_by_param,\n                              std::optional<std::string> sort_order_param) -> QueryOrderConfig {\n  QueryOrderConfig config{\n      .sort_by = sort_by_param.value_or(\"created_at\"),\n      .sort_order = sort_order_param.value_or(\"desc\"),\n  };\n\n  if (config.sort_by != \"created_at\" && config.sort_by != \"name\" &&\n      config.sort_by != \"resolution\" && config.sort_by != \"size\" &&\n      config.sort_by != \"file_created_at\") {\n    config.sort_by = \"created_at\";\n  }\n  if (config.sort_order != \"asc\" && config.sort_order != \"desc\") {\n    config.sort_order = \"desc\";\n  }\n\n  if (config.sort_by == \"created_at\") {\n    config.asset_order_clause =\n        std::format(\"ORDER BY COALESCE(file_created_at, created_at) {}, id {}\", config.sort_order,\n                    config.sort_order);\n    config.indexed_order_clause =\n        std::format(\"ORDER BY sort_created_at {}, id {}\", config.sort_order, config.sort_order);\n    return config;\n  }\n\n  if (config.sort_by == \"file_created_at\") {\n    config.asset_order_clause =\n        std::format(\"ORDER BY file_created_at {}, id {}\", config.sort_order, config.sort_order);\n    config.indexed_order_clause = std::format(\"ORDER BY sort_file_created_at {}, id {}\",\n                                              config.sort_order, config.sort_order);\n    return config;\n  }\n\n  if (config.sort_by == \"name\") {\n    config.asset_order_clause =\n        std::format(\"ORDER BY name {}, id {}\", config.sort_order, config.sort_order);\n    config.indexed_order_clause =\n        std::format(\"ORDER BY sort_name {}, id {}\", config.sort_order, config.sort_order);\n    return config;\n  }\n\n  if (config.sort_by == \"resolution\") {\n    config.asset_order_clause = std::format(\n        \"ORDER BY (COALESCE(width, 0) * COALESCE(height, 0)) {}, width {}, height {}, id {}\",\n        config.sort_order, config.sort_order, config.sort_order, config.sort_order);\n    config.indexed_order_clause =\n        std::format(\"ORDER BY sort_resolution {}, sort_width {}, sort_height {}, id {}\",\n                    config.sort_order, config.sort_order, config.sort_order, config.sort_order);\n    return config;\n  }\n\n  config.asset_order_clause =\n      std::format(\"ORDER BY size {}, id {}\", config.sort_order, config.sort_order);\n  config.indexed_order_clause =\n      std::format(\"ORDER BY sort_size {}, id {}\", config.sort_order, config.sort_order);\n  return config;\n}\n\nauto find_active_asset_index(Core::State::AppState& app_state,\n                             const Types::QueryAssetsFilters& filters,\n                             const QueryOrderConfig& order_config, std::int64_t active_asset_id)\n    -> std::expected<std::optional<std::int64_t>, std::string> {\n  // 复用与主查询完全一致的筛选/排序语义，只额外回答“当前 active 资产在第几位”。\n  auto where_result = build_unified_where_clause(filters);\n  if (!where_result) {\n    return std::unexpected(where_result.error());\n  }\n\n  auto [where_clause, query_params] = std::move(where_result.value());\n\n  std::string sql = std::format(R\"(\n    WITH filtered_assets AS (\n      SELECT id,\n             COALESCE(file_created_at, created_at) AS sort_created_at,\n             file_created_at AS sort_file_created_at,\n             name AS sort_name,\n             (COALESCE(width, 0) * COALESCE(height, 0)) AS sort_resolution,\n             COALESCE(width, 0) AS sort_width,\n             COALESCE(height, 0) AS sort_height,\n             size AS sort_size\n      FROM assets\n      {}\n    ),\n    indexed_assets AS (\n      SELECT id,\n             ROW_NUMBER() OVER ({}) - 1 AS row_index\n      FROM filtered_assets\n    )\n    SELECT row_index\n    FROM indexed_assets\n    WHERE id = ?\n  )\",\n                                where_clause, order_config.indexed_order_clause);\n\n  query_params.push_back(active_asset_id);\n\n  auto index_result =\n      Core::Database::query_scalar<std::int64_t>(*app_state.database, sql, query_params);\n  if (!index_result) {\n    return std::unexpected(\"Failed to query active asset index: \" + index_result.error());\n  }\n\n  return index_result.value();\n}\n\n// 构建统一的WHERE条件\nauto build_unified_where_clause(const Types::QueryAssetsFilters& filters,\n                                std::string_view asset_table_alias)\n    -> std::expected<std::pair<std::string, std::vector<Core::Database::Types::DbParam>>,\n                     std::string> {\n  std::vector<std::string> conditions;\n  std::vector<Core::Database::Types::DbParam> params;\n  const auto folder_id_column = qualify_asset_column(\"folder_id\", asset_table_alias);\n  const auto file_created_at_column = qualify_asset_column(\"file_created_at\", asset_table_alias);\n  const auto created_at_column = qualify_asset_column(\"created_at\", asset_table_alias);\n  const auto type_column = qualify_asset_column(\"type\", asset_table_alias);\n  const auto name_column = qualify_asset_column(\"name\", asset_table_alias);\n  const auto id_column = qualify_asset_column(\"id\", asset_table_alias);\n  const auto rating_column = qualify_asset_column(\"rating\", asset_table_alias);\n  const auto review_flag_column = qualify_asset_column(\"review_flag\", asset_table_alias);\n\n  // 文件夹筛选\n  if (filters.folder_id.has_value()) {\n    if (filters.include_subfolders.value_or(false)) {\n      // 使用递归CTE查询当前文件夹及所有子文件夹\n      conditions.push_back(std::format(R\"({} IN (\n        WITH RECURSIVE folder_hierarchy AS (\n          SELECT id FROM folders WHERE id = ?\n          UNION ALL\n          SELECT f.id FROM folders f\n          INNER JOIN folder_hierarchy fh ON f.parent_id = fh.id\n        )\n        SELECT id FROM folder_hierarchy\n      ))\",\n                                       folder_id_column));\n      params.push_back(filters.folder_id.value());\n    } else {\n      // 只查询当前文件夹\n      conditions.push_back(folder_id_column + \" = ?\");\n      params.push_back(filters.folder_id.value());\n    }\n  }\n\n  // 月份筛选\n  if (filters.month.has_value()) {\n    if (!validate_month_format(filters.month.value())) {\n      // 注意：这里如果格式不对，我们就忽略这个条件\n      // 或者可以在上层进行验证\n    } else {\n      conditions.push_back(\n          std::format(\"strftime('%Y-%m', datetime(COALESCE({}, {})/1000, 'unixepoch')) = ?\",\n                      file_created_at_column, created_at_column));\n      params.push_back(filters.month.value());\n    }\n  }\n\n  // 年份筛选\n  if (filters.year.has_value()) {\n    conditions.push_back(\n        std::format(\"strftime('%Y', datetime(COALESCE({}, {})/1000, 'unixepoch')) = ?\",\n                    file_created_at_column, created_at_column));\n    params.push_back(filters.year.value());\n  }\n\n  // 类型筛选\n  if (filters.type.has_value() && !filters.type->empty()) {\n    conditions.push_back(type_column + \" = ?\");\n    params.push_back(filters.type.value());\n  }\n\n  // 搜索\n  if (filters.search.has_value() && !filters.search->empty()) {\n    conditions.push_back(name_column + \" LIKE ?\");\n    params.push_back(\"%\" + filters.search.value() + \"%\");\n  }\n\n  // 审片筛选：评分和留用/弃置都属于资产固有元数据，因此直接挂在 assets 表上筛选。\n  if (filters.rating.has_value()) {\n    int rating = filters.rating.value();\n    if (rating < 0 || rating > 5) {\n      return std::unexpected(\"Rating filter must be between 0 and 5\");\n    }\n\n    conditions.push_back(rating_column + \" = ?\");\n    params.push_back(static_cast<std::int64_t>(rating));\n  }\n\n  if (filters.review_flag.has_value() && !filters.review_flag->empty()) {\n    if (!is_valid_review_flag(filters.review_flag.value())) {\n      return std::unexpected(\"Review flag filter must be one of none, picked, rejected\");\n    }\n\n    conditions.push_back(review_flag_column + \" = ?\");\n    params.push_back(filters.review_flag.value());\n  }\n\n  // 标签筛选\n  if (filters.tag_ids.has_value() && !filters.tag_ids->empty()) {\n    std::string match_mode = filters.tag_match_mode.value_or(\"any\");\n\n    if (match_mode == \"all\") {\n      // 匹配所有标签（AND）：资产必须拥有所有指定的标签\n      for (const auto& tag_id : filters.tag_ids.value()) {\n        conditions.push_back(\n            std::format(\"{} IN (SELECT asset_id FROM asset_tags WHERE tag_id = ?)\", id_column));\n        params.push_back(tag_id);\n      }\n    } else {\n      // 匹配任一标签（OR）：资产拥有任意一个标签即可\n      auto placeholders = build_in_clause_placeholders(filters.tag_ids->size());\n      conditions.push_back(std::format(\n          \"{} IN (SELECT asset_id FROM asset_tags WHERE tag_id IN ({}))\", id_column, placeholders));\n      for (const auto& tag_id : filters.tag_ids.value()) {\n        params.push_back(tag_id);\n      }\n    }\n  }\n\n  // 无限暖暖服装筛选\n  if (filters.cloth_ids.has_value() && !filters.cloth_ids->empty()) {\n    std::string match_mode = filters.cloth_match_mode.value_or(\"any\");\n    auto placeholders = build_in_clause_placeholders(filters.cloth_ids->size());\n\n    if (match_mode == \"all\") {\n      conditions.push_back(std::format(\n          R\"({} IN (\n            SELECT asset_id\n            FROM asset_infinity_nikki_clothes\n            WHERE cloth_id IN ({})\n            GROUP BY asset_id\n            HAVING COUNT(DISTINCT cloth_id) = ?\n          ))\",\n          id_column, placeholders));\n\n      for (const auto cloth_id : filters.cloth_ids.value()) {\n        params.push_back(cloth_id);\n      }\n      params.push_back(static_cast<std::int64_t>(filters.cloth_ids->size()));\n    } else {\n      conditions.push_back(std::format(\n          \"{} IN (SELECT DISTINCT asset_id FROM asset_infinity_nikki_clothes WHERE cloth_id IN \"\n          \"({}))\",\n          id_column, placeholders));\n      for (const auto cloth_id : filters.cloth_ids.value()) {\n        params.push_back(cloth_id);\n      }\n    }\n  }\n\n  auto color_filter_result = Features::Gallery::Color::Filter::append_color_filter_conditions(\n      filters, conditions, params, asset_table_alias);\n  if (!color_filter_result) {\n    return std::unexpected(color_filter_result.error());\n  }\n\n  // 构建 WHERE 子句\n  std::string where_clause;\n  if (!conditions.empty()) {\n    where_clause =\n        \"WHERE \" + std::ranges::fold_left(conditions, std::string{},\n                                          [](const std::string& acc, const std::string& cond) {\n                                            return acc.empty() ? cond : acc + \" AND \" + cond;\n                                          });\n  }\n\n  return std::make_pair(where_clause, params);\n}\n\n// ============= 查询服务实现 =============\n\n// 统一的资产查询函数\nauto query_assets(Core::State::AppState& app_state, const Types::QueryAssetsParams& params)\n    -> std::expected<Types::ListResponse, std::string> {\n  // 1. 构建通用WHERE条件\n  auto where_result = build_unified_where_clause(params.filters);\n  if (!where_result) {\n    return std::unexpected(where_result.error());\n  }\n  auto [where_clause, where_params] = std::move(where_result.value());\n\n  // 2. 验证和构建 ORDER BY\n  auto order_config = build_query_order_config(params.sort_by, params.sort_order);\n\n  // 3. 获取总数（用于分页计算或前端显示）\n  std::string count_sql = std::format(\"SELECT COUNT(*) FROM assets {}\", where_clause);\n  auto total_count_result =\n      Core::Database::query_scalar<int>(*app_state.database, count_sql, where_params);\n  if (!total_count_result) {\n    return std::unexpected(\"Failed to count assets: \" + total_count_result.error());\n  }\n  int total_count = total_count_result->value_or(0);\n\n  // 4. 构建主查询\n  std::string sql = std::format(R\"(\n    SELECT id, name, path, type,\n           (\n             SELECT printf('#%02X%02X%02X', ac.r, ac.g, ac.b)\n             FROM asset_colors ac\n             WHERE ac.asset_id = assets.id\n             ORDER BY ac.weight DESC, ac.id ASC\n             LIMIT 1\n           ) AS dominant_color_hex,\n           rating, review_flag,\n           description, width, height, size, extension, mime_type, hash,\n           NULL AS root_id, NULL AS relative_path, folder_id,\n           file_created_at, file_modified_at,\n           created_at, updated_at\n    FROM assets\n    {}\n    {}\n  )\",\n                                where_clause, order_config.asset_order_clause);\n\n  auto final_params = where_params;\n\n  // 5. 如果需要分页，添加 LIMIT/OFFSET\n  int page = 1;\n  int per_page = 50;\n  bool has_pagination = params.page.has_value() && params.per_page.has_value();\n\n  if (has_pagination) {\n    page = params.page.value();\n    per_page = params.per_page.value();\n    int offset = (page - 1) * per_page;\n    sql += \" LIMIT ? OFFSET ?\";\n    final_params.push_back(per_page);\n    final_params.push_back(offset);\n  }\n\n  // 6. 执行查询\n  auto assets_result = Core::Database::query<Types::Asset>(*app_state.database, sql, final_params);\n  if (!assets_result) {\n    return std::unexpected(\"Failed to query assets: \" + assets_result.error());\n  }\n\n  // 7. 构建响应\n  Types::ListResponse response;\n  response.items = std::move(assets_result.value());\n\n  auto locator_result =\n      Features::Gallery::OriginalLocator::populate_asset_locators(app_state, response.items);\n  if (!locator_result) {\n    return std::unexpected(locator_result.error());\n  }\n\n  response.total_count = total_count;\n\n  if (has_pagination) {\n    response.current_page = page;\n    response.per_page = per_page;\n    response.total_pages = (total_count + per_page - 1) / per_page;\n  } else {\n    // 不分页时，返回简单的分页信息\n    response.current_page = 1;\n    response.per_page = total_count;\n    response.total_pages = 1;\n  }\n\n  if (params.active_asset_id.has_value()) {\n    auto active_index_result = find_active_asset_index(app_state, params.filters, order_config,\n                                                       params.active_asset_id.value());\n    if (!active_index_result) {\n      return std::unexpected(active_index_result.error());\n    }\n    response.active_asset_index = active_index_result.value();\n  }\n\n  return response;\n}\n\nauto query_asset_layout_meta(Core::State::AppState& app_state,\n                             const Types::QueryAssetLayoutMetaParams& params)\n    -> std::expected<Types::QueryAssetLayoutMetaResponse, std::string> {\n  auto where_result = build_unified_where_clause(params.filters);\n  if (!where_result) {\n    return std::unexpected(where_result.error());\n  }\n  auto [where_clause, where_params] = std::move(where_result.value());\n\n  auto order_config = build_query_order_config(params.sort_by, params.sort_order);\n\n  std::string count_sql = std::format(\"SELECT COUNT(*) FROM assets {}\", where_clause);\n  auto total_count_result =\n      Core::Database::query_scalar<int>(*app_state.database, count_sql, where_params);\n  if (!total_count_result) {\n    return std::unexpected(\"Failed to count assets for layout meta: \" + total_count_result.error());\n  }\n\n  std::string sql = std::format(R\"(\n    SELECT id, width, height\n    FROM assets\n    {}\n    {}\n  )\",\n                                where_clause, order_config.asset_order_clause);\n\n  auto items_result =\n      Core::Database::query<Types::AssetLayoutMetaItem>(*app_state.database, sql, where_params);\n  if (!items_result) {\n    return std::unexpected(\"Failed to query asset layout meta: \" + items_result.error());\n  }\n\n  Types::QueryAssetLayoutMetaResponse response;\n  response.items = std::move(items_result.value());\n  response.total_count = total_count_result->value_or(0);\n  return response;\n}\n\nauto query_photo_map_points(Core::State::AppState& app_state,\n                            const Types::QueryPhotoMapPointsParams& params)\n    -> std::expected<std::vector<Types::PhotoMapPoint>, std::string> {\n  // 目标：为每个 marker 计算它在“当前 gallery 排序结果集”的 index，\n  // 以便前端打开灯箱时 activeIndex 一次性对齐，避免闪一下。\n\n  // 1) 复用 gallery 的“查询过滤语义”，计算排序 index 时不要提前丢掉视频等其它类型。\n  auto where_result = build_unified_where_clause(params.filters, \"a\");\n  if (!where_result) {\n    return std::unexpected(where_result.error());\n  }\n  auto [where_clause, query_params] = std::move(where_result.value());\n\n  // 2) 复用 gallery 的排序语义（sort_by/sort_order）。\n  auto order_config = build_query_order_config(params.sort_by, params.sort_order);\n\n  // 3) 在全量 filtered_assets 上计算 asset_index，再 JOIN Infinity Nikki 坐标表并过滤出可渲染\n  // marker。\n  //    注意：asset_index 是在“全量 filtered_assets 排序结果”里的下标；最终 WHERE 只影响 marker\n  //    子集输出， 不改变 asset_index 的数值。\n  std::string sql = std::format(R\"(\n    WITH filtered_assets AS (\n      SELECT a.id,\n             a.name,\n             a.hash,\n             a.file_created_at,\n             a.type AS asset_type,\n\n             COALESCE(a.file_created_at, a.created_at) AS sort_created_at,\n             a.file_created_at AS sort_file_created_at,\n             a.name AS sort_name,\n             (COALESCE(a.width, 0) * COALESCE(a.height, 0)) AS sort_resolution,\n             COALESCE(a.width, 0) AS sort_width,\n             COALESCE(a.height, 0) AS sort_height,\n             a.size AS sort_size\n      FROM assets a\n      {}\n    ),\n    indexed_assets AS (\n      SELECT id,\n             ROW_NUMBER() OVER ({}) - 1 AS asset_index\n      FROM filtered_assets\n    )\n    SELECT fa.id AS asset_id,\n           fa.name,\n           fa.hash,\n           fa.file_created_at,\n           p.nikki_loc_x,\n           p.nikki_loc_y,\n           p.nikki_loc_z,\n           ia.asset_index AS asset_index\n    FROM indexed_assets ia\n    INNER JOIN filtered_assets fa ON fa.id = ia.id\n    INNER JOIN asset_infinity_nikki_params p ON p.asset_id = ia.id\n    WHERE p.nikki_loc_x IS NOT NULL\n      AND p.nikki_loc_y IS NOT NULL\n      AND fa.asset_type IN ('photo', 'live_photo')\n    ORDER BY ia.asset_index\n  )\",\n                                where_clause, order_config.indexed_order_clause);\n\n  auto result = Core::Database::query<Types::PhotoMapPoint>(*app_state.database, sql, query_params);\n  if (!result) {\n    return std::unexpected(\"Failed to query photo map points: \" + result.error());\n  }\n\n  return result.value();\n}\n\nauto get_timeline_buckets(Core::State::AppState& app_state,\n                          const Types::TimelineBucketsParams& params)\n    -> std::expected<Types::TimelineBucketsResponse, std::string> {\n  // 将 TimelineBucketsParams 转换为 QueryAssetsFilters，复用统一的过滤逻辑\n  Types::QueryAssetsFilters filters;\n  filters.folder_id = params.folder_id;\n  filters.include_subfolders = params.include_subfolders;\n  filters.type = params.type;\n  filters.search = params.search;\n  filters.rating = params.rating;\n  filters.review_flag = params.review_flag;\n  filters.tag_ids = params.tag_ids;\n  filters.tag_match_mode = params.tag_match_mode;\n  filters.cloth_ids = params.cloth_ids;\n  filters.cloth_match_mode = params.cloth_match_mode;\n  filters.color_hexes = params.color_hexes;\n  filters.color_match_mode = params.color_match_mode;\n  filters.color_distance = params.color_distance;\n\n  auto order_config =\n      build_query_order_config(std::optional<std::string>{\"created_at\"}, params.sort_order);\n\n  // 复用统一的 WHERE 条件构建器\n  auto where_result = build_unified_where_clause(filters);\n  if (!where_result) {\n    return std::unexpected(where_result.error());\n  }\n  auto [where_clause, query_params] = std::move(where_result.value());\n\n  // 构建查询\n  std::string sql = std::format(R\"(\n    SELECT \n      strftime('%Y-%m', datetime(COALESCE(file_created_at, created_at)/1000, 'unixepoch')) as month,\n      COUNT(*) as count\n    FROM assets \n    {}\n    GROUP BY month\n    ORDER BY month {}\n  )\",\n                                where_clause, order_config.sort_order);\n\n  auto result =\n      Core::Database::query<Types::TimelineBucket>(*app_state.database, sql, query_params);\n\n  if (!result) {\n    return std::unexpected(\"Failed to query timeline buckets: \" + result.error());\n  }\n\n  // 计算总数\n  int total_count = 0;\n  for (const auto& bucket : result.value()) {\n    total_count += bucket.count;\n  }\n\n  Types::TimelineBucketsResponse response;\n  response.buckets = std::move(result.value());\n  response.total_count = total_count;\n\n  if (params.active_asset_id.has_value()) {\n    auto active_index_result =\n        find_active_asset_index(app_state, filters, order_config, params.active_asset_id.value());\n    if (!active_index_result) {\n      return std::unexpected(active_index_result.error());\n    }\n    response.active_asset_index = active_index_result.value();\n  }\n\n  return response;\n}\n\nauto get_assets_by_month(Core::State::AppState& app_state,\n                         const Types::GetAssetsByMonthParams& params)\n    -> std::expected<Types::GetAssetsByMonthResponse, std::string> {\n  // 验证月份格式\n  if (!validate_month_format(params.month)) {\n    return std::unexpected(\"Invalid month format. Expected: YYYY-MM\");\n  }\n\n  // 转换为统一查询参数\n  Types::QueryAssetsParams query_params;\n  query_params.filters.folder_id = params.folder_id;\n  query_params.filters.include_subfolders = params.include_subfolders;\n  query_params.filters.month = params.month;  // 关键：月份变成筛选条件\n  query_params.filters.type = params.type;\n  query_params.filters.search = params.search;\n  query_params.filters.rating = params.rating;\n  query_params.filters.review_flag = params.review_flag;\n  query_params.filters.tag_ids = params.tag_ids;\n  query_params.filters.tag_match_mode = params.tag_match_mode;\n  query_params.filters.cloth_ids = params.cloth_ids;\n  query_params.filters.cloth_match_mode = params.cloth_match_mode;\n  query_params.filters.color_hexes = params.color_hexes;\n  query_params.filters.color_match_mode = params.color_match_mode;\n  query_params.filters.color_distance = params.color_distance;\n  query_params.sort_by = \"created_at\";\n  query_params.sort_order = params.sort_order;\n  // 注意：不传 page，所以返回该月全部数据\n\n  // 调用统一查询接口\n  auto result = query_assets(app_state, query_params);\n  if (!result) {\n    return std::unexpected(result.error());\n  }\n\n  // 转换为 GetAssetsByMonthResponse 格式\n  Types::GetAssetsByMonthResponse response;\n  response.month = params.month;\n  response.assets = std::move(result.value().items);\n  response.count = static_cast<int>(response.assets.size());\n\n  return response;\n}\n\nauto get_infinity_nikki_details(Core::State::AppState& app_state,\n                                const Types::GetInfinityNikkiDetailsParams& params)\n    -> std::expected<Types::InfinityNikkiDetails, std::string> {\n  std::string extracted_sql = R\"(\n    SELECT camera_params,\n           time_hour,\n           time_min,\n           camera_focal_length,\n           rotation,\n           aperture_value,\n           filter_id,\n           filter_strength,\n           vignette_intensity,\n           light_id,\n           light_strength,\n           vertical,\n           bloom_intensity,\n           bloom_threshold,\n           brightness,\n           exposure,\n           contrast,\n           saturation,\n           vibrance,\n           highlights,\n           shadow,\n           nikki_loc_x,\n           nikki_loc_y,\n           nikki_loc_z,\n           nikki_hidden,\n           pose_id\n    FROM asset_infinity_nikki_params\n    WHERE asset_id = ?\n  )\";\n\n  auto extracted_result = Core::Database::query_single<Types::InfinityNikkiExtractedParams>(\n      *app_state.database, extracted_sql, {params.asset_id});\n  if (!extracted_result) {\n    return std::unexpected(\"Failed to query Infinity Nikki extracted params: \" +\n                           extracted_result.error());\n  }\n\n  std::string user_record_sql = R\"(\n    SELECT code_type,\n           code_value\n    FROM asset_infinity_nikki_user_record\n    WHERE asset_id = ?\n  )\";\n\n  auto user_record_result = Core::Database::query_single<Types::InfinityNikkiUserRecord>(\n      *app_state.database, user_record_sql, {params.asset_id});\n  if (!user_record_result) {\n    return std::unexpected(\"Failed to query Infinity Nikki user record: \" +\n                           user_record_result.error());\n  }\n\n  return Types::InfinityNikkiDetails{.extracted = extracted_result.value(),\n                                     .user_record = user_record_result.value()};\n}\n\nauto get_asset_main_colors(Core::State::AppState& app_state,\n                           const Types::GetAssetMainColorsParams& params)\n    -> std::expected<std::vector<Types::AssetMainColor>, std::string> {\n  auto result =\n      Features::Gallery::Color::Repository::get_asset_main_colors(app_state, params.asset_id);\n  if (!result) {\n    return std::unexpected(result.error());\n  }\n\n  return result.value();\n}\n\nauto get_home_stats(Core::State::AppState& app_state)\n    -> std::expected<Types::HomeStats, std::string> {\n  std::string sql = R\"(\n    SELECT\n      COUNT(*) AS total_count,\n      COALESCE(SUM(CASE WHEN type = 'photo' THEN 1 ELSE 0 END), 0) AS photo_count,\n      COALESCE(SUM(CASE WHEN type = 'video' THEN 1 ELSE 0 END), 0) AS video_count,\n      COALESCE(SUM(CASE WHEN type = 'live_photo' THEN 1 ELSE 0 END), 0) AS live_photo_count,\n      COALESCE(SUM(CASE WHEN size IS NOT NULL AND size > 0 THEN size ELSE 0 END), 0) AS total_size,\n      COALESCE(\n        SUM(\n          CASE\n            WHEN date(datetime(COALESCE(file_created_at, created_at) / 1000, 'unixepoch', 'localtime')) =\n                 date('now', 'localtime') THEN 1\n            ELSE 0\n          END\n        ),\n        0\n      ) AS today_added_count\n    FROM assets\n  )\";\n\n  auto result = Core::Database::query_single<Types::HomeStats>(*app_state.database, sql);\n  if (!result) {\n    return std::unexpected(\"Failed to query home stats: \" + result.error());\n  }\n\n  return result.value().value_or(Types::HomeStats{});\n}\n\nauto update_assets_review_state(Core::State::AppState& app_state,\n                                const Types::UpdateAssetsReviewStateParams& params)\n    -> std::expected<Types::OperationResult, std::string> {\n  if (params.asset_ids.empty()) {\n    return std::unexpected(\"No assets selected for review update\");\n  }\n\n  if (!params.rating.has_value() && !params.review_flag.has_value()) {\n    return std::unexpected(\"At least one review field must be provided\");\n  }\n\n  if (params.rating.has_value() && (params.rating.value() < 0 || params.rating.value() > 5)) {\n    return std::unexpected(\"Rating must be between 0 and 5\");\n  }\n\n  if (params.review_flag.has_value() && !is_valid_review_flag(params.review_flag.value())) {\n    return std::unexpected(\"Review flag must be one of none, picked, rejected\");\n  }\n\n  std::vector<std::string> set_clauses;\n  std::vector<Core::Database::Types::DbParam> db_params;\n\n  // 这里使用统一的批量更新入口，后续如果想扩展标记时间、操作者等元数据，只需在这里继续扩展。\n  if (params.rating.has_value()) {\n    set_clauses.push_back(\"rating = ?\");\n    db_params.push_back(static_cast<std::int64_t>(params.rating.value()));\n  }\n\n  if (params.review_flag.has_value()) {\n    set_clauses.push_back(\"review_flag = ?\");\n    db_params.push_back(params.review_flag.value());\n  }\n\n  auto placeholders = build_in_clause_placeholders(params.asset_ids.size());\n  std::string sql =\n      std::format(\"UPDATE assets SET {} WHERE id IN ({})\",\n                  std::ranges::fold_left(set_clauses, std::string{},\n                                         [](const std::string& acc, const std::string& clause) {\n                                           return acc.empty() ? clause : acc + \", \" + clause;\n                                         }),\n                  placeholders);\n\n  for (const auto asset_id : params.asset_ids) {\n    db_params.push_back(asset_id);\n  }\n\n  auto result = Core::Database::execute(*app_state.database, sql, db_params);\n  if (!result) {\n    return std::unexpected(\"Failed to update assets review state: \" + result.error());\n  }\n\n  return Types::OperationResult{\n      .success = true,\n      .message = \"Assets review state updated successfully\",\n      .affected_count = static_cast<std::int64_t>(params.asset_ids.size())};\n}\n\nauto update_asset_description(Core::State::AppState& app_state,\n                              const Types::UpdateAssetDescriptionParams& params)\n    -> std::expected<Types::OperationResult, std::string> {\n  if (params.asset_id <= 0) {\n    return std::unexpected(\"Asset id must be greater than 0\");\n  }\n\n  auto asset_result =\n      Features::Gallery::Asset::Repository::get_asset_by_id(app_state, params.asset_id);\n  if (!asset_result) {\n    return std::unexpected(\"Failed to load asset before updating description: \" +\n                           asset_result.error());\n  }\n\n  if (!asset_result->has_value()) {\n    return std::unexpected(\"Asset not found\");\n  }\n\n  auto normalized_description =\n      params.description.has_value()\n          ? std::optional<std::string>{trim_ascii_copy(params.description.value())}\n          : std::nullopt;\n  if (normalized_description.has_value() && normalized_description->empty()) {\n    normalized_description = std::nullopt;\n  }\n\n  std::vector<Core::Database::Types::DbParam> db_params;\n  db_params.push_back(normalized_description.has_value()\n                          ? Core::Database::Types::DbParam{normalized_description.value()}\n                          : Core::Database::Types::DbParam{std::monostate{}});\n  db_params.push_back(params.asset_id);\n\n  auto result = Core::Database::execute(\n      *app_state.database, \"UPDATE assets SET description = ? WHERE id = ?\", db_params);\n  if (!result) {\n    return std::unexpected(\"Failed to update asset description: \" + result.error());\n  }\n\n  auto affected_result =\n      Core::Database::query_scalar<std::int64_t>(*app_state.database, \"SELECT changes()\");\n  if (!affected_result) {\n    return std::unexpected(\"Failed to query updated asset count: \" + affected_result.error());\n  }\n\n  return Types::OperationResult{.success = true,\n                                .message = \"Asset description updated successfully\",\n                                .affected_count = affected_result->value_or(0)};\n}\n\nauto set_infinity_nikki_user_record(Core::State::AppState& app_state,\n                                    const Types::SetInfinityNikkiUserRecordParams& params)\n    -> std::expected<Types::OperationResult, std::string> {\n  if (params.asset_id <= 0) {\n    return std::unexpected(\"Asset id must be greater than 0\");\n  }\n\n  if (params.code_type != \"dye\" && params.code_type != \"home_building\") {\n    return std::unexpected(\"Infinity Nikki code type must be one of dye, home_building\");\n  }\n\n  auto asset_result =\n      Features::Gallery::Asset::Repository::get_asset_by_id(app_state, params.asset_id);\n  if (!asset_result) {\n    return std::unexpected(\"Failed to load asset before updating Infinity Nikki user record: \" +\n                           asset_result.error());\n  }\n\n  if (!asset_result->has_value()) {\n    return std::unexpected(\"Asset not found\");\n  }\n\n  auto normalized_code_value =\n      params.code_value.has_value()\n          ? std::optional<std::string>{trim_ascii_copy(params.code_value.value())}\n          : std::nullopt;\n  if (normalized_code_value.has_value() && normalized_code_value->empty()) {\n    normalized_code_value = std::nullopt;\n  }\n\n  std::expected<void, std::string> write_result;\n  if (normalized_code_value.has_value()) {\n    auto result =\n        Core::Database::execute(*app_state.database,\n                                R\"(\n          INSERT INTO asset_infinity_nikki_user_record (asset_id, code_type, code_value)\n          VALUES (?, ?, ?)\n          ON CONFLICT(asset_id) DO UPDATE SET\n            code_type = excluded.code_type,\n            code_value = excluded.code_value\n        )\",\n                                {params.asset_id, params.code_type, normalized_code_value.value()});\n    if (!result) {\n      write_result =\n          std::unexpected(\"Failed to upsert Infinity Nikki user record: \" + result.error());\n    } else {\n      write_result = {};\n    }\n  } else {\n    auto result = Core::Database::execute(\n        *app_state.database, \"DELETE FROM asset_infinity_nikki_user_record WHERE asset_id = ?\",\n        {params.asset_id});\n    if (!result) {\n      write_result =\n          std::unexpected(\"Failed to delete Infinity Nikki user record: \" + result.error());\n    } else {\n      write_result = {};\n    }\n  }\n\n  if (!write_result) {\n    return std::unexpected(write_result.error());\n  }\n\n  auto result = Core::Database::query_scalar<std::int64_t>(*app_state.database, \"SELECT changes()\");\n  if (!result) {\n    return std::unexpected(\"Failed to query updated Infinity Nikki user record count: \" +\n                           result.error());\n  }\n\n  return Types::OperationResult{.success = true,\n                                .message = \"Infinity Nikki user record updated successfully\",\n                                .affected_count = result->value_or(0)};\n}\n\nauto get_infinity_nikki_metadata_names(Core::State::AppState& app_state,\n                                       const Types::GetInfinityNikkiMetadataNamesParams& params)\n    -> asio::awaitable<std::expected<Types::InfinityNikkiMetadataNames, std::string>> {\n  co_return co_await InfinityNikkiMetadataDict::resolve_metadata_names(app_state, params);\n}\n\n// ============= 维护服务实现 =============\n\nauto load_asset_cache(Core::State::AppState& app_state)\n    -> std::expected<std::unordered_map<std::string, Types::Metadata>, std::string> {\n  std::string sql = R\"(\n    SELECT id, name, path, type,\n           NULL AS dominant_color_hex,\n           rating, review_flag,\n           description, width, height, size, extension, mime_type, hash,\n           NULL AS root_id, NULL AS relative_path, folder_id,\n           file_created_at, file_modified_at,\n           created_at, updated_at\n    FROM assets\n  )\";\n\n  auto result = Core::Database::query<Types::Asset>(*app_state.database, sql);\n  if (!result) {\n    return std::unexpected(\"Failed to load asset cache: \" + result.error());\n  }\n\n  auto assets = std::move(result.value());\n  std::unordered_map<std::string, Types::Metadata> cache;\n  cache.reserve(assets.size());\n  for (const auto& asset : assets) {\n    Types::Metadata metadata{.id = asset.id,\n                             .path = asset.path,\n                             .size = asset.size.value_or(0),\n                             .file_modified_at = asset.file_modified_at.value_or(0),\n                             .hash = asset.hash.value_or(\"\")};\n\n    cache.emplace(asset.path, std::move(metadata));\n  }\n  Logger().info(\"Loaded {} assets into memory cache\", cache.size());\n  return cache;\n}\n\n}  // namespace Features::Gallery::Asset::Service\n"
  },
  {
    "path": "src/features/gallery/asset/service.ixx",
    "content": "module;\n\nexport module Features.Gallery.Asset.Service;\n\nimport std;\nimport Core.State;\nimport Features.Gallery.Types;\nimport <asio.hpp>;\n\nnamespace Features::Gallery::Asset::Service {\n\n// 查询服务\nexport auto query_assets(Core::State::AppState& app_state, const Types::QueryAssetsParams& params)\n    -> std::expected<Types::ListResponse, std::string>;\n\nexport auto query_asset_layout_meta(Core::State::AppState& app_state,\n                                    const Types::QueryAssetLayoutMetaParams& params)\n    -> std::expected<Types::QueryAssetLayoutMetaResponse, std::string>;\n\nexport auto query_photo_map_points(Core::State::AppState& app_state,\n                                   const Types::QueryPhotoMapPointsParams& params)\n    -> std::expected<std::vector<Types::PhotoMapPoint>, std::string>;\n\nexport auto get_timeline_buckets(Core::State::AppState& app_state,\n                                 const Types::TimelineBucketsParams& params)\n    -> std::expected<Types::TimelineBucketsResponse, std::string>;\n\nexport auto get_assets_by_month(Core::State::AppState& app_state,\n                                const Types::GetAssetsByMonthParams& params)\n    -> std::expected<Types::GetAssetsByMonthResponse, std::string>;\n\nexport auto get_infinity_nikki_details(Core::State::AppState& app_state,\n                                       const Types::GetInfinityNikkiDetailsParams& params)\n    -> std::expected<Types::InfinityNikkiDetails, std::string>;\n\nexport auto get_asset_main_colors(Core::State::AppState& app_state,\n                                  const Types::GetAssetMainColorsParams& params)\n    -> std::expected<std::vector<Types::AssetMainColor>, std::string>;\n\nexport auto get_home_stats(Core::State::AppState& app_state)\n    -> std::expected<Types::HomeStats, std::string>;\n\nexport auto update_assets_review_state(Core::State::AppState& app_state,\n                                       const Types::UpdateAssetsReviewStateParams& params)\n    -> std::expected<Types::OperationResult, std::string>;\n\nexport auto update_asset_description(Core::State::AppState& app_state,\n                                     const Types::UpdateAssetDescriptionParams& params)\n    -> std::expected<Types::OperationResult, std::string>;\n\nexport auto set_infinity_nikki_user_record(Core::State::AppState& app_state,\n                                           const Types::SetInfinityNikkiUserRecordParams& params)\n    -> std::expected<Types::OperationResult, std::string>;\n\nexport auto get_infinity_nikki_metadata_names(\n    Core::State::AppState& app_state, const Types::GetInfinityNikkiMetadataNamesParams& params)\n    -> asio::awaitable<std::expected<Types::InfinityNikkiMetadataNames, std::string>>;\n\n// 维护服务\nexport auto load_asset_cache(Core::State::AppState& app_state)\n    -> std::expected<std::unordered_map<std::string, Types::Metadata>, std::string>;\n\n}  // namespace Features::Gallery::Asset::Service\n"
  },
  {
    "path": "src/features/gallery/asset/thumbnail.cpp",
    "content": "module;\n\nmodule Features.Gallery.Asset.Thumbnail;\n\nimport std;\nimport Core.State;\nimport Features.Gallery.Types;\nimport Features.Gallery.State;\nimport Features.Gallery.Asset.Repository;\nimport Features.Gallery.Asset.Service;\nimport Core.Database;\nimport Utils.Image;\nimport Utils.Media.VideoAsset;\nimport Utils.Path;\nimport Utils.Logger;\n\nnamespace Features::Gallery::Asset::Thumbnail {\n\n// “一个缩略图 hash 应该如何被满足”的最小工作单元。\n// 一个 hash 可能对应多个源文件路径；修复时只需找到其中任意一个仍存在的源文件。\nstruct ExpectedThumbnailEntry {\n  std::string hash;\n  std::string type;\n  std::vector<std::filesystem::path> source_paths;\n};\n\n// 内部汇总结构：只关注“缺失缩略图补回”这一件事。\nstruct MissingThumbnailRepairSummary {\n  int candidate_hashes = 0;\n  int missing_thumbnails = 0;\n  int repaired_thumbnails = 0;\n  int failed_repairs = 0;\n  int skipped_missing_sources = 0;\n};\n\nauto extract_hash_from_thumbnail(const std::filesystem::path& thumbnail_path)\n    -> std::optional<std::string>;\n\nauto query_thumbnail_candidates(Core::State::AppState& app_state)\n    -> std::expected<std::vector<Types::Asset>, std::string> {\n  std::string sql = R\"(\n    SELECT id, name, path, type,\n           NULL AS dominant_color_hex,\n           rating, review_flag,\n           description, width, height, size, extension, mime_type, hash,\n           NULL AS root_id, NULL AS relative_path, folder_id,\n           file_created_at, file_modified_at,\n           created_at, updated_at\n    FROM assets\n    WHERE type IN ('photo', 'video')\n      AND hash IS NOT NULL\n      AND hash != ''\n      AND path IS NOT NULL\n      AND path != ''\n  )\";\n\n  auto result = Core::Database::query<Types::Asset>(*app_state.database, sql);\n  if (!result) {\n    return std::unexpected(\"Failed to query thumbnail candidates: \" + result.error());\n  }\n\n  return result.value();\n}\n\n// 局部修复时允许只处理某个 root；全局对账则传 nullopt 表示不过滤。\nauto normalize_thumbnail_root_filter(std::optional<std::filesystem::path> root_directory)\n    -> std::expected<std::optional<std::filesystem::path>, std::string> {\n  if (!root_directory.has_value()) {\n    return std::optional<std::filesystem::path>{std::nullopt};\n  }\n\n  auto normalized_root_result = Utils::Path::NormalizePath(root_directory.value());\n  if (!normalized_root_result) {\n    return std::unexpected(\"Failed to normalize thumbnail repair root: \" +\n                           normalized_root_result.error());\n  }\n\n  return std::optional<std::filesystem::path>{normalized_root_result.value()};\n}\n\n// 从 DB 收集“理论上应当存在缩略图”的集合，并按 hash 去重。\nauto collect_expected_thumbnail_entries(Core::State::AppState& app_state,\n                                        std::optional<std::filesystem::path> root_directory)\n    -> std::expected<std::unordered_map<std::string, ExpectedThumbnailEntry>, std::string> {\n  auto normalized_root_result = normalize_thumbnail_root_filter(root_directory);\n  if (!normalized_root_result) {\n    return std::unexpected(normalized_root_result.error());\n  }\n  const auto& normalized_root_directory = normalized_root_result.value();\n\n  auto candidates_result = query_thumbnail_candidates(app_state);\n  if (!candidates_result) {\n    return std::unexpected(candidates_result.error());\n  }\n\n  std::unordered_map<std::string, ExpectedThumbnailEntry> entries;\n  for (const auto& asset : candidates_result.value()) {\n    if (!asset.hash.has_value() || asset.hash->empty()) {\n      continue;\n    }\n\n    std::filesystem::path asset_path(asset.path);\n    if (normalized_root_directory.has_value() &&\n        !Utils::Path::IsPathWithinBase(asset_path, normalized_root_directory.value())) {\n      continue;\n    }\n\n    auto& entry = entries[asset.hash.value()];\n    if (entry.hash.empty()) {\n      entry.hash = asset.hash.value();\n      entry.type = asset.type;\n    }\n    entry.source_paths.push_back(std::move(asset_path));\n  }\n\n  return entries;\n}\n\n// 从缩略图目录扫描“当前实际存在的缩略图集合”。\nauto scan_existing_thumbnail_files(Core::State::AppState& app_state)\n    -> std::expected<std::unordered_map<std::string, std::filesystem::path>, std::string> {\n  if (app_state.gallery->thumbnails_directory.empty()) {\n    return std::unexpected(\"Thumbnails directory not initialized\");\n  }\n\n  auto thumbnails_dir = app_state.gallery->thumbnails_directory;\n  std::error_code ec;\n  if (!std::filesystem::exists(thumbnails_dir, ec)) {\n    return std::unordered_map<std::string, std::filesystem::path>{};\n  }\n  if (ec) {\n    return std::unexpected(\"Failed to check thumbnails directory existence: \" + ec.message());\n  }\n\n  std::unordered_map<std::string, std::filesystem::path> entries;\n  for (const auto& entry : std::filesystem::recursive_directory_iterator(thumbnails_dir, ec)) {\n    if (ec) {\n      return std::unexpected(\"Failed to iterate thumbnails directory recursively: \" + ec.message());\n    }\n\n    if (!entry.is_regular_file(ec) || entry.path().extension() != \".webp\") {\n      continue;\n    }\n    if (ec) {\n      return std::unexpected(\"Failed to inspect thumbnail entry: \" + ec.message());\n    }\n\n    auto hash_result = extract_hash_from_thumbnail(entry.path());\n    if (!hash_result) {\n      continue;\n    }\n\n    entries.try_emplace(*hash_result, entry.path());\n  }\n\n  if (ec) {\n    return std::unexpected(\"Error during thumbnail inventory scan: \" + ec.message());\n  }\n\n  return entries;\n}\n\n// 只负责补“缺失缩略图”；孤儿删除由上层全局对账处理。\n// 如果调用方已经事先拿到了 existing_hashes，就不必再逐个 hash 查磁盘了。\nauto repair_expected_thumbnail_entries(\n    Core::State::AppState& app_state,\n    const std::unordered_map<std::string, ExpectedThumbnailEntry>& expected_entries,\n    const std::unordered_set<std::string>* existing_hashes, std::uint32_t short_edge_size)\n    -> MissingThumbnailRepairSummary {\n  MissingThumbnailRepairSummary stats;\n  stats.candidate_hashes = static_cast<int>(expected_entries.size());\n\n  std::optional<Utils::Image::WICFactory> wic_factory;\n\n  for (const auto& [hash, entry] : expected_entries) {\n    bool thumbnail_exists = false;\n\n    if (existing_hashes != nullptr) {\n      thumbnail_exists = existing_hashes->contains(hash);\n    } else {\n      auto thumbnail_path_result = ensure_thumbnail_path(app_state, hash);\n      if (!thumbnail_path_result) {\n        stats.failed_repairs++;\n        Logger().warn(\"Failed to resolve thumbnail path for hash {}: {}\", hash,\n                      thumbnail_path_result.error());\n        continue;\n      }\n\n      std::error_code exists_ec;\n      thumbnail_exists = std::filesystem::exists(thumbnail_path_result.value(), exists_ec);\n      if (exists_ec) {\n        stats.failed_repairs++;\n        Logger().warn(\"Failed to check thumbnail existence for hash {}: {}\", hash,\n                      exists_ec.message());\n        continue;\n      }\n    }\n\n    if (thumbnail_exists) {\n      continue;\n    }\n\n    stats.missing_thumbnails++;\n\n    std::optional<std::filesystem::path> source_path;\n    // 同一个 hash 可能来自多份重复内容；这里选任意一份还存在的源文件即可重建缩略图。\n    for (const auto& candidate_path : entry.source_paths) {\n      std::error_code source_ec;\n      bool exists = std::filesystem::exists(candidate_path, source_ec);\n      bool is_regular = exists && std::filesystem::is_regular_file(candidate_path, source_ec);\n      if (!source_ec && exists && is_regular) {\n        source_path = candidate_path;\n        break;\n      }\n    }\n\n    if (!source_path.has_value()) {\n      stats.skipped_missing_sources++;\n      Logger().debug(\"Skip thumbnail repair for hash {}: no source file is available\", hash);\n      continue;\n    }\n\n    if (entry.type == \"photo\") {\n      if (!wic_factory.has_value()) {\n        auto wic_result = Utils::Image::get_thread_wic_factory();\n        if (!wic_result) {\n          stats.failed_repairs++;\n          Logger().warn(\"Failed to initialize WIC factory for thumbnail repair: {}\",\n                        wic_result.error());\n          continue;\n        }\n        wic_factory = std::move(wic_result.value());\n      }\n\n      auto repair_result =\n          generate_thumbnail(app_state, *wic_factory, *source_path, hash, short_edge_size, false);\n      if (!repair_result) {\n        stats.failed_repairs++;\n        Logger().warn(\"Failed to repair thumbnail for '{}': {}\", source_path->string(),\n                      repair_result.error());\n        continue;\n      }\n\n      stats.repaired_thumbnails++;\n      continue;\n    }\n\n    if (entry.type == \"video\") {\n      auto video_result =\n          Utils::Media::VideoAsset::analyze_video_file(*source_path, short_edge_size);\n      if (!video_result) {\n        stats.failed_repairs++;\n        Logger().warn(\"Failed to analyze video during thumbnail repair '{}': {}\",\n                      source_path->string(), video_result.error());\n        continue;\n      }\n\n      if (!video_result->thumbnail.has_value()) {\n        stats.failed_repairs++;\n        Logger().warn(\"Video thumbnail repair yielded no thumbnail data: {}\",\n                      source_path->string());\n        continue;\n      }\n\n      auto repair_result = save_thumbnail_data(app_state, hash, *video_result->thumbnail, false);\n      if (!repair_result) {\n        stats.failed_repairs++;\n        Logger().warn(\"Failed to save repaired video thumbnail for '{}': {}\", source_path->string(),\n                      repair_result.error());\n        continue;\n      }\n\n      stats.repaired_thumbnails++;\n    }\n  }\n\n  return stats;\n}\n\n// ============= 缩略图路径管理 =============\n\nauto build_thumbnail_path(const std::filesystem::path& thumbnails_dir, const std::string& file_hash)\n    -> std::filesystem::path {\n  auto level1 = file_hash.substr(0, 2);\n  auto level2 = file_hash.substr(2, 2);\n  return thumbnails_dir / level1 / level2 / std::format(\"{}.webp\", file_hash);\n}\n\nauto extract_hash_from_thumbnail(const std::filesystem::path& thumbnail_path)\n    -> std::optional<std::string> {\n  auto stem = thumbnail_path.stem().string();\n  if (stem.empty()) {\n    return std::nullopt;\n  }\n  return stem;\n}\n\nauto ensure_thumbnails_directory_exists(Core::State::AppState& app_state)\n    -> std::expected<void, std::string> {\n  // 如果状态中已经有缩略图目录路径，确保目录存在\n  if (!app_state.gallery->thumbnails_directory.empty()) {\n    auto ensure_dir_result =\n        Utils::Path::EnsureDirectoryExists(app_state.gallery->thumbnails_directory);\n    if (!ensure_dir_result) {\n      return std::unexpected(\"Failed to ensure thumbnails directory exists: \" +\n                             ensure_dir_result.error());\n    }\n    return {};\n  }\n\n  // 否则，计算路径、创建目录并保存到状态中\n  auto thumbnails_dir_result = Utils::Path::GetAppDataSubdirectory(\"thumbnails\");\n  if (!thumbnails_dir_result) {\n    return std::unexpected(\"Failed to get thumbnails directory: \" + thumbnails_dir_result.error());\n  }\n\n  auto thumbnails_dir = thumbnails_dir_result.value();\n\n  app_state.gallery->thumbnails_directory = thumbnails_dir;\n\n  return {};\n}\n\n// 确保缩略图路径存在\nauto ensure_thumbnail_path(Core::State::AppState& app_state, const std::string& file_hash)\n    -> std::expected<std::filesystem::path, std::string> {\n  // 检查缩略图目录是否已初始化\n  if (app_state.gallery->thumbnails_directory.empty()) {\n    auto ensure_result = ensure_thumbnails_directory_exists(app_state);\n    if (!ensure_result) {\n      return std::unexpected(ensure_result.error());\n    }\n  }\n\n  auto thumbnail_path = build_thumbnail_path(app_state.gallery->thumbnails_directory, file_hash);\n\n  // 确保子目录存在\n  std::error_code ec;\n  auto parent_dir = thumbnail_path.parent_path();\n  if (!std::filesystem::exists(parent_dir, ec)) {\n    if (!std::filesystem::create_directories(parent_dir, ec)) {\n      return std::unexpected(\"Failed to create thumbnail subdirectories: \" + ec.message());\n    }\n  }\n\n  return thumbnail_path;\n}\n\nauto repair_missing_thumbnails(Core::State::AppState& app_state,\n                               std::optional<std::filesystem::path> root_directory,\n                               std::uint32_t short_edge_size)\n    -> std::expected<ThumbnailRepairStats, std::string> {\n  auto ensure_result = ensure_thumbnails_directory_exists(app_state);\n  if (!ensure_result) {\n    return std::unexpected(ensure_result.error());\n  }\n\n  // 局部修复：只处理“当前 root 缺哪些缩略图”，不删除孤儿缩略图。\n  auto expected_entries_result = collect_expected_thumbnail_entries(app_state, root_directory);\n  if (!expected_entries_result) {\n    return std::unexpected(expected_entries_result.error());\n  }\n\n  auto summary = repair_expected_thumbnail_entries(app_state, expected_entries_result.value(),\n                                                   nullptr, short_edge_size);\n  return ThumbnailRepairStats{.candidate_hashes = summary.candidate_hashes,\n                              .missing_thumbnails = summary.missing_thumbnails,\n                              .repaired_thumbnails = summary.repaired_thumbnails,\n                              .failed_repairs = summary.failed_repairs,\n                              .skipped_missing_sources = summary.skipped_missing_sources};\n}\n\nauto reconcile_thumbnail_cache(Core::State::AppState& app_state, std::uint32_t short_edge_size)\n    -> std::expected<ThumbnailCacheReconcileStats, std::string> {\n  auto ensure_result = ensure_thumbnails_directory_exists(app_state);\n  if (!ensure_result) {\n    return std::unexpected(ensure_result.error());\n  }\n\n  // 启动时的全局缓存对账：先拿到 DB 认为“应存在”的集合。\n  auto expected_entries_result = collect_expected_thumbnail_entries(app_state, std::nullopt);\n  if (!expected_entries_result) {\n    return std::unexpected(expected_entries_result.error());\n  }\n\n  // 再扫描磁盘上“实际存在”的集合。\n  auto existing_entries_result = scan_existing_thumbnail_files(app_state);\n  if (!existing_entries_result) {\n    return std::unexpected(existing_entries_result.error());\n  }\n\n  const auto& expected_entries = expected_entries_result.value();\n  const auto& existing_entries = existing_entries_result.value();\n\n  std::unordered_set<std::string> existing_hashes;\n  existing_hashes.reserve(existing_entries.size());\n  for (const auto& [hash, _] : existing_entries) {\n    existing_hashes.insert(hash);\n  }\n\n  ThumbnailCacheReconcileStats stats;\n  stats.expected_hashes = static_cast<int>(expected_entries.size());\n  stats.existing_thumbnails = static_cast<int>(existing_entries.size());\n\n  // 先补 missing，再删 orphan。\n  // 对用户体验来说，先恢复可见内容比先回收磁盘空间更重要。\n  auto repair_summary = repair_expected_thumbnail_entries(app_state, expected_entries,\n                                                          &existing_hashes, short_edge_size);\n  stats.missing_thumbnails = repair_summary.missing_thumbnails;\n  stats.repaired_thumbnails = repair_summary.repaired_thumbnails;\n  stats.failed_repairs = repair_summary.failed_repairs;\n  stats.skipped_missing_sources = repair_summary.skipped_missing_sources;\n\n  for (const auto& [hash, thumbnail_path] : existing_entries) {\n    if (expected_entries.contains(hash)) {\n      continue;\n    }\n\n    // 走到这里说明：磁盘上有这个缩略图，但 DB 已不再认为它应该存在。\n    stats.orphaned_thumbnails++;\n    std::error_code remove_ec;\n    if (std::filesystem::remove(thumbnail_path, remove_ec)) {\n      stats.deleted_orphaned_thumbnails++;\n      Logger().debug(\"Deleted orphaned thumbnail during cache reconcile: {}\",\n                     thumbnail_path.string());\n    } else {\n      stats.failed_orphan_deletions++;\n      Logger().warn(\"Failed to delete orphaned thumbnail during cache reconcile {}: {}\",\n                    thumbnail_path.string(), remove_ec.message());\n    }\n  }\n\n  return stats;\n}\n\n// ============= 缩略图清理功能 =============\n\nauto delete_thumbnail(Core::State::AppState& app_state, const Types::Asset& asset)\n    -> std::expected<void, std::string> {\n  if (app_state.gallery->thumbnails_directory.empty()) {\n    return std::unexpected(\"Thumbnails directory not initialized\");\n  }\n\n  if (!asset.hash || asset.hash->empty()) {\n    return std::unexpected(\"Asset has no file hash, cannot determine thumbnail files\");\n  }\n\n  const std::string& file_hash = asset.hash.value();\n  auto thumbnail_path = build_thumbnail_path(app_state.gallery->thumbnails_directory, file_hash);\n  std::error_code ec;\n\n  bool exists = std::filesystem::exists(thumbnail_path, ec);\n  if (ec) {\n    return std::unexpected(\"Failed to check thumbnail existence: \" + ec.message());\n  }\n  if (!exists) {\n    return {};\n  }\n\n  if (!std::filesystem::remove(thumbnail_path, ec)) {\n    return std::unexpected(\"Failed to delete thumbnail: \" + ec.message());\n  }\n\n  Logger().debug(\"Deleted thumbnail: {}\", thumbnail_path.string());\n  return {};\n}\n\nauto cleanup_orphaned_thumbnails(Core::State::AppState& app_state)\n    -> std::expected<int, std::string> {\n  // 直接从状态中获取缩略图目录路径\n  if (app_state.gallery->thumbnails_directory.empty()) {\n    return std::unexpected(\"Thumbnails directory not initialized\");\n  }\n  auto thumbnails_dir = app_state.gallery->thumbnails_directory;\n\n  std::error_code ec;\n  if (!std::filesystem::exists(thumbnails_dir, ec)) {\n    return 0;  // 目录不存在，没有需要清理的\n  }\n\n  // 使用 load_asset_cache 获取所有资产的文件哈希集合\n  std::unordered_set<std::string> all_file_hashes;\n  auto cache_result = Service::load_asset_cache(app_state);\n  if (cache_result) {\n    for (const auto& [path, metadata] : cache_result.value()) {\n      if (!metadata.hash.empty()) {\n        all_file_hashes.insert(metadata.hash);\n      }\n    }\n  }\n\n  int deleted_count = 0;\n\n  // 使用递归遍历器以支持分层目录结构\n  for (const auto& entry : std::filesystem::recursive_directory_iterator(thumbnails_dir, ec)) {\n    if (ec) {\n      return std::unexpected(\"Failed to iterate thumbnails directory recursively: \" + ec.message());\n    }\n\n    if (entry.is_regular_file(ec) && entry.path().extension() == \".webp\") {\n      auto hash_result = extract_hash_from_thumbnail(entry.path());\n      if (!hash_result) {\n        continue;\n      }\n\n      // 文件哈希不存在于任何资产中，删除缩略图\n      if (all_file_hashes.find(*hash_result) == all_file_hashes.end()) {\n        std::error_code remove_ec;\n        if (std::filesystem::remove(entry.path(), remove_ec)) {\n          deleted_count++;\n          Logger().debug(\"Deleted orphaned thumbnail: {}\", entry.path().string());\n        } else {\n          Logger().warn(\"Failed to delete orphaned thumbnail {}: {}\", entry.path().string(),\n                        remove_ec.message());\n        }\n      }\n    }\n  }\n\n  if (ec) {\n    return std::unexpected(\"Error during orphaned thumbnails cleanup: \" + ec.message());\n  }\n\n  return deleted_count;\n}\n\n// ============= 基于哈希的缩略图生成 =============\n\n// 使用文件哈希生成缩略图（按短边等比例缩放）\nauto generate_thumbnail(Core::State::AppState& app_state, Utils::Image::WICFactory& wic_factory,\n                        const std::filesystem::path& source_file, const std::string& file_hash,\n                        std::uint32_t short_edge_size, bool force_overwrite)\n    -> std::expected<std::filesystem::path, std::string> {\n  try {\n    auto thumbnail_path_result = ensure_thumbnail_path(app_state, file_hash);\n    if (!thumbnail_path_result) {\n      return std::unexpected(thumbnail_path_result.error());\n    }\n    auto thumbnail_path = thumbnail_path_result.value();\n\n    // 检查缩略图是否已存在（基于哈希的去重）\n    if (!force_overwrite && std::filesystem::exists(thumbnail_path)) {\n      Logger().debug(\"Thumbnail already exists, reusing: {}\", thumbnail_path.string());\n      return thumbnail_path;\n    }\n\n    // 生成 WebP 缩略图\n    Utils::Image::WebPEncodeOptions options;\n    options.quality = 90.0f;  // 默认质量\n\n    auto webp_result =\n        Utils::Image::generate_webp_thumbnail(wic_factory, source_file, short_edge_size, options);\n    if (!webp_result) {\n      return std::unexpected(\"Failed to generate WebP thumbnail: \" + webp_result.error());\n    }\n\n    return save_thumbnail_data(app_state, file_hash, webp_result.value(), force_overwrite);\n\n  } catch (const std::exception& e) {\n    return std::unexpected(\"Exception in generate_thumbnail: \" + std::string(e.what()));\n  }\n}\n\n// 将已编码 WebP 写入按 file_hash\n// 命名的路径；图片解码与视频抽帧共用。存在则跳过，减少扫描并发重复写。\nauto save_thumbnail_data(Core::State::AppState& app_state, const std::string& file_hash,\n                         const Utils::Image::WebPEncodedResult& webp_data, bool force_overwrite)\n    -> std::expected<std::filesystem::path, std::string> {\n  auto thumbnail_path_result = ensure_thumbnail_path(app_state, file_hash);\n  if (!thumbnail_path_result) {\n    return std::unexpected(thumbnail_path_result.error());\n  }\n  auto thumbnail_path = thumbnail_path_result.value();\n\n  if (!force_overwrite && std::filesystem::exists(thumbnail_path)) {\n    Logger().debug(\"Thumbnail already exists, reusing: {}\", thumbnail_path.string());\n    return thumbnail_path;\n  }\n\n  std::ofstream thumbnail_file(thumbnail_path, std::ios::binary);\n  if (!thumbnail_file) {\n    return std::unexpected(\"Failed to create thumbnail file: \" + thumbnail_path.string());\n  }\n\n  thumbnail_file.write(reinterpret_cast<const char*>(webp_data.data.data()), webp_data.data.size());\n  thumbnail_file.close();\n\n  if (!thumbnail_file.good()) {\n    return std::unexpected(\"Failed to write thumbnail data to file\");\n  }\n\n  Logger().debug(\"Generated thumbnail: {} ({} bytes)\", thumbnail_path.string(),\n                 webp_data.data.size());\n  return thumbnail_path;\n}\n\n// ============= 缩略图统计功能 =============\n\nauto get_thumbnail_stats(Core::State::AppState& app_state)\n    -> std::expected<AssetThumbnailStats, std::string> {\n  AssetThumbnailStats stats = {};\n\n  // 直接从状态中获取缩略图目录路径\n  if (app_state.gallery->thumbnails_directory.empty()) {\n    return std::unexpected(\"Thumbnails directory not initialized\");\n  }\n  auto thumbnails_dir = app_state.gallery->thumbnails_directory;\n  stats.thumbnails_directory = thumbnails_dir.string();\n\n  std::error_code ec;\n  if (!std::filesystem::exists(thumbnails_dir, ec)) {\n    return stats;  // 返回空统计\n  }\n\n  // 使用 load_asset_cache 获取所有资产的文件哈希集合\n  std::unordered_set<std::string> all_file_hashes;\n  auto cache_result = Service::load_asset_cache(app_state);\n  if (cache_result) {\n    for (const auto& [path, metadata] : cache_result.value()) {\n      if (!metadata.hash.empty()) {\n        all_file_hashes.insert(metadata.hash);\n      }\n    }\n  }\n\n  std::int64_t total_size = 0;\n  int total_thumbnails = 0;\n  int orphaned_thumbnails = 0;\n\n  // 使用递归遍历器以支持分层目录结构\n  for (const auto& entry : std::filesystem::recursive_directory_iterator(thumbnails_dir, ec)) {\n    if (ec) {\n      return std::unexpected(\"Failed to iterate thumbnails directory recursively: \" + ec.message());\n    }\n\n    if (entry.is_regular_file(ec) && entry.path().extension() == \".webp\") {\n      total_thumbnails++;\n\n      // 累加文件大小\n      std::error_code size_ec;\n      auto file_size = std::filesystem::file_size(entry.path(), size_ec);\n      if (!size_ec) {\n        total_size += file_size;\n      }\n\n      // 检查是否为孤立缩略图\n      auto hash_result = extract_hash_from_thumbnail(entry.path());\n      if (hash_result && all_file_hashes.find(*hash_result) == all_file_hashes.end()) {\n        orphaned_thumbnails++;\n      }\n    }\n  }\n\n  if (ec) {\n    return std::unexpected(\"Error during thumbnail stats collection: \" + ec.message());\n  }\n\n  stats.total_thumbnails = total_thumbnails;\n  stats.total_size = total_size;\n  stats.orphaned_thumbnails = orphaned_thumbnails;\n\n  return stats;\n}\n\n}  // namespace Features::Gallery::Asset::Thumbnail\n"
  },
  {
    "path": "src/features/gallery/asset/thumbnail.ixx",
    "content": "module;\n\nexport module Features.Gallery.Asset.Thumbnail;\n\nimport std;\nimport Core.State;\nimport Features.Gallery.Types;\nimport Utils.Image;\n\nexport namespace Features::Gallery::Asset::Thumbnail {\n\n// 仅用于“补缺失缩略图”场景的统计。\n// 这里不关心孤儿缩略图，因为局部修复不会删除它们。\nstruct ThumbnailRepairStats {\n  // 去重后的候选 hash 数；不是资产条数。\n  int candidate_hashes = 0;\n  // 期望存在但当前磁盘上不存在的缩略图数。\n  int missing_thumbnails = 0;\n  // 本次实际补回成功的缩略图数。\n  int repaired_thumbnails = 0;\n  // 生成/写入失败的次数。\n  int failed_repairs = 0;\n  // 期望补图，但找不到任何可用原图源文件的次数。\n  int skipped_missing_sources = 0;\n};\n\n// 用于“全局缓存对账”场景的统计。\n// 启动时会同时关注：缺失缩略图补回 + 孤儿缩略图清理。\nstruct ThumbnailCacheReconcileStats {\n  // DB 中“理论上应当存在缩略图”的去重 hash 总数。\n  int expected_hashes = 0;\n  // 缩略图目录里实际扫描到的 .webp 文件数（按 hash 去重后）。\n  int existing_thumbnails = 0;\n  // expected - existing 的数量。\n  int missing_thumbnails = 0;\n  // 缺失缩略图中，最终成功补回的数量。\n  int repaired_thumbnails = 0;\n  // existing - expected 的数量。\n  int orphaned_thumbnails = 0;\n  // 孤儿缩略图中，实际删除成功的数量。\n  int deleted_orphaned_thumbnails = 0;\n  // 补图失败次数。\n  int failed_repairs = 0;\n  // 删除孤儿缩略图失败次数。\n  int failed_orphan_deletions = 0;\n  // 理论缺失，但没有任何可用源文件可用于重建的次数。\n  int skipped_missing_sources = 0;\n};\n\n// 缩略图生成\nauto generate_thumbnail(Core::State::AppState& app_state, Utils::Image::WICFactory& wic_factory,\n                        const std::filesystem::path& source_file, const std::string& file_hash,\n                        std::uint32_t short_edge_size, bool force_overwrite = false)\n    -> std::expected<std::filesystem::path, std::string>;\n\n// 落盘内存中的 WebP（视频封面帧等）；路径规则与 generate_thumbnail 一致。\nauto save_thumbnail_data(Core::State::AppState& app_state, const std::string& file_hash,\n                         const Utils::Image::WebPEncodedResult& webp_data,\n                         bool force_overwrite = false)\n    -> std::expected<std::filesystem::path, std::string>;\n\n// 路径管理\nauto ensure_thumbnails_directory_exists(Core::State::AppState& app_state)\n    -> std::expected<void, std::string>;\n\nauto ensure_thumbnail_path(Core::State::AppState& app_state, const std::string& file_hash)\n    -> std::expected<std::filesystem::path, std::string>;\n\nauto repair_missing_thumbnails(Core::State::AppState& app_state,\n                               std::optional<std::filesystem::path> root_directory = std::nullopt,\n                               std::uint32_t short_edge_size = 480)\n    -> std::expected<ThumbnailRepairStats, std::string>;\n\n// 启动后的全局缓存对账：\n// 1. 用 DB 推导“应存在的缩略图集合”\n// 2. 用磁盘枚举“实际存在的缩略图集合”\n// 3. 补 missing，删 orphan\nauto reconcile_thumbnail_cache(Core::State::AppState& app_state,\n                               std::uint32_t short_edge_size = 480)\n    -> std::expected<ThumbnailCacheReconcileStats, std::string>;\n\n// 清理功能\nauto cleanup_orphaned_thumbnails(Core::State::AppState& app_state)\n    -> std::expected<int, std::string>;\n\nauto delete_thumbnail(Core::State::AppState& app_state, const Types::Asset& asset)\n    -> std::expected<void, std::string>;\n\n// 统计信息\nstruct AssetThumbnailStats {\n  int total_thumbnails;\n  std::int64_t total_size;\n  int orphaned_thumbnails;\n  int corrupted_thumbnails;\n  std::string thumbnails_directory;\n};\n\nauto get_thumbnail_stats(Core::State::AppState& app_state)\n    -> std::expected<AssetThumbnailStats, std::string>;\n\n}  // namespace Features::Gallery::Asset::Thumbnail\n"
  },
  {
    "path": "src/features/gallery/color/extractor.cpp",
    "content": "module;\n\n#include <dkm.hpp>\n\nmodule Features.Gallery.Color.Extractor;\n\nimport std;\nimport Utils.Image;\nimport Features.Gallery.Color.Types;\n\nnamespace Features::Gallery::Color::Extractor {\n\nauto parse_hex_nibble(char ch) -> std::optional<uint8_t> {\n  if (ch >= '0' && ch <= '9') {\n    return static_cast<uint8_t>(ch - '0');\n  }\n  if (ch >= 'a' && ch <= 'f') {\n    return static_cast<uint8_t>(10 + (ch - 'a'));\n  }\n  if (ch >= 'A' && ch <= 'F') {\n    return static_cast<uint8_t>(10 + (ch - 'A'));\n  }\n  return std::nullopt;\n}\n\nauto parse_hex_byte(char high, char low) -> std::optional<uint8_t> {\n  auto high_value = parse_hex_nibble(high);\n  auto low_value = parse_hex_nibble(low);\n  if (!high_value || !low_value) {\n    return std::nullopt;\n  }\n  return static_cast<uint8_t>((*high_value << 4) | *low_value);\n}\n\nauto parse_hex_color(std::string_view hex) -> std::expected<std::array<uint8_t, 3>, std::string> {\n  if (!hex.empty() && hex.front() == '#') {\n    hex.remove_prefix(1);\n  }\n  if (hex.size() != 6) {\n    return std::unexpected(\"Expected color format #RRGGBB or RRGGBB\");\n  }\n\n  auto r = parse_hex_byte(hex[0], hex[1]);\n  auto g = parse_hex_byte(hex[2], hex[3]);\n  auto b = parse_hex_byte(hex[4], hex[5]);\n  if (!r || !g || !b) {\n    return std::unexpected(\"Invalid hex color characters\");\n  }\n\n  return std::array<uint8_t, 3>{*r, *g, *b};\n}\n\nauto srgb_to_linear(float value) -> float {\n  float normalized = value / 255.0f;\n  if (normalized <= 0.04045f) {\n    return normalized / 12.92f;\n  }\n  return std::pow((normalized + 0.055f) / 1.055f, 2.4f);\n}\n\nauto lab_f(float value) -> float {\n  constexpr float epsilon = 216.0f / 24389.0f;\n  constexpr float kappa = 24389.0f / 27.0f;\n  if (value > epsilon) {\n    return std::cbrt(value);\n  }\n  return (kappa * value + 16.0f) / 116.0f;\n}\n\nauto rgb_to_lab_color(uint8_t r, uint8_t g, uint8_t b, float l_bin_size, float ab_bin_size)\n    -> Types::LabColor {\n  float lr = srgb_to_linear(static_cast<float>(r));\n  float lg = srgb_to_linear(static_cast<float>(g));\n  float lb = srgb_to_linear(static_cast<float>(b));\n\n  float x = lr * 0.4124564f + lg * 0.3575761f + lb * 0.1804375f;\n  float y = lr * 0.2126729f + lg * 0.7151522f + lb * 0.0721750f;\n  float z = lr * 0.0193339f + lg * 0.1191920f + lb * 0.9503041f;\n\n  constexpr float ref_x = 0.95047f;\n  constexpr float ref_y = 1.00000f;\n  constexpr float ref_z = 1.08883f;\n\n  float fx = lab_f(x / ref_x);\n  float fy = lab_f(y / ref_y);\n  float fz = lab_f(z / ref_z);\n\n  float l = 116.0f * fy - 16.0f;\n  float a = 500.0f * (fx - fy);\n  float lab_b = 200.0f * (fy - fz);\n\n  int l_bin = std::max(0, static_cast<int>(std::floor(l / l_bin_size)));\n  int a_bin = std::max(0, static_cast<int>(std::floor((a + 128.0f) / ab_bin_size)));\n  int b_bin = std::max(0, static_cast<int>(std::floor((lab_b + 128.0f) / ab_bin_size)));\n\n  return Types::LabColor{\n      .l = l,\n      .a = a,\n      .b = lab_b,\n      .l_bin = l_bin,\n      .a_bin = a_bin,\n      .b_bin = b_bin,\n  };\n}\n\nauto run_kmeans(const std::vector<std::array<float, 3>>& points, size_t k)\n    -> std::expected<std::pair<std::vector<std::array<float, 3>>, std::vector<size_t>>,\n                     std::string> {\n  try {\n    auto [means, labels] = dkm::kmeans_lloyd(points, k);\n    std::vector<size_t> normalized_labels;\n    normalized_labels.reserve(labels.size());\n    for (const auto& label : labels) {\n      normalized_labels.push_back(static_cast<size_t>(label));\n    }\n    return std::make_pair(std::move(means), std::move(normalized_labels));\n  } catch (const std::exception& e) {\n    return std::unexpected(\"DKM kmeans failed: \" + std::string(e.what()));\n  }\n}\n\nauto delta_e_76(const Types::ExtractedColor& lhs, const Types::ExtractedColor& rhs) -> float {\n  float dl = lhs.lab_l - rhs.lab_l;\n  float da = lhs.lab_a - rhs.lab_a;\n  float db = lhs.lab_b - rhs.lab_b;\n  return std::sqrt(dl * dl + da * da + db * db);\n}\n\nauto extract_main_colors(Utils::Image::WICFactory& factory, const std::filesystem::path& path,\n                         const Types::MainColorExtractOptions& options)\n    -> std::expected<std::vector<Types::ExtractedColor>, std::string> {\n  if (!factory) {\n    return std::unexpected(\"WIC factory is null\");\n  }\n  if (!std::filesystem::exists(path)) {\n    return std::unexpected(\"File does not exist: \" + path.string());\n  }\n\n  auto frame_result = Utils::Image::load_bitmap_frame(factory.get(), path);\n  if (!frame_result) {\n    return std::unexpected(frame_result.error());\n  }\n\n  auto scaled_result =\n      Utils::Image::scale_bitmap(factory.get(), frame_result->get(), options.sample_short_edge);\n  if (!scaled_result) {\n    return std::unexpected(scaled_result.error());\n  }\n\n  auto bgra_result = Utils::Image::convert_to_bgra_bitmap(factory.get(), scaled_result->get());\n  if (!bgra_result) {\n    return std::unexpected(bgra_result.error());\n  }\n\n  auto bitmap_data_result = Utils::Image::copy_bgra_bitmap_data(bgra_result->get());\n  if (!bitmap_data_result) {\n    return std::unexpected(bitmap_data_result.error());\n  }\n  auto bitmap_data = std::move(bitmap_data_result.value());\n\n  uint64_t total_pixels = static_cast<uint64_t>(bitmap_data.width) * bitmap_data.height;\n  if (total_pixels == 0) {\n    return std::unexpected(\"Image has no pixels\");\n  }\n\n  uint32_t max_samples = options.max_samples;\n  uint64_t sample_step = std::max<uint64_t>(1, total_pixels / max_samples);\n  std::vector<std::array<float, 3>> points;\n  points.reserve(static_cast<size_t>(std::min<uint64_t>(total_pixels, max_samples)));\n\n  for (uint64_t index = 0; index < total_pixels; index += sample_step) {\n    uint32_t y = static_cast<uint32_t>(index / bitmap_data.width);\n    uint32_t x = static_cast<uint32_t>(index % bitmap_data.width);\n    uint64_t offset = static_cast<uint64_t>(y) * bitmap_data.stride + static_cast<uint64_t>(x) * 4;\n    if (offset + 3 >= bitmap_data.pixels.size()) {\n      continue;\n    }\n\n    uint8_t b = bitmap_data.pixels[offset + 0];\n    uint8_t g = bitmap_data.pixels[offset + 1];\n    uint8_t r = bitmap_data.pixels[offset + 2];\n    uint8_t a = bitmap_data.pixels[offset + 3];\n    if (a < 16) {\n      continue;\n    }\n\n    points.push_back({static_cast<float>(r), static_cast<float>(g), static_cast<float>(b)});\n  }\n\n  if (points.empty()) {\n    return std::unexpected(\"No valid pixels found for color extraction\");\n  }\n\n  size_t cluster_count =\n      std::clamp(static_cast<size_t>(options.cluster_count), size_t{1}, points.size());\n  auto kmeans_result = run_kmeans(points, cluster_count);\n  if (!kmeans_result) {\n    return std::unexpected(\"Color clustering failed: \" + kmeans_result.error());\n  }\n\n  auto [means, labels] = std::move(kmeans_result.value());\n  std::vector<size_t> cluster_counts(means.size(), 0);\n  for (auto label : labels) {\n    if (label < cluster_counts.size()) {\n      cluster_counts[label] += 1;\n    }\n  }\n\n  std::vector<Types::ExtractedColor> palette;\n  palette.reserve(means.size());\n  float total_weight = static_cast<float>(labels.size());\n\n  for (size_t cluster = 0; cluster < means.size(); ++cluster) {\n    if (cluster_counts[cluster] == 0) {\n      continue;\n    }\n    uint8_t r = static_cast<uint8_t>(std::clamp(std::lround(means[cluster][0]), 0l, 255l));\n    uint8_t g = static_cast<uint8_t>(std::clamp(std::lround(means[cluster][1]), 0l, 255l));\n    uint8_t b = static_cast<uint8_t>(std::clamp(std::lround(means[cluster][2]), 0l, 255l));\n    auto lab = rgb_to_lab_color(r, g, b, options.l_bin_size, options.ab_bin_size);\n\n    palette.push_back(Types::ExtractedColor{\n        .r = r,\n        .g = g,\n        .b = b,\n        .lab_l = lab.l,\n        .lab_a = lab.a,\n        .lab_b = lab.b,\n        .weight = static_cast<float>(cluster_counts[cluster]) / total_weight,\n        .l_bin = lab.l_bin,\n        .a_bin = lab.a_bin,\n        .b_bin = lab.b_bin,\n    });\n  }\n\n  std::ranges::sort(palette,\n                    [](const Types::ExtractedColor& lhs, const Types::ExtractedColor& rhs) {\n                      return lhs.weight > rhs.weight;\n                    });\n\n  std::vector<Types::ExtractedColor> merged_palette;\n  for (const auto& color : palette) {\n    bool merged = false;\n    for (auto& existing : merged_palette) {\n      if (delta_e_76(existing, color) < options.merge_delta_e) {\n        float new_weight = existing.weight + color.weight;\n        if (new_weight > 0.0f) {\n          auto mixed_channel = [new_weight](float lhs, float lhs_weight, float rhs,\n                                            float rhs_weight) {\n            return (lhs * lhs_weight + rhs * rhs_weight) / new_weight;\n          };\n\n          uint8_t mixed_r = static_cast<uint8_t>(std::clamp(\n              std::lround(mixed_channel(existing.r, existing.weight, color.r, color.weight)), 0l,\n              255l));\n          uint8_t mixed_g = static_cast<uint8_t>(std::clamp(\n              std::lround(mixed_channel(existing.g, existing.weight, color.g, color.weight)), 0l,\n              255l));\n          uint8_t mixed_b = static_cast<uint8_t>(std::clamp(\n              std::lround(mixed_channel(existing.b, existing.weight, color.b, color.weight)), 0l,\n              255l));\n          auto lab =\n              rgb_to_lab_color(mixed_r, mixed_g, mixed_b, options.l_bin_size, options.ab_bin_size);\n\n          existing = Types::ExtractedColor{\n              .r = mixed_r,\n              .g = mixed_g,\n              .b = mixed_b,\n              .lab_l = lab.l,\n              .lab_a = lab.a,\n              .lab_b = lab.b,\n              .weight = new_weight,\n              .l_bin = lab.l_bin,\n              .a_bin = lab.a_bin,\n              .b_bin = lab.b_bin,\n          };\n        }\n        merged = true;\n        break;\n      }\n    }\n\n    if (!merged) {\n      merged_palette.push_back(color);\n    }\n  }\n\n  std::ranges::sort(merged_palette,\n                    [](const Types::ExtractedColor& lhs, const Types::ExtractedColor& rhs) {\n                      return lhs.weight > rhs.weight;\n                    });\n\n  float merged_weight_sum = 0.0f;\n  for (const auto& color : merged_palette) {\n    merged_weight_sum += color.weight;\n  }\n  if (merged_weight_sum > 0.0f) {\n    for (auto& color : merged_palette) {\n      color.weight /= merged_weight_sum;\n    }\n  }\n\n  std::vector<Types::ExtractedColor> selected_palette;\n  float cumulative_weight = 0.0f;\n  for (const auto& color : merged_palette) {\n    if (selected_palette.size() >= options.max_colors) {\n      break;\n    }\n\n    bool require_for_minimum = selected_palette.size() < options.min_colors;\n    if (!require_for_minimum && color.weight < options.min_weight) {\n      continue;\n    }\n\n    selected_palette.push_back(color);\n    cumulative_weight += color.weight;\n    if (selected_palette.size() >= options.min_colors && cumulative_weight >= options.coverage) {\n      break;\n    }\n  }\n\n  if (selected_palette.empty() && !merged_palette.empty()) {\n    selected_palette.push_back(merged_palette.front());\n  }\n\n  float selected_weight_sum = 0.0f;\n  for (const auto& color : selected_palette) {\n    selected_weight_sum += color.weight;\n  }\n  if (selected_weight_sum > 0.0f) {\n    for (auto& color : selected_palette) {\n      color.weight /= selected_weight_sum;\n    }\n  }\n\n  return selected_palette;\n}\n\n}  // namespace Features::Gallery::Color::Extractor\n"
  },
  {
    "path": "src/features/gallery/color/extractor.ixx",
    "content": "module;\n\nexport module Features.Gallery.Color.Extractor;\n\nimport std;\nimport Utils.Image;\nimport Features.Gallery.Color.Types;\n\nnamespace Features::Gallery::Color::Extractor {\n\nexport auto parse_hex_color(std::string_view hex)\n    -> std::expected<std::array<std::uint8_t, 3>, std::string>;\n\nexport auto rgb_to_lab_color(std::uint8_t r, std::uint8_t g, std::uint8_t b,\n                             float l_bin_size = 5.0f, float ab_bin_size = 8.0f) -> Types::LabColor;\n\nexport auto extract_main_colors(Utils::Image::WICFactory& factory,\n                                const std::filesystem::path& path,\n                                const Types::MainColorExtractOptions& options = {})\n    -> std::expected<std::vector<Types::ExtractedColor>, std::string>;\n\n}  // namespace Features::Gallery::Color::Extractor\n"
  },
  {
    "path": "src/features/gallery/color/filter.cpp",
    "content": "module;\n\nmodule Features.Gallery.Color.Filter;\n\nimport std;\nimport Core.Database.Types;\nimport Features.Gallery.Types;\nimport Features.Gallery.Color.Extractor;\n\nnamespace Features::Gallery::Color::Filter {\n\nconstexpr double kDefaultColorDistance = 18.0;\nconstexpr int kColorBinTolerance = 1;\n\nstruct QueryColorTarget {\n  float lab_l = 0.0f;\n  float lab_a = 0.0f;\n  float lab_b = 0.0f;\n  int l_bin = 0;\n  int a_bin = 0;\n  int b_bin = 0;\n};\n\nauto build_color_target(const std::string& hex) -> std::expected<QueryColorTarget, std::string> {\n  auto rgb_result = Features::Gallery::Color::Extractor::parse_hex_color(hex);\n  if (!rgb_result) {\n    return std::unexpected(rgb_result.error());\n  }\n\n  auto rgb = rgb_result.value();\n  auto lab = Features::Gallery::Color::Extractor::rgb_to_lab_color(rgb[0], rgb[1], rgb[2]);\n  return QueryColorTarget{\n      .lab_l = lab.l,\n      .lab_a = lab.a,\n      .lab_b = lab.b,\n      .l_bin = lab.l_bin,\n      .a_bin = lab.a_bin,\n      .b_bin = lab.b_bin,\n  };\n}\n\nauto append_color_match_params(std::vector<Core::Database::Types::DbParam>& params,\n                               const QueryColorTarget& target, double distance) -> void {\n  params.push_back(std::max(0, target.l_bin - kColorBinTolerance));\n  params.push_back(target.l_bin + kColorBinTolerance);\n  params.push_back(std::max(0, target.a_bin - kColorBinTolerance));\n  params.push_back(target.a_bin + kColorBinTolerance);\n  params.push_back(std::max(0, target.b_bin - kColorBinTolerance));\n  params.push_back(target.b_bin + kColorBinTolerance);\n\n  params.push_back(static_cast<double>(target.lab_l));\n  params.push_back(static_cast<double>(target.lab_l));\n  params.push_back(static_cast<double>(target.lab_a));\n  params.push_back(static_cast<double>(target.lab_a));\n  params.push_back(static_cast<double>(target.lab_b));\n  params.push_back(static_cast<double>(target.lab_b));\n  params.push_back(distance * distance);\n}\n\nauto build_single_color_match_sql() -> std::string {\n  return R\"(\nac.l_bin BETWEEN ? AND ?\nAND ac.a_bin BETWEEN ? AND ?\nAND ac.b_bin BETWEEN ? AND ?\nAND (\n  (ac.lab_l - ?) * (ac.lab_l - ?)\n  + (ac.lab_a - ?) * (ac.lab_a - ?)\n  + (ac.lab_b - ?) * (ac.lab_b - ?)\n) <= ?\n)\";\n}\n\nauto qualify_asset_id(std::string_view asset_table_alias) -> std::string {\n  if (asset_table_alias.empty()) {\n    return \"id\";\n  }\n\n  return std::string(asset_table_alias) + \".id\";\n}\n\nauto append_color_filter_conditions(const Features::Gallery::Types::QueryAssetsFilters& filters,\n                                    std::vector<std::string>& conditions,\n                                    std::vector<Core::Database::Types::DbParam>& params,\n                                    std::string_view asset_table_alias)\n    -> std::expected<void, std::string> {\n  if (!filters.color_hexes.has_value() || filters.color_hexes->empty()) {\n    return {};\n  }\n\n  std::vector<QueryColorTarget> targets;\n  targets.reserve(filters.color_hexes->size());\n\n  for (const auto& color_hex : filters.color_hexes.value()) {\n    auto target_result = build_color_target(color_hex);\n    if (!target_result) {\n      return std::unexpected(\"Invalid color hex '\" + color_hex + \"': \" + target_result.error());\n    }\n    targets.push_back(target_result.value());\n  }\n\n  const double color_distance =\n      std::max(0.1, filters.color_distance.value_or(kDefaultColorDistance));\n  const std::string color_match_sql = build_single_color_match_sql();\n  const std::string color_match_mode = filters.color_match_mode.value_or(\"any\");\n  const auto asset_id = qualify_asset_id(asset_table_alias);\n\n  if (color_match_mode == \"all\") {\n    for (const auto& target : targets) {\n      conditions.push_back(std::format(\"{} IN (SELECT ac.asset_id FROM asset_colors ac WHERE {})\",\n                                       asset_id, color_match_sql));\n      append_color_match_params(params, target, color_distance);\n    }\n    return {};\n  }\n\n  std::vector<std::string> any_match_sql_parts;\n  any_match_sql_parts.reserve(targets.size());\n  for (const auto& target : targets) {\n    any_match_sql_parts.push_back(std::format(\"({})\", color_match_sql));\n    append_color_match_params(params, target, color_distance);\n  }\n\n  auto merged_any_sql = std::ranges::fold_left(any_match_sql_parts, std::string{},\n                                               [](const std::string& acc, const std::string& item) {\n                                                 return acc.empty() ? item : acc + \" OR \" + item;\n                                               });\n\n  conditions.push_back(\n      std::format(\"{} IN (SELECT DISTINCT ac.asset_id FROM asset_colors ac WHERE {})\", asset_id,\n                  merged_any_sql));\n  return {};\n}\n\n}  // namespace Features::Gallery::Color::Filter\n"
  },
  {
    "path": "src/features/gallery/color/filter.ixx",
    "content": "module;\n\nexport module Features.Gallery.Color.Filter;\n\nimport std;\nimport Core.Database.Types;\nimport Features.Gallery.Types;\n\nnamespace Features::Gallery::Color::Filter {\n\nexport auto append_color_filter_conditions(\n    const Features::Gallery::Types::QueryAssetsFilters& filters,\n    std::vector<std::string>& conditions, std::vector<Core::Database::Types::DbParam>& params,\n    std::string_view asset_table_alias = \"\") -> std::expected<void, std::string>;\n\n}  // namespace Features::Gallery::Color::Filter\n"
  },
  {
    "path": "src/features/gallery/color/repository.cpp",
    "content": "module;\n\nmodule Features.Gallery.Color.Repository;\n\nimport std;\nimport Core.State;\nimport Core.Database;\nimport Core.Database.State;\nimport Core.Database.Types;\nimport Features.Gallery.Color.Types;\nimport Features.Gallery.Types;\n\nnamespace Features::Gallery::Color::Repository {\n\nauto replace_asset_colors(Core::State::AppState& app_state, std::int64_t asset_id,\n                          const std::vector<Types::ExtractedColor>& colors)\n    -> std::expected<void, std::string> {\n  ColorReplaceBatchItem item{\n      .asset_id = asset_id,\n      .colors = colors,\n  };\n  std::vector<ColorReplaceBatchItem> items;\n  items.push_back(std::move(item));\n  return batch_replace_asset_colors(app_state, items);\n}\n\nauto batch_replace_asset_colors(Core::State::AppState& app_state,\n                                const std::vector<ColorReplaceBatchItem>& items)\n    -> std::expected<void, std::string> {\n  if (items.empty()) {\n    return {};\n  }\n\n  return Core::Database::execute_transaction(\n      *app_state.database,\n      [&items](Core::Database::State::DatabaseState& db_state) -> std::expected<void, std::string> {\n        static const std::string kDeleteSql = \"DELETE FROM asset_colors WHERE asset_id = ?\";\n        static const std::string kInsertSql = R\"(\n          INSERT INTO asset_colors (\n            asset_id, r, g, b, lab_l, lab_a, lab_b, weight, l_bin, a_bin, b_bin\n          ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n        )\";\n\n        for (const auto& item : items) {\n          if (item.asset_id <= 0) {\n            return std::unexpected(\"Invalid asset_id in color replacement batch\");\n          }\n\n          auto delete_result = Core::Database::execute(\n              db_state, kDeleteSql, std::vector<Core::Database::Types::DbParam>{item.asset_id});\n          if (!delete_result) {\n            return std::unexpected(\"Failed to delete existing asset colors for asset_id \" +\n                                   std::to_string(item.asset_id) + \": \" + delete_result.error());\n          }\n\n          for (const auto& color : item.colors) {\n            std::vector<Core::Database::Types::DbParam> params = {\n                item.asset_id,\n                static_cast<int64_t>(color.r),\n                static_cast<int64_t>(color.g),\n                static_cast<int64_t>(color.b),\n                static_cast<double>(color.lab_l),\n                static_cast<double>(color.lab_a),\n                static_cast<double>(color.lab_b),\n                static_cast<double>(color.weight),\n                static_cast<int64_t>(color.l_bin),\n                static_cast<int64_t>(color.a_bin),\n                static_cast<int64_t>(color.b_bin),\n            };\n\n            auto insert_result = Core::Database::execute(db_state, kInsertSql, params);\n            if (!insert_result) {\n              return std::unexpected(\"Failed to insert asset color for asset_id \" +\n                                     std::to_string(item.asset_id) + \": \" + insert_result.error());\n            }\n          }\n        }\n\n        return {};\n      });\n}\n\nauto get_asset_main_colors(Core::State::AppState& app_state, std::int64_t asset_id)\n    -> std::expected<std::vector<Features::Gallery::Types::AssetMainColor>, std::string> {\n  if (asset_id <= 0) {\n    return std::unexpected(\"Invalid asset_id\");\n  }\n\n  static const std::string kQuerySql = R\"(\n    SELECT r, g, b, weight\n    FROM asset_colors\n    WHERE asset_id = ?\n    ORDER BY weight DESC, id ASC\n  )\";\n\n  auto result = Core::Database::query<Features::Gallery::Types::AssetMainColor>(\n      *app_state.database, kQuerySql, std::vector<Core::Database::Types::DbParam>{asset_id});\n  if (!result) {\n    return std::unexpected(\"Failed to query asset main colors: \" + result.error());\n  }\n\n  return result.value();\n}\n\n}  // namespace Features::Gallery::Color::Repository\n"
  },
  {
    "path": "src/features/gallery/color/repository.ixx",
    "content": "module;\n\nexport module Features.Gallery.Color.Repository;\n\nimport std;\nimport Core.State;\nimport Features.Gallery.Color.Types;\nimport Features.Gallery.Types;\n\nnamespace Features::Gallery::Color::Repository {\n\nexport struct ColorReplaceBatchItem {\n  std::int64_t asset_id = 0;\n  std::vector<Types::ExtractedColor> colors;\n};\n\nexport auto replace_asset_colors(Core::State::AppState& app_state, std::int64_t asset_id,\n                                 const std::vector<Types::ExtractedColor>& colors)\n    -> std::expected<void, std::string>;\n\nexport auto batch_replace_asset_colors(Core::State::AppState& app_state,\n                                       const std::vector<ColorReplaceBatchItem>& items)\n    -> std::expected<void, std::string>;\n\nexport auto get_asset_main_colors(Core::State::AppState& app_state, std::int64_t asset_id)\n    -> std::expected<std::vector<Features::Gallery::Types::AssetMainColor>, std::string>;\n\n}  // namespace Features::Gallery::Color::Repository\n"
  },
  {
    "path": "src/features/gallery/color/types.ixx",
    "content": "module;\n\nexport module Features.Gallery.Color.Types;\n\nimport std;\n\nnamespace Features::Gallery::Color::Types {\n\nexport struct LabColor {\n  float l = 0.0f;\n  float a = 0.0f;\n  float b = 0.0f;\n  int l_bin = 0;\n  int a_bin = 0;\n  int b_bin = 0;\n};\n\nexport struct ExtractedColor {\n  std::uint8_t r = 0;\n  std::uint8_t g = 0;\n  std::uint8_t b = 0;\n  float lab_l = 0.0f;\n  float lab_a = 0.0f;\n  float lab_b = 0.0f;\n  float weight = 0.0f;  // 0-1\n  int l_bin = 0;\n  int a_bin = 0;\n  int b_bin = 0;\n};\n\nexport struct MainColorExtractOptions {\n  std::uint32_t sample_short_edge = 128;\n  std::uint32_t max_samples = 8000;\n  std::uint32_t cluster_count = 8;\n  std::uint32_t min_colors = 3;\n  std::uint32_t max_colors = 8;\n  float min_weight = 0.03f;\n  float coverage = 0.85f;\n  float merge_delta_e = 8.0f;\n  float l_bin_size = 5.0f;\n  float ab_bin_size = 8.0f;\n};\n\n}  // namespace Features::Gallery::Color::Types\n"
  },
  {
    "path": "src/features/gallery/folder/repository.cpp",
    "content": "module;\n\nmodule Features.Gallery.Folder.Repository;\n\nimport std;\nimport Core.State;\nimport Core.Database;\nimport Core.Database.Types;\nimport Features.Gallery.Types;\nimport Utils.Logger;\nimport <rfl.hpp>;\n\nnamespace Features::Gallery::Folder::Repository {\n\nauto create_folder(Core::State::AppState& app_state, const Types::Folder& folder)\n    -> std::expected<std::int64_t, std::string> {\n  std::string sql = R\"(\n            INSERT INTO folders (\n                path, parent_id, name, display_name, \n                sort_order, is_hidden\n            ) VALUES (?, ?, ?, ?, ?, ?)\n        )\";\n\n  std::vector<Core::Database::Types::DbParam> params;\n  params.push_back(folder.path);\n\n  params.push_back(folder.parent_id.has_value()\n                       ? Core::Database::Types::DbParam{folder.parent_id.value()}\n                       : Core::Database::Types::DbParam{std::monostate{}});\n\n  params.push_back(folder.name);\n\n  params.push_back(folder.display_name.has_value()\n                       ? Core::Database::Types::DbParam{folder.display_name.value()}\n                       : Core::Database::Types::DbParam{std::monostate{}});\n\n  params.push_back(static_cast<int64_t>(folder.sort_order));\n  params.push_back(folder.is_hidden);\n\n  auto result = Core::Database::execute(*app_state.database, sql, params);\n  if (!result) {\n    return std::unexpected(\"Failed to insert folder: \" + result.error());\n  }\n\n  // 获取插入的 ID\n  auto id_result =\n      Core::Database::query_scalar<int64_t>(*app_state.database, \"SELECT last_insert_rowid()\");\n  if (!id_result) {\n    return std::unexpected(\"Failed to get inserted folder ID: \" + id_result.error());\n  }\n\n  return id_result->value_or(0);\n}\n\nauto get_folder_by_path(Core::State::AppState& app_state, const std::string& path)\n    -> std::expected<std::optional<Types::Folder>, std::string> {\n  std::string sql = R\"(\n            SELECT id, path, parent_id, name, display_name, \n                   cover_asset_id, sort_order, is_hidden,\n                   created_at, updated_at\n            FROM folders\n            WHERE path = ?\n        )\";\n\n  std::vector<Core::Database::Types::DbParam> params = {path};\n\n  auto result = Core::Database::query_single<Types::Folder>(*app_state.database, sql, params);\n  if (!result) {\n    return std::unexpected(\"Failed to query folder by path: \" + result.error());\n  }\n\n  return result.value();\n}\n\nauto get_folder_by_id(Core::State::AppState& app_state, std::int64_t id)\n    -> std::expected<std::optional<Types::Folder>, std::string> {\n  std::string sql = R\"(\n            SELECT id, path, parent_id, name, display_name, \n                   cover_asset_id, sort_order, is_hidden,\n                   created_at, updated_at\n            FROM folders\n            WHERE id = ?\n        )\";\n\n  std::vector<Core::Database::Types::DbParam> params = {id};\n\n  auto result = Core::Database::query_single<Types::Folder>(*app_state.database, sql, params);\n  if (!result) {\n    return std::unexpected(\"Failed to query folder by id: \" + result.error());\n  }\n\n  return result.value();\n}\n\nauto update_folder(Core::State::AppState& app_state, const Types::Folder& folder)\n    -> std::expected<void, std::string> {\n  std::string sql = R\"(\n            UPDATE folders SET\n                path = ?, parent_id = ?, name = ?, display_name = ?,\n                cover_asset_id = ?, sort_order = ?, is_hidden = ?\n            WHERE id = ?\n        )\";\n\n  std::vector<Core::Database::Types::DbParam> params;\n  params.push_back(folder.path);\n\n  params.push_back(folder.parent_id.has_value()\n                       ? Core::Database::Types::DbParam{folder.parent_id.value()}\n                       : Core::Database::Types::DbParam{std::monostate{}});\n\n  params.push_back(folder.name);\n\n  params.push_back(folder.display_name.has_value()\n                       ? Core::Database::Types::DbParam{folder.display_name.value()}\n                       : Core::Database::Types::DbParam{std::monostate{}});\n\n  params.push_back(folder.cover_asset_id.has_value()\n                       ? Core::Database::Types::DbParam{folder.cover_asset_id.value()}\n                       : Core::Database::Types::DbParam{std::monostate{}});\n\n  params.push_back(static_cast<int64_t>(folder.sort_order));\n  params.push_back(folder.is_hidden);\n  params.push_back(folder.id);\n\n  auto result = Core::Database::execute(*app_state.database, sql, params);\n  if (!result) {\n    return std::unexpected(\"Failed to update folder: \" + result.error());\n  }\n\n  return {};\n}\n\nauto delete_folder(Core::State::AppState& app_state, std::int64_t id)\n    -> std::expected<void, std::string> {\n  // 暂时实现硬删除，实际项目中可能需要考虑级联删除等问题\n  std::string sql = \"DELETE FROM folders WHERE id = ?\";\n  std::vector<Core::Database::Types::DbParam> params = {id};\n\n  auto result = Core::Database::execute(*app_state.database, sql, params);\n  if (!result) {\n    return std::unexpected(\"Failed to delete folder: \" + result.error());\n  }\n\n  return {};\n}\n\nauto list_all_folders(Core::State::AppState& app_state)\n    -> std::expected<std::vector<Types::Folder>, std::string> {\n  std::string sql = R\"(\n            SELECT id, path, parent_id, name, display_name, \n                   cover_asset_id, sort_order, is_hidden,\n                   created_at, updated_at\n            FROM folders\n            ORDER BY path\n        )\";\n\n  auto result = Core::Database::query<Types::Folder>(*app_state.database, sql);\n  if (!result) {\n    return std::unexpected(\"Failed to list all folders: \" + result.error());\n  }\n\n  return result.value();\n}\n\nauto get_child_folders(Core::State::AppState& app_state, std::optional<std::int64_t> parent_id)\n    -> std::expected<std::vector<Types::Folder>, std::string> {\n  std::string sql;\n  std::vector<Core::Database::Types::DbParam> params;\n\n  if (parent_id.has_value()) {\n    sql = R\"(\n            SELECT id, path, parent_id, name, display_name, \n                   cover_asset_id, sort_order, is_hidden,\n                   created_at, updated_at\n            FROM folders\n            WHERE parent_id = ?\n            ORDER BY sort_order, name\n        )\";\n    params.push_back(parent_id.value());\n  } else {\n    // 获取根文件夹（parent_id 为 NULL）\n    sql = R\"(\n            SELECT id, path, parent_id, name, display_name, \n                   cover_asset_id, sort_order, is_hidden,\n                   created_at, updated_at\n            FROM folders\n            WHERE parent_id IS NULL\n            ORDER BY sort_order, name\n        )\";\n  }\n\n  auto result = Core::Database::query<Types::Folder>(*app_state.database, sql, params);\n  if (!result) {\n    return std::unexpected(\"Failed to get child folders: \" + result.error());\n  }\n\n  return result.value();\n}\n\nauto get_folder_tree(Core::State::AppState& app_state)\n    -> std::expected<std::vector<Types::FolderTreeNode>, std::string> {\n  // 1. 获取所有文件夹\n  auto folders_result = list_all_folders(app_state);\n  if (!folders_result) {\n    return std::unexpected(\"Failed to get all folders: \" + folders_result.error());\n  }\n\n  const auto& folders = folders_result.value();\n\n  // 2. 查询每个文件夹的直接 assets 数量\n  std::unordered_map<std::int64_t, std::int64_t> direct_asset_counts;\n  std::string count_sql = R\"(\n            SELECT folder_id, COUNT(*) as count\n            FROM assets\n            WHERE folder_id IS NOT NULL\n            GROUP BY folder_id\n        )\";\n\n  // 定义用于接收统计结果的结构\n  struct FolderAssetCount {\n    std::int64_t folder_id;\n    std::int64_t count;\n  };\n\n  auto count_result = Core::Database::query<FolderAssetCount>(*app_state.database, count_sql);\n  if (!count_result) {\n    return std::unexpected(\"Failed to query asset counts: \" + count_result.error());\n  }\n\n  // 填充直接 assets 数量映射\n  for (const auto& item : count_result.value()) {\n    direct_asset_counts[item.folder_id] = item.count;\n  }\n\n  // 2. 创建 id -> FolderTreeNode 的映射，用于快速查找\n  std::unordered_map<std::int64_t, Types::FolderTreeNode> node_map;\n\n  // 第一次遍历：创建所有节点\n  for (const auto& folder : folders) {\n    Types::FolderTreeNode node{.id = folder.id,\n                               .path = folder.path,\n                               .parent_id = folder.parent_id,\n                               .name = folder.name,\n                               .display_name = folder.display_name,\n                               .cover_asset_id = folder.cover_asset_id,\n                               .sort_order = folder.sort_order,\n                               .is_hidden = folder.is_hidden,\n                               .created_at = folder.created_at,\n                               .updated_at = folder.updated_at,\n                               .children = {}};\n\n    node_map[folder.id] = std::move(node);\n  }\n\n  // 3. 第二次遍历：构建父子关系（收集子节点ID）\n  std::unordered_map<std::int64_t, std::vector<std::int64_t>> parent_to_children;\n  std::vector<std::int64_t> root_ids;\n\n  for (const auto& folder : folders) {\n    if (folder.parent_id.has_value()) {\n      // 有父节点，记录到父节点的子节点列表中\n      parent_to_children[folder.parent_id.value()].push_back(folder.id);\n    } else {\n      // 没有父节点，是根节点\n      root_ids.push_back(folder.id);\n    }\n  }\n\n  // 4. 递归构建树结构\n  std::function<Types::FolderTreeNode(std::int64_t)> build_tree;\n  build_tree = [&](std::int64_t folder_id) -> Types::FolderTreeNode {\n    auto node_it = node_map.find(folder_id);\n    if (node_it == node_map.end()) {\n      Logger().error(\"Folder {} not found in node_map\", folder_id);\n      return Types::FolderTreeNode{};\n    }\n\n    Types::FolderTreeNode node = std::move(node_it->second);\n\n    // 递归构建子节点\n    auto children_it = parent_to_children.find(folder_id);\n    if (children_it != parent_to_children.end()) {\n      for (std::int64_t child_id : children_it->second) {\n        node.children.push_back(build_tree(child_id));\n      }\n    }\n\n    return node;\n  };\n\n  // 5. 构建所有根节点\n  std::vector<Types::FolderTreeNode> root_nodes;\n  for (std::int64_t root_id : root_ids) {\n    root_nodes.push_back(build_tree(root_id));\n  }\n\n  // 6. 对根节点按 sort_order 和 name 排序\n  std::sort(root_nodes.begin(), root_nodes.end(),\n            [](const Types::FolderTreeNode& a, const Types::FolderTreeNode& b) {\n              if (a.sort_order != b.sort_order) {\n                return a.sort_order < b.sort_order;\n              }\n              return a.name < b.name;\n            });\n\n  // 递归排序所有子节点\n  std::function<void(Types::FolderTreeNode&)> sort_children;\n  sort_children = [&](Types::FolderTreeNode& node) {\n    std::sort(node.children.begin(), node.children.end(),\n              [](const Types::FolderTreeNode& a, const Types::FolderTreeNode& b) {\n                if (a.sort_order != b.sort_order) {\n                  return a.sort_order < b.sort_order;\n                }\n                return a.name < b.name;\n              });\n\n    for (auto& child : node.children) {\n      sort_children(child);\n    }\n  };\n\n  for (auto& root : root_nodes) {\n    sort_children(root);\n  }\n\n  // 7. 递归计算每个文件夹的 asset_count（包含所有子文件夹）\n  std::function<std::int64_t(Types::FolderTreeNode&)> calculate_total_assets;\n  calculate_total_assets = [&](Types::FolderTreeNode& node) -> std::int64_t {\n    // 当前文件夹的直接 assets 数量\n    std::int64_t total = 0;\n    auto it = direct_asset_counts.find(node.id);\n    if (it != direct_asset_counts.end()) {\n      total = it->second;\n    }\n\n    // 递归累加所有子文件夹的 assets\n    for (auto& child : node.children) {\n      total += calculate_total_assets(child);\n    }\n\n    // 设置节点的 asset_count\n    node.asset_count = total;\n    return total;\n  };\n\n  // 对所有根节点执行计算\n  for (auto& root : root_nodes) {\n    calculate_total_assets(root);\n  }\n\n  return root_nodes;\n}\n\n}  // namespace Features::Gallery::Folder::Repository\n"
  },
  {
    "path": "src/features/gallery/folder/repository.ixx",
    "content": "module;\n\nexport module Features.Gallery.Folder.Repository;\n\nimport std;\nimport Core.State;\nimport Features.Gallery.Types;\n\nnamespace Features::Gallery::Folder::Repository {\n\nexport auto create_folder(Core::State::AppState& app_state, const Types::Folder& folder)\n    -> std::expected<std::int64_t, std::string>;\n\nexport auto get_folder_by_path(Core::State::AppState& app_state, const std::string& path)\n    -> std::expected<std::optional<Types::Folder>, std::string>;\n\nexport auto get_folder_by_id(Core::State::AppState& app_state, std::int64_t id)\n    -> std::expected<std::optional<Types::Folder>, std::string>;\n\nexport auto update_folder(Core::State::AppState& app_state, const Types::Folder& folder)\n    -> std::expected<void, std::string>;\n\nexport auto delete_folder(Core::State::AppState& app_state, std::int64_t id)\n    -> std::expected<void, std::string>;\n\nexport auto list_all_folders(Core::State::AppState& app_state)\n    -> std::expected<std::vector<Types::Folder>, std::string>;\n\nexport auto get_child_folders(Core::State::AppState& app_state,\n                              std::optional<std::int64_t> parent_id)\n    -> std::expected<std::vector<Types::Folder>, std::string>;\n\nexport auto get_folder_tree(Core::State::AppState& app_state)\n    -> std::expected<std::vector<Types::FolderTreeNode>, std::string>;\n\n}  // namespace Features::Gallery::Folder::Repository\n"
  },
  {
    "path": "src/features/gallery/folder/service.cpp",
    "content": "module;\n\nmodule Features.Gallery.Folder.Service;\n\nimport std;\nimport Core.WebView;\nimport Core.WebView.State;\nimport Core.State;\nimport Core.Database;\nimport Features.Gallery.Types;\nimport Features.Gallery.Folder.Repository;\nimport Features.Gallery.OriginalLocator;\nimport Features.Gallery.Watcher;\nimport Features.Gallery.Asset.Thumbnail;\nimport Utils.Logger;\nimport Utils.Path;\nimport Utils.String;\nimport Utils.System;\n\nnamespace Features::Gallery::Folder::Service {\n\n// 确保根文件夹的 WebView 原图 host mappings 就绪。\nauto ensure_root_folder_webview_mapping(Core::State::AppState& app_state,\n                                        const Types::Folder& folder) -> void {\n  if (folder.parent_id.has_value()) {\n    return;\n  }\n\n  auto host_name = Features::Gallery::OriginalLocator::make_root_host_name(folder.id);\n  Core::WebView::register_virtual_host_folder_mapping(\n      app_state, std::move(host_name), Utils::String::FromUtf8(folder.path),\n      Core::WebView::State::VirtualHostResourceAccessKind::deny_cors);\n}\n\n// 移除根文件夹的 WebView 原图 host mappings。\nauto remove_root_folder_webview_mapping(Core::State::AppState& app_state,\n                                        const Types::Folder& folder) -> void {\n  if (folder.parent_id.has_value()) {\n    return;\n  }\n\n  auto host_name = Features::Gallery::OriginalLocator::make_root_host_name(folder.id);\n  Core::WebView::unregister_virtual_host_folder_mapping(app_state, host_name);\n}\n\n// ============= 路径处理辅助函数 =============\n\nauto extract_unique_folder_paths(const std::vector<std::filesystem::path>& file_paths,\n                                 const std::filesystem::path& scan_root)\n    -> std::vector<std::filesystem::path> {\n  std::unordered_set<std::string> unique_paths;\n  std::vector<std::filesystem::path> result;\n\n  // 规范化扫描根目录\n  auto normalized_scan_root_result = Utils::Path::NormalizePath(scan_root);\n  if (!normalized_scan_root_result) {\n    Logger().error(\"Failed to normalize scan root path '{}': {}\", scan_root.string(),\n                   normalized_scan_root_result.error());\n    return result;\n  }\n  auto normalized_scan_root = normalized_scan_root_result.value();\n  std::string scan_root_str = normalized_scan_root.string();\n\n  for (const auto& file_path : file_paths) {\n    auto current_path = file_path.parent_path();\n\n    // 从文件的父目录开始，递归向上直到扫描根目录\n    while (!current_path.empty()) {\n      auto normalized_result = Utils::Path::NormalizePath(current_path);\n      if (!normalized_result) {\n        Logger().warn(\"Failed to normalize path '{}': {}\", current_path.string(),\n                      normalized_result.error());\n        break;\n      }\n\n      auto normalized = normalized_result.value();\n      std::string path_str = normalized.string();\n\n      // 如果已经到达或超出扫描根目录，停止\n      if (path_str == scan_root_str) {\n        break;\n      }\n\n      // 检查是否是扫描根目录的子路径\n      if (path_str.size() < scan_root_str.size() ||\n          path_str.substr(0, scan_root_str.size()) != scan_root_str) {\n        // 已经超出扫描根目录范围，停止\n        break;\n      }\n\n      // 添加到结果集\n      if (unique_paths.insert(path_str).second) {\n        result.push_back(normalized);\n      }\n\n      // 继续向上遍历\n      current_path = current_path.parent_path();\n    }\n  }\n\n  // 确保根目录本身也在结果中，以便子文件夹能找到父ID\n  if (unique_paths.insert(scan_root_str).second) {\n    result.push_back(normalized_scan_root);\n  }\n\n  return result;\n}\n\nauto build_folder_hierarchy(const std::vector<std::filesystem::path>& paths)\n    -> std::vector<Types::FolderHierarchy> {\n  std::vector<Types::FolderHierarchy> result;\n  result.reserve(paths.size());\n\n  for (const auto& path : paths) {\n    Types::FolderHierarchy hierarchy;\n    hierarchy.path = path.string();\n    hierarchy.name = path.filename().string();\n\n    // 计算父路径\n    auto parent = path.parent_path();\n    if (!parent.empty() && parent != path.root_path()) {\n      hierarchy.parent_path = parent.string();\n    }\n\n    // 计算嵌套层级（简单实现：统计路径分隔符数量）\n    std::string path_str = path.string();\n    hierarchy.level =\n        static_cast<int>(std::ranges::count(path_str, std::filesystem::path::preferred_separator));\n\n    result.push_back(std::move(hierarchy));\n  }\n\n  return result;\n}\n\n// 批量创建文件夹记录（统一的文件夹创建接口）\nauto batch_create_folders_for_paths(Core::State::AppState& app_state,\n                                    const std::vector<std::filesystem::path>& folder_paths)\n    -> std::expected<std::unordered_map<std::string, std::int64_t>, std::string> {\n  std::unordered_map<std::string, std::int64_t> path_to_id_map;\n\n  std::vector<std::filesystem::path> normalized_paths;\n  normalized_paths.reserve(folder_paths.size());\n  std::unordered_set<std::string> normalized_path_keys;\n\n  for (const auto& folder_path : folder_paths) {\n    auto normalized_result = Utils::Path::NormalizePath(folder_path);\n    if (!normalized_result) {\n      Logger().warn(\"Failed to normalize folder path '{}': {}\", folder_path.string(),\n                    normalized_result.error());\n      continue;\n    }\n\n    auto normalized_path = normalized_result.value();\n    auto path_key = normalized_path.string();\n    if (normalized_path_keys.insert(path_key).second) {\n      normalized_paths.push_back(std::move(normalized_path));\n    }\n  }\n\n  // 按路径深度排序，确保父目录先创建\n  auto sorted_paths = normalized_paths;\n  std::ranges::sort(sorted_paths, [](const auto& a, const auto& b) {\n    // 按路径字符串长度排序（通常深度较浅的路径更短）\n    auto a_str = a.string();\n    auto b_str = b.string();\n    if (a_str.length() != b_str.length()) {\n      return a_str.length() < b_str.length();\n    }\n    return a_str < b_str;  // 相同长度时按字典序\n  });\n\n  for (const auto& folder_path : sorted_paths) {\n    auto path_str = folder_path.string();\n\n    // 如果已经处理过，跳过\n    if (path_to_id_map.contains(path_str)) {\n      continue;\n    }\n\n    // 首先检查数据库中是否已存在\n    auto existing_folder_result = Repository::get_folder_by_path(app_state, path_str);\n    if (!existing_folder_result) {\n      Logger().error(\"Failed to query folder for path '{}': {}\", path_str,\n                     existing_folder_result.error());\n      continue;\n    }\n\n    if (existing_folder_result->has_value()) {\n      // 文件夹已存在，直接使用\n      auto folder = existing_folder_result->value();\n      auto folder_id = folder.id;\n      path_to_id_map[path_str] = folder_id;\n      ensure_root_folder_webview_mapping(app_state, folder);\n      Logger().debug(\"Found existing folder '{}' with ID {}\", path_str, folder_id);\n      continue;\n    }\n\n    // 文件夹不存在，需要创建\n    // 计算父路径和 parent_id\n    std::optional<std::int64_t> parent_id;\n    auto parent_path = folder_path.parent_path();\n\n    if (!parent_path.empty() && parent_path != folder_path.root_path()) {\n      std::string parent_path_str = parent_path.string();\n\n      // 由于已经按深度排序，父目录的 ID 应该已经在 map 中\n      if (auto it = path_to_id_map.find(parent_path_str); it != path_to_id_map.end()) {\n        parent_id = it->second;\n      } else {\n        Logger().info(\"Parent folder '{}' not found in map for child '{}'\", parent_path_str,\n                      path_str);\n      }\n    }\n\n    // 创建新文件夹\n    std::string folder_name = folder_path.filename().string();\n    Types::Folder new_folder{.path = path_str, .parent_id = parent_id, .name = folder_name};\n\n    auto create_result = Repository::create_folder(app_state, new_folder);\n    if (!create_result) {\n      Logger().error(\"Failed to create folder for path '{}': {}\", path_str, create_result.error());\n      continue;\n    }\n\n    auto folder_id = create_result.value();\n    path_to_id_map[path_str] = folder_id;\n    ensure_root_folder_webview_mapping(\n        app_state,\n        Types::Folder{\n            .id = folder_id, .path = path_str, .parent_id = parent_id, .name = folder_name});\n    Logger().debug(\"Created folder '{}' with ID {} (parent_id: {})\", path_str, folder_id,\n                   parent_id.has_value() ? std::to_string(parent_id.value()) : \"NULL\");\n  }\n\n  return path_to_id_map;\n}\n\n// 根据数据库里的根文件夹记录，确保 WebView 原图 host mappings 全部就绪。\nauto ensure_all_root_folder_webview_mappings(Core::State::AppState& app_state)\n    -> std::expected<void, std::string> {\n  auto folders_result = Repository::list_all_folders(app_state);\n  if (!folders_result) {\n    return std::unexpected(\"Failed to list folders for WebView mapping sync: \" +\n                           folders_result.error());\n  }\n\n  for (const auto& folder : folders_result.value()) {\n    ensure_root_folder_webview_mapping(app_state, folder);\n  }\n\n  return {};\n}\n\n// 规范化文件夹显示名称（去除首尾空白字符，若为空则返回 nullopt）\nauto normalize_display_name(const std::optional<std::string>& display_name)\n    -> std::optional<std::string> {\n  if (!display_name.has_value()) {\n    return std::nullopt;\n  }\n\n  auto trimmed = Utils::String::TrimAscii(display_name.value());\n  if (trimmed.empty()) {\n    return std::nullopt;\n  }\n\n  return trimmed;\n}\n\n// 清除指定根目录对应的数据索引（在数据库事务中级联删除其涵盖的所有资产与子文件夹记录）\nauto cleanup_root_folder_index(Core::State::AppState& app_state, std::int64_t root_folder_id,\n                               const std::string& root_path)\n    -> std::expected<std::int64_t, std::string> {\n  return Core::Database::execute_transaction(\n      *app_state.database, [&](auto& db_state) -> std::expected<std::int64_t, std::string> {\n        // 1. 基于路径匹配，删除该目录下及所有子目录内的资产记录\n        auto delete_assets_result =\n            Core::Database::execute(db_state, \"DELETE FROM assets WHERE path = ? OR path LIKE ?\",\n                                    {root_path, root_path + \"/%\"});\n        if (!delete_assets_result) {\n          return std::unexpected(\"Failed to delete assets under root path: \" +\n                                 delete_assets_result.error());\n        }\n\n        auto deleted_assets_result =\n            Core::Database::query_scalar<std::int64_t>(db_state, \"SELECT changes()\");\n        if (!deleted_assets_result) {\n          return std::unexpected(\"Failed to query deleted assets count: \" +\n                                 deleted_assets_result.error());\n        }\n        auto deleted_assets = deleted_assets_result->value_or(0);\n\n        // 2. 使用递归 CTE 查找并删除该文件夹及其所有嵌套级别的子文件夹记录\n        std::string delete_folders_sql = R\"(\n          DELETE FROM folders\n          WHERE id IN (\n            WITH RECURSIVE folder_tree(id) AS (\n              SELECT id FROM folders WHERE id = ?\n              UNION ALL\n              SELECT f.id FROM folders f\n              INNER JOIN folder_tree t ON f.parent_id = t.id\n            )\n            SELECT id FROM folder_tree\n          )\n        )\";\n\n        auto delete_folders_result =\n            Core::Database::execute(db_state, delete_folders_sql, {root_folder_id});\n        if (!delete_folders_result) {\n          return std::unexpected(\"Failed to delete folders under root: \" +\n                                 delete_folders_result.error());\n        }\n\n        auto deleted_folders_result =\n            Core::Database::query_scalar<std::int64_t>(db_state, \"SELECT changes()\");\n        if (!deleted_folders_result) {\n          return std::unexpected(\"Failed to query deleted folders count: \" +\n                                 deleted_folders_result.error());\n        }\n        auto deleted_folders = deleted_folders_result->value_or(0);\n\n        return deleted_assets + deleted_folders;\n      });\n}\n\n// 更新文件夹的自定义显示名称（允许清空为 nullopt）\nauto update_folder_display_name(Core::State::AppState& app_state, std::int64_t folder_id,\n                                const std::optional<std::string>& display_name)\n    -> std::expected<Types::OperationResult, std::string> {\n  auto folder_result = Repository::get_folder_by_id(app_state, folder_id);\n  if (!folder_result) {\n    return std::unexpected(\"Failed to query folder: \" + folder_result.error());\n  }\n  if (!folder_result->has_value()) {\n    return std::unexpected(\"Folder not found: \" + std::to_string(folder_id));\n  }\n\n  auto folder = folder_result->value();\n  folder.display_name = normalize_display_name(display_name);\n\n  auto update_result = Repository::update_folder(app_state, folder);\n  if (!update_result) {\n    return std::unexpected(\"Failed to update folder: \" + update_result.error());\n  }\n\n  return Types::OperationResult{\n      .success = true,\n      .message = folder.display_name.has_value() ? \"Folder display name updated\"\n                                                 : \"Folder display name reset\",\n      .affected_count = 1,\n  };\n}\n\n// 在系统资源管理器中打开指定 ID 的文件夹路径\nauto open_folder_in_explorer(Core::State::AppState& app_state, std::int64_t folder_id)\n    -> std::expected<Types::OperationResult, std::string> {\n  auto folder_result = Repository::get_folder_by_id(app_state, folder_id);\n  if (!folder_result) {\n    return std::unexpected(\"Failed to query folder: \" + folder_result.error());\n  }\n  if (!folder_result->has_value()) {\n    return std::unexpected(\"Folder not found: \" + std::to_string(folder_id));\n  }\n\n  auto open_result =\n      Utils::System::open_directory(std::filesystem::path(folder_result->value().path));\n  if (!open_result) {\n    return std::unexpected(\"Failed to open folder in explorer: \" + open_result.error());\n  }\n\n  return Types::OperationResult{\n      .success = true,\n      .message = \"Folder opened in explorer\",\n      .affected_count = 0,\n  };\n}\n\n// 取消对指定根文件夹的监控跟踪，清理相关的索引与孤立缩略图缓存\nauto remove_root_folder_watch(Core::State::AppState& app_state, std::int64_t folder_id)\n    -> std::expected<Types::OperationResult, std::string> {\n  auto folder_result = Repository::get_folder_by_id(app_state, folder_id);\n  if (!folder_result) {\n    return std::unexpected(\"Failed to query folder: \" + folder_result.error());\n  }\n  if (!folder_result->has_value()) {\n    return std::unexpected(\"Folder not found: \" + std::to_string(folder_id));\n  }\n\n  const auto& folder = folder_result->value();\n  // 仅允许直接取消对根监控文件夹的监听\n  if (folder.parent_id.has_value()) {\n    return std::unexpected(\"Only root folders can be removed from watch list\");\n  }\n\n  // 1. 从系统的文件变动监控器中注销该目录\n  auto remove_watcher_result = Features::Gallery::Watcher::remove_watcher_for_directory(\n      app_state, std::filesystem::path(folder.path));\n  if (!remove_watcher_result) {\n    return std::unexpected(\"Failed to remove watcher: \" + remove_watcher_result.error());\n  }\n\n  // 2. 清空该根目录在数据库内的所有文件及子文件夹索引数据\n  auto cleanup_result = cleanup_root_folder_index(app_state, folder.id, folder.path);\n  if (!cleanup_result) {\n    return std::unexpected(\"Failed to cleanup folder index: \" + cleanup_result.error());\n  }\n\n  remove_root_folder_webview_mapping(app_state, folder);\n\n  // 3. 触发清理因本次操作而处于孤立状态的缩略图缓存\n  auto thumbnail_cleanup_result =\n      Features::Gallery::Asset::Thumbnail::cleanup_orphaned_thumbnails(app_state);\n  if (!thumbnail_cleanup_result) {\n    Logger().warn(\"Failed to cleanup orphaned thumbnails after removing watch: {}\",\n                  thumbnail_cleanup_result.error());\n  }\n\n  return Types::OperationResult{\n      .success = true,\n      .message = \"Folder removed from watch list and index cleaned\",\n      .affected_count = cleanup_result.value(),\n  };\n}\n\n}  // namespace Features::Gallery::Folder::Service\n"
  },
  {
    "path": "src/features/gallery/folder/service.ixx",
    "content": "module;\n\nexport module Features.Gallery.Folder.Service;\n\nimport std;\nimport Core.State;\nimport Features.Gallery.Types;\n\nnamespace Features::Gallery::Folder::Service {\n\n// 构建文件夹层次结构信息\nexport auto build_folder_hierarchy(const std::vector<std::filesystem::path>& paths)\n    -> std::vector<Types::FolderHierarchy>;\n\n// 从路径集合中提取所有唯一的文件夹路径（包含所有祖先目录直到扫描根目录）\nexport auto extract_unique_folder_paths(const std::vector<std::filesystem::path>& file_paths,\n                                        const std::filesystem::path& scan_root)\n    -> std::vector<std::filesystem::path>;\n\n// 批量创建文件夹记录（统一的文件夹创建接口）\nexport auto batch_create_folders_for_paths(Core::State::AppState& app_state,\n                                           const std::vector<std::filesystem::path>& folder_paths)\n    -> std::expected<std::unordered_map<std::string, std::int64_t>, std::string>;\n\n// 根据数据库里的根文件夹记录，确保 WebView 原图 host mappings 全部就绪。\nexport auto ensure_all_root_folder_webview_mappings(Core::State::AppState& app_state)\n    -> std::expected<void, std::string>;\n\n// 更新文件夹显示名称（仅应用内展示）。\nexport auto update_folder_display_name(Core::State::AppState& app_state, std::int64_t folder_id,\n                                       const std::optional<std::string>& display_name)\n    -> std::expected<Types::OperationResult, std::string>;\n\n// 在系统资源管理器中打开文件夹。\nexport auto open_folder_in_explorer(Core::State::AppState& app_state, std::int64_t folder_id)\n    -> std::expected<Types::OperationResult, std::string>;\n\n// 移除根文件夹监听并清理对应索引（包含子文件夹）。\nexport auto remove_root_folder_watch(Core::State::AppState& app_state, std::int64_t folder_id)\n    -> std::expected<Types::OperationResult, std::string>;\n\n}  // namespace Features::Gallery::Folder::Service\n"
  },
  {
    "path": "src/features/gallery/gallery.cpp",
    "content": "module;\n\n#include <mfapi.h>\n#include <asio.hpp>\n\nmodule Features.Gallery;\n\nimport std;\nimport Core.Async;\nimport Core.RPC.NotificationHub;\nimport Core.State;\nimport Features.Gallery.State;\nimport Features.Gallery.Types;\nimport Features.Gallery.Asset.Repository;\nimport Features.Gallery.Asset.Service;\nimport Features.Gallery.Scanner;\nimport Features.Gallery.ScanCommon;\nimport Features.Gallery.Asset.Thumbnail;\nimport Features.Gallery.Folder.Repository;\nimport Features.Gallery.Folder.Service;\nimport Features.Gallery.Ignore.Service;\nimport Features.Gallery.StaticResolver;\nimport Features.Gallery.Watcher;\nimport Utils.File;\nimport Utils.Image;\nimport Utils.Logger;\nimport Utils.LRUCache;\nimport Utils.Path;\nimport Utils.System;\n\nnamespace Features::Gallery {\n\nauto make_bootstrap_scan_options(const std::filesystem::path& directory) -> Types::ScanOptions {\n  Types::ScanOptions options;\n  options.directory = directory.string();\n  options.supported_extensions = ScanCommon::default_supported_extensions();\n  return options;\n}\n\nauto ensure_output_directory_media_source(Core::State::AppState& app_state,\n                                          const std::string& output_dir_path) -> void {\n  if (!app_state.async) {\n    Logger().warn(\"Skip output-directory gallery sync: async state is not ready\");\n    return;\n  }\n\n  auto* io_context = Core::Async::get_io_context(*app_state.async);\n  if (!io_context) {\n    Logger().warn(\"Skip output-directory gallery sync: async runtime is not available\");\n    return;\n  }\n\n  auto output_dir_path_snapshot = output_dir_path;\n  asio::co_spawn(\n      *io_context,\n      [&app_state, output_dir_path_snapshot]() -> asio::awaitable<void> {\n        co_await asio::post(asio::use_awaitable);\n\n        auto output_dir_result = Utils::Path::GetOutputDirectory(output_dir_path_snapshot);\n        if (!output_dir_result) {\n          Logger().warn(\"Failed to resolve output directory for gallery sync: {}\",\n                        output_dir_result.error());\n        } else {\n          auto scan_result =\n              scan_directory(app_state, make_bootstrap_scan_options(output_dir_result.value()));\n          if (!scan_result) {\n            Logger().warn(\"Failed to scan output directory for gallery sync '{}': {}\",\n                          output_dir_result->string(), scan_result.error());\n          } else {\n            Logger().info(\"Output directory added to gallery sources: {}\",\n                          output_dir_result->string());\n            Core::RPC::NotificationHub::send_notification(app_state, \"gallery.changed\");\n          }\n        }\n      },\n      asio::detached);\n}\n\n// ============= 初始化和清理 =============\n\nauto initialize(Core::State::AppState& app_state) -> std::expected<void, std::string> {\n  try {\n    Logger().info(\"Initializing gallery module...\");\n\n    // 供 Utils::Media::VideoAsset（SourceReader）使用；须在任意 analyze 之前成功。\n    if (FAILED(MFStartup(MF_VERSION))) {\n      return std::unexpected(\"Failed to initialize Media Foundation for gallery\");\n    }\n\n    // 确保缩略图目录存在\n    auto ensure_dir_result = Asset::Thumbnail::ensure_thumbnails_directory_exists(app_state);\n    if (!ensure_dir_result) {\n      Logger().error(\"Failed to ensure thumbnails directory exists: {}\", ensure_dir_result.error());\n      return std::unexpected(\"Failed to ensure thumbnails directory exists: \" +\n                             ensure_dir_result.error());\n    }\n\n    // 注册静态服务解析器\n    StaticResolver::register_http_resolvers(app_state);\n    StaticResolver::register_webview_resolvers(app_state);\n\n    // 根据数据库里的根文件夹记录，确保 WebView 原图 host mappings 全部就绪。\n    if (auto mapping_result = Folder::Service::ensure_all_root_folder_webview_mappings(app_state);\n        !mapping_result) {\n      return std::unexpected(\"Failed to sync gallery root WebView mappings: \" +\n                             mapping_result.error());\n    }\n\n    Logger().info(\"Gallery module initialized successfully\");\n    Logger().info(\"Thumbnail directory set to: {}\",\n                  app_state.gallery->thumbnails_directory.string());\n    return {};\n\n  } catch (const std::exception& e) {\n    return std::unexpected(\"Exception during asset module initialization: \" +\n                           std::string(e.what()));\n  }\n}\n\nauto cleanup(Core::State::AppState& app_state) -> void {\n  try {\n    Logger().info(\"Cleaning up gallery module resources...\");\n\n    // 注销静态服务解析器\n    StaticResolver::unregister_all_resolvers(app_state);\n\n    // 重置缩略图路径状态\n    app_state.gallery->thumbnails_directory.clear();\n\n    // 与 initialize 中 MFStartup 成对；此后不应再调用视频分析。\n    MFShutdown();\n\n    Logger().info(\"Gallery module cleanup completed\");\n  } catch (const std::exception& e) {\n    Logger().error(\"Exception during asset module cleanup: {}\", e.what());\n  }\n}\n\n// ============= 资产项管理 =============\n\nauto delete_asset(Core::State::AppState& app_state, const Types::DeleteParams& params)\n    -> std::expected<Types::OperationResult, std::string> {\n  try {\n    // 获取要删除的资产项\n    auto asset_result = Asset::Repository::get_asset_by_id(app_state, params.id);\n    if (!asset_result) {\n      return std::unexpected(\"Failed to get asset item: \" + asset_result.error());\n    }\n\n    if (!asset_result->has_value()) {\n      Types::OperationResult result;\n      result.success = false;\n      result.message = \"Asset item not found\";\n      result.affected_count = 0;\n      return result;\n    }\n\n    auto asset = asset_result->value();\n\n    // 删除缩略图\n    auto delete_thumbnail_result = Asset::Thumbnail::delete_thumbnail(app_state, asset);\n    if (!delete_thumbnail_result) {\n      Logger().warn(\"Failed to delete thumbnail for asset item {}: {}\", params.id,\n                    delete_thumbnail_result.error());\n    }\n\n    // 删除物理文件（如果请求）\n    if (params.delete_file.value_or(false)) {\n      std::filesystem::path file_path(asset.path);\n      if (std::filesystem::exists(file_path)) {\n        std::error_code ec;\n        std::filesystem::remove(file_path, ec);\n        if (ec) {\n          Logger().warn(\"Failed to delete physical file {}: {}\", asset.path, ec.message());\n        } else {\n          Logger().info(\"Deleted physical file: {}\", asset.path);\n        }\n      }\n    }\n\n    // 从数据库删除\n    auto delete_result = Asset::Repository::delete_asset(app_state, params.id);\n\n    Types::OperationResult result;\n    if (delete_result) {\n      result.success = true;\n      result.message = params.delete_file.value_or(false)\n                           ? \"Asset item and file deleted successfully\"\n                           : \"Asset item removed from library successfully\";\n      result.affected_count = 1;\n    } else {\n      result.success = false;\n      result.message = \"Failed to delete asset item: \" + delete_result.error();\n      result.affected_count = 0;\n    }\n\n    return result;\n\n  } catch (const std::exception& e) {\n    return std::unexpected(\"Exception in delete_asset: \" + std::string(e.what()));\n  }\n}\n\nauto open_asset_with_default_app(Core::State::AppState& app_state, std::int64_t id)\n    -> std::expected<Types::OperationResult, std::string> {\n  auto asset_result = Asset::Repository::get_asset_by_id(app_state, id);\n  if (!asset_result) {\n    return std::unexpected(\"Failed to get asset item: \" + asset_result.error());\n  }\n\n  if (!asset_result->has_value()) {\n    return Types::OperationResult{\n        .success = false,\n        .message = \"Asset item not found\",\n        .affected_count = 0,\n    };\n  }\n\n  auto open_result =\n      Utils::System::open_file_with_default_app(std::filesystem::path(asset_result->value().path));\n  if (!open_result) {\n    return std::unexpected(\"Failed to open asset with default app: \" + open_result.error());\n  }\n\n  return Types::OperationResult{\n      .success = true,\n      .message = \"Asset opened with default app\",\n      .affected_count = 0,\n  };\n}\n\nauto reveal_asset_in_explorer(Core::State::AppState& app_state, std::int64_t id)\n    -> std::expected<Types::OperationResult, std::string> {\n  auto asset_result = Asset::Repository::get_asset_by_id(app_state, id);\n  if (!asset_result) {\n    return std::unexpected(\"Failed to get asset item: \" + asset_result.error());\n  }\n\n  if (!asset_result->has_value()) {\n    return Types::OperationResult{\n        .success = false,\n        .message = \"Asset item not found\",\n        .affected_count = 0,\n    };\n  }\n\n  auto reveal_result =\n      Utils::System::reveal_file_in_explorer(std::filesystem::path(asset_result->value().path));\n  if (!reveal_result) {\n    return std::unexpected(\"Failed to reveal asset in explorer: \" + reveal_result.error());\n  }\n\n  return Types::OperationResult{\n      .success = true,\n      .message = \"Asset revealed in explorer\",\n      .affected_count = 0,\n  };\n}\n\nauto copy_assets_to_clipboard(Core::State::AppState& app_state,\n                              const std::vector<std::int64_t>& ids)\n    -> std::expected<Types::OperationResult, std::string> {\n  // 这一层负责把“选中的资产 ID”转换成真正可复制的磁盘文件路径。\n  // 真正的系统剪贴板写入由 Utils::System 负责。\n  if (ids.empty()) {\n    return Types::OperationResult{\n        .success = false,\n        .message = \"No assets selected\",\n        .affected_count = 0,\n    };\n  }\n\n  std::vector<std::int64_t> unique_ids;\n  unique_ids.reserve(ids.size());\n  std::unordered_set<std::int64_t> seen_ids;\n  seen_ids.reserve(ids.size());\n\n  // 保持选择集顺序，同时去掉重复 ID，避免重复复制同一个文件。\n  for (auto id : ids) {\n    if (seen_ids.insert(id).second) {\n      unique_ids.push_back(id);\n    }\n  }\n\n  std::vector<std::filesystem::path> clipboard_paths;\n  clipboard_paths.reserve(unique_ids.size());\n\n  std::int64_t copied_count = 0;\n  std::int64_t not_found_count = 0;\n  std::vector<std::string> errors;\n  errors.reserve(unique_ids.size());\n\n  // 逐个把资产 ID 转成文件路径，并过滤掉索引不存在或磁盘不存在的项。\n  for (auto id : unique_ids) {\n    auto asset_result = Asset::Repository::get_asset_by_id(app_state, id);\n    if (!asset_result) {\n      errors.push_back(\"Failed to query asset \" + std::to_string(id) + \": \" + asset_result.error());\n      continue;\n    }\n\n    if (!asset_result->has_value()) {\n      not_found_count++;\n      continue;\n    }\n\n    const auto& asset = asset_result->value();\n    if (asset.path.empty()) {\n      errors.push_back(\"Asset path is empty for asset \" + std::to_string(asset.id));\n      continue;\n    }\n\n    std::filesystem::path file_path(asset.path);\n    std::error_code ec;\n    const bool file_exists = std::filesystem::exists(file_path, ec);\n    if (ec) {\n      errors.push_back(\"Failed to access file \" + asset.path + \": \" + ec.message());\n      continue;\n    }\n\n    if (!file_exists) {\n      not_found_count++;\n      continue;\n    }\n\n    clipboard_paths.push_back(std::move(file_path));\n  }\n\n  // 只有在至少找到一个真实文件时，才真正写入系统剪贴板。\n  if (!clipboard_paths.empty()) {\n    auto copy_result = Utils::System::copy_files_to_clipboard(clipboard_paths);\n    if (!copy_result) {\n      errors.push_back(\"Failed to copy files to clipboard: \" + copy_result.error());\n    } else {\n      copied_count = static_cast<std::int64_t>(clipboard_paths.size());\n    }\n  }\n\n  const auto total_count = static_cast<std::int64_t>(unique_ids.size());\n  const auto failed_count = std::max<std::int64_t>(0, total_count - copied_count - not_found_count);\n\n  // 这里沿用 gallery 现有的 OperationResult 风格，\n  // 方便前端统一做 success / partial / failed 的 toast 提示。\n  Types::OperationResult result{\n      .success = copied_count == total_count,\n      .message = \"\",\n      .affected_count = copied_count,\n      .failed_count = failed_count,\n      .not_found_count = not_found_count,\n      .unchanged_count = 0,\n  };\n\n  if (result.success) {\n    result.message = std::format(\"Copied {} asset(s) to clipboard\", copied_count);\n    return result;\n  }\n\n  if (copied_count > 0) {\n    result.message = std::format(\"Copied {} asset(s) to clipboard, {} failed, {} not found\",\n                                 copied_count, failed_count, not_found_count);\n  } else {\n    result.message = std::format(\"Failed to copy assets to clipboard: {} failed, {} not found\",\n                                 failed_count, not_found_count);\n  }\n\n  // 详细错误记日志，用户界面只展示汇总结果即可。\n  for (const auto& error : errors) {\n    Logger().warn(\"copy_assets_to_clipboard: {}\", error);\n  }\n\n  return result;\n}\n\nauto move_assets_to_trash(Core::State::AppState& app_state, const std::vector<std::int64_t>& ids)\n    -> std::expected<Types::OperationResult, std::string> {\n  if (ids.empty()) {\n    return Types::OperationResult{\n        .success = false,\n        .message = \"No assets selected\",\n        .affected_count = 0,\n    };\n  }\n\n  struct TrashCandidate {\n    Types::Asset asset;\n    std::filesystem::path file_path;\n    bool file_exists = false;\n    bool manual_ignore_registered = false;\n  };\n\n  std::unordered_set<std::int64_t> unique_ids(ids.begin(), ids.end());\n  std::vector<TrashCandidate> candidates;\n  candidates.reserve(unique_ids.size());\n\n  std::int64_t moved_count = 0;\n  std::int64_t skipped_not_found = 0;\n  std::vector<std::string> errors;\n  errors.reserve(unique_ids.size());\n\n  for (auto id : unique_ids) {\n    auto asset_result = Asset::Repository::get_asset_by_id(app_state, id);\n    if (!asset_result) {\n      errors.push_back(\"Failed to query asset \" + std::to_string(id) + \": \" + asset_result.error());\n      continue;\n    }\n\n    if (!asset_result->has_value()) {\n      skipped_not_found++;\n      continue;\n    }\n\n    const auto& asset = asset_result->value();\n    std::filesystem::path file_path(asset.path);\n    std::error_code ec;\n    bool file_exists = std::filesystem::exists(file_path, ec);\n    if (ec) {\n      errors.push_back(\"Failed to access file \" + asset.path + \": \" + ec.message());\n      continue;\n    }\n\n    candidates.push_back(TrashCandidate{\n        .asset = asset,\n        .file_path = std::move(file_path),\n        .file_exists = file_exists,\n    });\n  }\n\n  std::vector<std::filesystem::path> recycle_paths;\n  recycle_paths.reserve(candidates.size());\n  for (auto& candidate : candidates) {\n    if (!candidate.file_exists) {\n      continue;\n    }\n\n    auto begin_ignore_result =\n        Watcher::begin_manual_move_ignore(app_state, candidate.file_path, candidate.file_path);\n    if (!begin_ignore_result) {\n      Logger().warn(\"Failed to register watcher ignore for recycle-bin move '{}': {}\",\n                    candidate.file_path.string(), begin_ignore_result.error());\n    } else {\n      candidate.manual_ignore_registered = true;\n    }\n\n    recycle_paths.push_back(candidate.file_path);\n  }\n\n  bool recycle_failed = false;\n  if (!recycle_paths.empty()) {\n    auto recycle_result = Utils::System::move_files_to_recycle_bin(recycle_paths);\n    if (!recycle_result) {\n      recycle_failed = true;\n      errors.push_back(\"Failed to move files to recycle bin: \" + recycle_result.error());\n    }\n  }\n\n  std::unordered_set<std::int64_t> failed_recycle_ids;\n  if (recycle_failed) {\n    for (const auto& candidate : candidates) {\n      if (!candidate.file_exists) {\n        continue;\n      }\n      failed_recycle_ids.insert(candidate.asset.id);\n      errors.push_back(\"Failed to move file to recycle bin \" + candidate.asset.path);\n    }\n  }\n\n  std::vector<std::int64_t> delete_ids;\n  delete_ids.reserve(candidates.size());\n  for (const auto& candidate : candidates) {\n    if (failed_recycle_ids.contains(candidate.asset.id)) {\n      continue;\n    }\n    if (auto delete_thumbnail_result =\n            Asset::Thumbnail::delete_thumbnail(app_state, candidate.asset);\n        !delete_thumbnail_result) {\n      Logger().warn(\"Failed to delete thumbnail for asset {}: {}\", candidate.asset.id,\n                    delete_thumbnail_result.error());\n    }\n    delete_ids.push_back(candidate.asset.id);\n  }\n\n  auto delete_result = Asset::Repository::batch_delete_assets_by_ids(app_state, delete_ids);\n  if (!delete_result) {\n    errors.push_back(\"Failed to delete asset indexes: \" + delete_result.error());\n  } else {\n    moved_count = static_cast<std::int64_t>(delete_ids.size());\n  }\n\n  for (auto& candidate : candidates) {\n    if (!candidate.manual_ignore_registered) {\n      continue;\n    }\n    if (auto complete_ignore_result = Watcher::complete_manual_move_ignore(\n            app_state, candidate.file_path, candidate.file_path);\n        !complete_ignore_result) {\n      Logger().warn(\"Failed to complete watcher ignore for recycle-bin move '{}': {}\",\n                    candidate.file_path.string(), complete_ignore_result.error());\n    }\n  }\n\n  Types::OperationResult result{\n      .success = errors.empty(),\n      .message = \"\",\n      .affected_count = moved_count,\n      .failed_count =\n          static_cast<std::int64_t>(unique_ids.size()) - moved_count - skipped_not_found,\n      .not_found_count = skipped_not_found,\n      .unchanged_count = 0,\n  };\n\n  if (errors.empty()) {\n    result.message = std::format(\"Moved {} asset(s) to recycle bin\", moved_count);\n    return result;\n  }\n\n  if (moved_count > 0) {\n    result.message = std::format(\"Moved {} asset(s) to recycle bin, {} failed, {} not found\",\n                                 moved_count, errors.size(), skipped_not_found);\n  } else {\n    result.message = std::format(\"Failed to move assets to recycle bin: {} failed, {} not found\",\n                                 errors.size(), skipped_not_found);\n  }\n\n  for (const auto& error : errors) {\n    Logger().warn(\"move_assets_to_trash: {}\", error);\n  }\n\n  return result;\n}\n\nauto move_assets_to_folder(Core::State::AppState& app_state,\n                           const Types::MoveAssetsToFolderParams& params)\n    -> std::expected<Types::OperationResult, std::string> {\n  if (params.ids.empty()) {\n    return Types::OperationResult{\n        .success = false,\n        .message = \"No assets selected\",\n        .affected_count = 0,\n    };\n  }\n\n  if (params.target_folder_id <= 0) {\n    return Types::OperationResult{\n        .success = false,\n        .message = \"Invalid target folder\",\n        .affected_count = 0,\n    };\n  }\n\n  auto target_folder_result =\n      Folder::Repository::get_folder_by_id(app_state, params.target_folder_id);\n  if (!target_folder_result) {\n    return std::unexpected(\"Failed to query target folder: \" + target_folder_result.error());\n  }\n  if (!target_folder_result->has_value()) {\n    return Types::OperationResult{\n        .success = false,\n        .message = \"Target folder not found\",\n        .affected_count = 0,\n    };\n  }\n\n  auto normalized_target_folder_result =\n      Utils::Path::NormalizePath(std::filesystem::path(target_folder_result->value().path));\n  if (!normalized_target_folder_result) {\n    return std::unexpected(\"Failed to normalize target folder path: \" +\n                           normalized_target_folder_result.error());\n  }\n\n  auto target_folder_path = normalized_target_folder_result.value();\n  std::unordered_set<std::int64_t> unique_ids(params.ids.begin(), params.ids.end());\n  std::int64_t moved_count = 0;\n  std::int64_t skipped_not_found = 0;\n  std::int64_t skipped_same_folder = 0;\n  std::vector<std::string> errors;\n  errors.reserve(unique_ids.size());\n\n  for (auto id : unique_ids) {\n    auto asset_result = Asset::Repository::get_asset_by_id(app_state, id);\n    if (!asset_result) {\n      errors.push_back(\"Failed to query asset \" + std::to_string(id) + \": \" + asset_result.error());\n      continue;\n    }\n    if (!asset_result->has_value()) {\n      skipped_not_found++;\n      continue;\n    }\n\n    auto asset = asset_result->value();\n    auto normalized_source_result = Utils::Path::NormalizePath(std::filesystem::path(asset.path));\n    if (!normalized_source_result) {\n      errors.push_back(\"Failed to normalize source path for asset \" + std::to_string(asset.id) +\n                       \": \" + normalized_source_result.error());\n      continue;\n    }\n\n    auto source_path = normalized_source_result.value();\n    auto destination_path = target_folder_path / source_path.filename();\n    auto normalized_destination_result = Utils::Path::NormalizePath(destination_path);\n    if (!normalized_destination_result) {\n      errors.push_back(\"Failed to normalize destination path for asset \" +\n                       std::to_string(asset.id) + \": \" + normalized_destination_result.error());\n      continue;\n    }\n\n    auto normalized_destination_path = normalized_destination_result.value();\n    // 目标与源一致时不报错，按“跳过项”处理，便于批量操作给出可理解反馈。\n    if (Utils::Path::NormalizeForComparison(source_path) ==\n        Utils::Path::NormalizeForComparison(normalized_destination_path)) {\n      skipped_same_folder++;\n      continue;\n    }\n\n    bool manual_ignore_registered = false;\n    // 先注册 watcher ignore，再执行 move，避免 watcher 抢先对同一路径做重复分析。\n    if (auto begin_ignore_result =\n            Watcher::begin_manual_move_ignore(app_state, source_path, normalized_destination_path);\n        begin_ignore_result) {\n      manual_ignore_registered = true;\n    } else {\n      Logger().warn(\"Failed to register watcher ignore for manual move '{}': {}\",\n                    source_path.string(), begin_ignore_result.error());\n    }\n\n    auto complete_manual_ignore = [&]() {\n      if (!manual_ignore_registered) {\n        return;\n      }\n      // 无论成功还是失败都尝试完成 ignore，防止 in-flight 计数泄漏。\n      if (auto complete_ignore_result = Watcher::complete_manual_move_ignore(\n              app_state, source_path, normalized_destination_path);\n          !complete_ignore_result) {\n        Logger().warn(\"Failed to complete watcher ignore for manual move '{}': {}\",\n                      source_path.string(), complete_ignore_result.error());\n      }\n      manual_ignore_registered = false;\n    };\n\n    auto move_result =\n        Utils::File::move_path_blocking(source_path, normalized_destination_path, false);\n    if (!move_result) {\n      complete_manual_ignore();\n      errors.push_back(\"Failed to move asset \" + std::to_string(asset.id) + \": \" +\n                       move_result.error());\n      continue;\n    }\n\n    asset.path = normalized_destination_path.generic_string();\n    asset.name = normalized_destination_path.filename().string();\n    asset.folder_id = params.target_folder_id;\n    // 文件系统移动成功后再更新索引，保证 DB 记录与磁盘最终位置保持一致。\n    auto update_result = Asset::Repository::update_asset(app_state, asset);\n    if (!update_result) {\n      complete_manual_ignore();\n      errors.push_back(\"Failed to update asset index \" + std::to_string(asset.id) + \": \" +\n                       update_result.error());\n      continue;\n    }\n\n    complete_manual_ignore();\n\n    moved_count++;\n  }\n\n  Types::OperationResult result{\n      .success = errors.empty(),\n      .message = \"\",\n      .affected_count = moved_count,\n      .failed_count = static_cast<std::int64_t>(unique_ids.size()) - moved_count -\n                      skipped_not_found - skipped_same_folder,\n      .not_found_count = skipped_not_found,\n      .unchanged_count = skipped_same_folder,\n  };\n\n  if (errors.empty()) {\n    result.message = std::format(\"Moved {} asset(s) to target folder\", moved_count);\n    return result;\n  }\n\n  if (moved_count > 0) {\n    result.message =\n        std::format(\"Moved {} asset(s), {} failed, {} not found, {} already in target folder\",\n                    moved_count, errors.size(), skipped_not_found, skipped_same_folder);\n  } else {\n    result.message = std::format(\"Failed to move assets: {} failed, {} not found, {} unchanged\",\n                                 errors.size(), skipped_not_found, skipped_same_folder);\n  }\n\n  for (const auto& error : errors) {\n    Logger().warn(\"move_assets_to_folder: {}\", error);\n  }\n\n  return result;\n}\n\n// ============= 扫描和索引 =============\n\nauto scan_directory(Core::State::AppState& app_state, const Types::ScanOptions& options,\n                    std::function<void(const Types::ScanProgress&)> progress_callback)\n    -> std::expected<Types::ScanResult, std::string> {\n  auto scan_result =\n      Scanner::scan_asset_directory(app_state, options, std::move(progress_callback));\n  if (!scan_result) {\n    Logger().error(\"Asset scan failed: {}\", scan_result.error());\n    return std::unexpected(\"Asset scan failed: \" + scan_result.error());\n  }\n\n  auto result = scan_result.value();\n  Logger().info(\"Asset scan completed. Total: {}, New: {}, Updated: {}, Errors: {}\",\n                result.total_files, result.new_items, result.updated_items, result.errors.size());\n\n  // 手动/显式扫描后只做当前目录的“缺失缩略图补回”，\n  // 不在这里顺手做全局孤儿清理，避免把启动级别的缓存对账混进日常扫描。\n  if (!options.rebuild_thumbnails.value_or(false)) {\n    auto thumbnail_repair_result = Asset::Thumbnail::repair_missing_thumbnails(\n        app_state, std::filesystem::path(options.directory),\n        options.thumbnail_short_edge.value_or(480));\n    if (!thumbnail_repair_result) {\n      Logger().warn(\"Gallery thumbnail repair failed after scan '{}': {}\", options.directory,\n                    thumbnail_repair_result.error());\n    } else {\n      const auto& stats = thumbnail_repair_result.value();\n      Logger().info(\n          \"Gallery thumbnail repair finished. context=scan_directory, candidates={}, missing={}, \"\n          \"repaired={}, failed={}, skipped_missing_sources={}\",\n          stats.candidate_hashes, stats.missing_thumbnails, stats.repaired_thumbnails,\n          stats.failed_repairs, stats.skipped_missing_sources);\n    }\n  }\n\n  auto watcher_result = Watcher::register_watcher_for_directory(\n      app_state, std::filesystem::path(options.directory), options);\n  if (!watcher_result) {\n    // 扫描已经成功，监听失败这里只记日志，不中断流程。\n    Logger().warn(\"Failed to ensure watcher for '{}': {}\", options.directory,\n                  watcher_result.error());\n    return result;\n  }\n\n  auto start_result = Watcher::start_watcher_for_directory(\n      app_state, std::filesystem::path(options.directory), false);\n  if (!start_result) {\n    Logger().warn(\"Failed to start watcher for '{}': {}\", options.directory, start_result.error());\n  }\n\n  return result;\n}\n\nauto cleanup_thumbnails(Core::State::AppState& app_state)\n    -> std::expected<Types::OperationResult, std::string> {\n  try {\n    auto cleanup_result = Asset::Thumbnail::cleanup_orphaned_thumbnails(app_state);\n\n    Types::OperationResult result;\n    if (cleanup_result) {\n      result.success = true;\n      result.message = std::format(\"Cleaned up {} orphaned thumbnails\", cleanup_result.value());\n      result.affected_count = cleanup_result.value();\n      Logger().info(\"Thumbnail cleanup completed: {} files removed\", cleanup_result.value());\n    } else {\n      result.success = false;\n      result.message = \"Failed to cleanup thumbnails: \" + cleanup_result.error();\n      result.affected_count = 0;\n    }\n\n    return result;\n\n  } catch (const std::exception& e) {\n    return std::unexpected(\"Exception in cleanup_thumbnails: \" + std::string(e.what()));\n  }\n}\n\n// ============= 统计和信息 =============\n\nauto get_thumbnail_stats(Core::State::AppState& app_state)\n    -> std::expected<std::string, std::string> {\n  try {\n    auto stats_result = Asset::Thumbnail::get_thumbnail_stats(app_state);\n    if (!stats_result) {\n      return std::unexpected(stats_result.error());\n    }\n\n    auto stats = stats_result.value();\n\n    std::string formatted_stats = std::format(\n        \"Thumbnail Statistics:\\\\n\"\n        \"Directory: {}\\\\n\"\n        \"Total Thumbnails: {}\\\\n\"\n        \"Total Size: {} bytes\\\\n\"\n        \"Orphaned Thumbnails: {}\\\\n\"\n        \"Corrupted Thumbnails: {}\",\n        stats.thumbnails_directory, stats.total_thumbnails, stats.total_size,\n        stats.orphaned_thumbnails, stats.corrupted_thumbnails);\n\n    return formatted_stats;\n\n  } catch (const std::exception& e) {\n    return std::unexpected(\"Exception in get_thumbnail_stats: \" + std::string(e.what()));\n  }\n}\n\n}  // namespace Features::Gallery\n"
  },
  {
    "path": "src/features/gallery/gallery.ixx",
    "content": "module;\n\nexport module Features.Gallery;\n\nimport std;\nimport Core.State;\nimport Features.Gallery.Types;\n\nnamespace Features::Gallery {\n\n// 初始化与清理\nexport auto initialize(Core::State::AppState& app_state) -> std::expected<void, std::string>;\nexport auto cleanup(Core::State::AppState& app_state) -> void;\n\n// 资产管理\nexport auto delete_asset(Core::State::AppState& app_state, const Types::DeleteParams& params)\n    -> std::expected<Types::OperationResult, std::string>;\nexport auto open_asset_with_default_app(Core::State::AppState& app_state, std::int64_t id)\n    -> std::expected<Types::OperationResult, std::string>;\nexport auto reveal_asset_in_explorer(Core::State::AppState& app_state, std::int64_t id)\n    -> std::expected<Types::OperationResult, std::string>;\nexport auto copy_assets_to_clipboard(Core::State::AppState& app_state,\n                                     const std::vector<std::int64_t>& ids)\n    -> std::expected<Types::OperationResult, std::string>;\nexport auto move_assets_to_trash(Core::State::AppState& app_state,\n                                 const std::vector<std::int64_t>& ids)\n    -> std::expected<Types::OperationResult, std::string>;\nexport auto move_assets_to_folder(Core::State::AppState& app_state,\n                                  const Types::MoveAssetsToFolderParams& params)\n    -> std::expected<Types::OperationResult, std::string>;\n\n// 扫描与索引\nexport auto scan_directory(Core::State::AppState& app_state, const Types::ScanOptions& options,\n                           std::function<void(const Types::ScanProgress&)> progress_callback =\n                               nullptr) -> std::expected<Types::ScanResult, std::string>;\nexport auto ensure_output_directory_media_source(Core::State::AppState& app_state,\n                                                 const std::string& output_dir_path) -> void;\n\n// 缩略图\nexport auto cleanup_thumbnails(Core::State::AppState& app_state)\n    -> std::expected<Types::OperationResult, std::string>;\n\n// 统计\nexport auto get_thumbnail_stats(Core::State::AppState& app_state)\n    -> std::expected<std::string, std::string>;\n\n}  // namespace Features::Gallery\n"
  },
  {
    "path": "src/features/gallery/ignore/matcher.cpp",
    "content": "module;\n\nmodule Features.Gallery.Ignore.Matcher;\n\nimport std;\nimport Utils.Logger;\n\nnamespace Features::Gallery::Ignore::Matcher {\n\n// ============= 路径处理辅助函数 =============\n\nauto normalize_path_for_matching(const std::filesystem::path& file_path,\n                                 const std::filesystem::path& base_path) -> std::string {\n  try {\n    // 计算相对路径，自动规范化，并使用统一的正斜杠格式\n    auto relative = std::filesystem::relative(file_path, base_path);\n    return relative.lexically_normal().generic_string();\n  } catch (const std::filesystem::filesystem_error&) {\n    // 如果无法计算相对路径，返回规范化的文件名\n    return file_path.filename().lexically_normal().generic_string();\n  }\n}\n\n// ============= Glob模式匹配实现 =============\n\nauto match_glob_pattern(const std::string& pattern, const std::string& path) -> bool {\n  // 简化的glob实现，支持基本的gitignore模式\n  // TODO: 这是一个基础实现，后续可以扩展支持更复杂的glob语法\n\n  try {\n    // 转换glob模式为正则表达式\n    std::string regex_pattern;\n    regex_pattern.reserve(pattern.size() * 2);\n\n    bool in_bracket = false;\n\n    for (size_t i = 0; i < pattern.size(); ++i) {\n      char c = pattern[i];\n\n      switch (c) {\n        case '*':\n          if (i + 1 < pattern.size() && pattern[i + 1] == '*') {\n            // ** 匹配任何目录层级\n            regex_pattern += \".*\";\n            ++i;  // 跳过第二个*\n            // 跳过后续的/\n            if (i + 1 < pattern.size() && pattern[i + 1] == '/') {\n              ++i;\n            }\n          } else {\n            // * 匹配除/外的任意字符\n            regex_pattern += \"[^/]*\";\n          }\n          break;\n        case '?':\n          regex_pattern += \"[^/]\";\n          break;\n        case '[':\n          regex_pattern += \"[\";\n          in_bracket = true;\n          break;\n        case ']':\n          regex_pattern += \"]\";\n          in_bracket = false;\n          break;\n        case '.':\n        case '+':\n        case '^':\n        case '$':\n        case '(':\n        case ')':\n        case '{':\n        case '}':\n        case '|':\n          // 转义正则表达式特殊字符\n          if (!in_bracket) {\n            regex_pattern += \"\\\\\";\n          }\n          regex_pattern += c;\n          break;\n        default:\n          regex_pattern += c;\n          break;\n      }\n    }\n\n    // 如果模式不以/开头，则匹配任何位置\n    if (!pattern.starts_with(\"/\")) {\n      regex_pattern = \"(^|.*/)\" + regex_pattern;\n    } else {\n      // 移除开头的/\n      if (regex_pattern.starts_with(\"/\")) {\n        regex_pattern = regex_pattern.substr(1);\n      }\n      regex_pattern = \"^\" + regex_pattern;\n    }\n\n    // 如果模式以/结尾，表示只匹配目录\n    if (pattern.ends_with(\"/\")) {\n      regex_pattern += \"$\";\n    } else {\n      // 匹配文件或目录\n      regex_pattern += \"(/.*)?$\";\n    }\n\n    std::regex glob_regex(regex_pattern, std::regex_constants::icase);\n    return std::regex_match(path, glob_regex);\n\n  } catch (const std::regex_error& e) {\n    Logger().warn(\"Invalid glob pattern '{}': {}\", pattern, e.what());\n    return false;\n  }\n}\n\n// ============= 正则表达式模式匹配 =============\n\nauto match_regex_pattern(const std::string& pattern, const std::string& path) -> bool {\n  try {\n    std::regex regex_pattern(pattern, std::regex_constants::icase);\n    return std::regex_search(path, regex_pattern);\n  } catch (const std::regex_error& e) {\n    Logger().warn(\"Invalid regex pattern '{}': {}\", pattern, e.what());\n    return false;\n  }\n}\n\n}  // namespace Features::Gallery::Ignore::Matcher\n"
  },
  {
    "path": "src/features/gallery/ignore/matcher.ixx",
    "content": "module;\n\nexport module Features.Gallery.Ignore.Matcher;\n\nimport std;\n\nnamespace Features::Gallery::Ignore::Matcher {\n\n// Glob 模式匹配\nexport auto match_glob_pattern(const std::string& pattern, const std::string& path) -> bool;\n\n// 正则表达式模式匹配\nexport auto match_regex_pattern(const std::string& pattern, const std::string& path) -> bool;\n\n}  // namespace Features::Gallery::Ignore::Matcher\n"
  },
  {
    "path": "src/features/gallery/ignore/repository.cpp",
    "content": "module;\n\nmodule Features.Gallery.Ignore.Repository;\n\nimport std;\nimport Core.State;\nimport Core.Database;\nimport Core.Database.State;\nimport Core.Database.Types;\nimport Features.Gallery.Types;\nimport Utils.Logger;\nimport <rfl.hpp>;\n\nnamespace Features::Gallery::Ignore::Repository {\n\n// ============= 基本 CRUD 操作 =============\n\nauto create_ignore_rule(Core::State::AppState& app_state, const Types::IgnoreRule& rule)\n    -> std::expected<std::int64_t, std::string> {\n  std::string sql = R\"(\n    INSERT INTO ignore_rules (\n      folder_id, rule_pattern, pattern_type, rule_type, \n      is_enabled, description, created_at, updated_at\n    ) VALUES (?, ?, ?, ?, ?, ?, \n      strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),\n      strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n    )\n  )\";\n\n  std::vector<Core::Database::Types::DbParam> params = {\n      rule.folder_id.has_value() ? Core::Database::Types::DbParam{rule.folder_id.value()}\n                                 : Core::Database::Types::DbParam{std::monostate{}},\n      rule.rule_pattern,\n      rule.pattern_type,\n      rule.rule_type,\n      rule.is_enabled,\n      rule.description.has_value() ? Core::Database::Types::DbParam{rule.description.value()}\n                                   : Core::Database::Types::DbParam{std::monostate{}}};\n\n  auto result = Core::Database::execute(*app_state.database, sql, params);\n  if (!result) {\n    return std::unexpected(\"Failed to create ignore rule: \" + result.error());\n  }\n\n  auto id_result =\n      Core::Database::query_scalar<int64_t>(*app_state.database, \"SELECT last_insert_rowid()\");\n  if (!id_result || !id_result->has_value()) {\n    return std::unexpected(\"Failed to get inserted rule ID\");\n  }\n\n  Logger().info(\"Created ignore rule with ID {}: {}\", id_result->value(), rule.rule_pattern);\n  return id_result->value();\n}\n\nauto get_ignore_rule_by_id(Core::State::AppState& app_state, std::int64_t id)\n    -> std::expected<std::optional<Types::IgnoreRule>, std::string> {\n  std::string sql = R\"(\n    SELECT id, folder_id, rule_pattern, pattern_type, rule_type, \n           is_enabled, description, created_at, updated_at\n    FROM ignore_rules \n    WHERE id = ?\n  )\";\n\n  auto result = Core::Database::query<Types::IgnoreRule>(*app_state.database, sql, {id});\n  if (!result) {\n    return std::unexpected(\"Failed to query ignore rule: \" + result.error());\n  }\n\n  if (result->empty()) {\n    return std::nullopt;\n  }\n\n  return std::make_optional(result->at(0));\n}\n\nauto update_ignore_rule(Core::State::AppState& app_state, const Types::IgnoreRule& rule)\n    -> std::expected<void, std::string> {\n  std::string sql = R\"(\n    UPDATE ignore_rules \n    SET folder_id = ?, rule_pattern = ?, pattern_type = ?, rule_type = ?,\n        is_enabled = ?, description = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n    WHERE id = ?\n  )\";\n\n  std::vector<Core::Database::Types::DbParam> params = {\n      rule.folder_id.has_value() ? Core::Database::Types::DbParam{rule.folder_id.value()}\n                                 : Core::Database::Types::DbParam{std::monostate{}},\n      rule.rule_pattern,\n      rule.pattern_type,\n      rule.rule_type,\n      rule.is_enabled,\n      rule.description.has_value() ? Core::Database::Types::DbParam{rule.description.value()}\n                                   : Core::Database::Types::DbParam{std::monostate{}},\n      rule.id};\n\n  auto result = Core::Database::execute(*app_state.database, sql, params);\n  if (!result) {\n    return std::unexpected(\"Failed to update ignore rule: \" + result.error());\n  }\n\n  Logger().debug(\"Updated ignore rule ID {}\", rule.id);\n  return {};\n}\n\nauto delete_ignore_rule(Core::State::AppState& app_state, std::int64_t id)\n    -> std::expected<void, std::string> {\n  std::string sql = \"DELETE FROM ignore_rules WHERE id = ?\";\n\n  auto result = Core::Database::execute(*app_state.database, sql, {id});\n  if (!result) {\n    return std::unexpected(\"Failed to delete ignore rule: \" + result.error());\n  }\n\n  Logger().info(\"Deleted ignore rule ID {}\", id);\n  return {};\n}\n\n// ============= 基于文件夹的查询操作 =============\n\nauto get_rules_by_folder_id(Core::State::AppState& app_state, std::int64_t folder_id)\n    -> std::expected<std::vector<Types::IgnoreRule>, std::string> {\n  std::string sql = R\"(\n    SELECT id, folder_id, rule_pattern, pattern_type, rule_type, \n           is_enabled, description, created_at, updated_at\n    FROM ignore_rules \n    WHERE folder_id = ? AND is_enabled = 1\n    ORDER BY created_at ASC\n  )\";\n\n  auto result = Core::Database::query<Types::IgnoreRule>(*app_state.database, sql, {folder_id});\n  if (!result) {\n    return std::unexpected(\"Failed to query rules by folder_id: \" + result.error());\n  }\n\n  return std::move(result.value());\n}\n\nauto get_rules_by_directory_path(Core::State::AppState& app_state,\n                                 const std::string& directory_path)\n    -> std::expected<std::vector<Types::IgnoreRule>, std::string> {\n  // 先查找folder_id\n  std::string folder_sql = \"SELECT id FROM folders WHERE path = ?\";\n  auto folder_result =\n      Core::Database::query_scalar<int64_t>(*app_state.database, folder_sql, {directory_path});\n\n  if (!folder_result) {\n    return std::unexpected(\"Failed to query folder by path: \" + folder_result.error());\n  }\n\n  if (!folder_result->has_value()) {\n    return std::vector<Types::IgnoreRule>{};  // 文件夹不存在，返回空列表\n  }\n\n  return get_rules_by_folder_id(app_state, folder_result->value());\n}\n\nauto get_global_rules(Core::State::AppState& app_state)\n    -> std::expected<std::vector<Types::IgnoreRule>, std::string> {\n  std::string sql = R\"(\n    SELECT id, folder_id, rule_pattern, pattern_type, rule_type, \n           is_enabled, description, created_at, updated_at\n    FROM ignore_rules \n    WHERE folder_id IS NULL AND is_enabled = 1\n    ORDER BY created_at ASC\n  )\";\n\n  auto result = Core::Database::query<Types::IgnoreRule>(*app_state.database, sql);\n  if (!result) {\n    return std::unexpected(\"Failed to query global rules: \" + result.error());\n  }\n\n  return std::move(result.value());\n}\n\n// ============= 批量操作 =============\n\nauto replace_rules_by_folder_id(Core::State::AppState& app_state, std::int64_t folder_id,\n                                const std::vector<Types::ScanIgnoreRule>& scan_rules)\n    -> std::expected<void, std::string> {\n  auto transaction_result = Core::Database::execute_transaction(\n      *app_state.database,\n      [&](Core::Database::State::DatabaseState& db_state) -> std::expected<void, std::string> {\n        // 先删除该文件夹已有规则，再插入新的完整规则集\n        auto delete_result = Core::Database::execute(\n            db_state, \"DELETE FROM ignore_rules WHERE folder_id = ?\", {folder_id});\n        if (!delete_result) {\n          return std::unexpected(\"Failed to delete existing rules: \" + delete_result.error());\n        }\n\n        for (const auto& scan_rule : scan_rules) {\n          if (scan_rule.pattern.empty()) {\n            continue;\n          }\n\n          std::string insert_sql = R\"(\n            INSERT INTO ignore_rules (\n              folder_id, rule_pattern, pattern_type, rule_type, \n              is_enabled, description, created_at, updated_at\n            ) VALUES (?, ?, ?, ?, ?, ?, \n              strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),\n              strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n            )\n          )\";\n\n          std::vector<Core::Database::Types::DbParam> params = {\n              folder_id,\n              scan_rule.pattern,\n              scan_rule.pattern_type,\n              scan_rule.rule_type,\n              1,\n              scan_rule.description.has_value()\n                  ? Core::Database::Types::DbParam{scan_rule.description.value()}\n                  : Core::Database::Types::DbParam{std::monostate{}}};\n\n          auto insert_result = Core::Database::execute(db_state, insert_sql, params);\n          if (!insert_result) {\n            return std::unexpected(\"Failed to insert ignore rule: \" + insert_result.error());\n          }\n        }\n\n        return {};\n      });\n\n  if (!transaction_result) {\n    return std::unexpected(\"Transaction failed: \" + transaction_result.error());\n  }\n\n  Logger().info(\"Replaced ignore rules for folder_id {} with {} rule(s)\", folder_id,\n                scan_rules.size());\n  return {};\n}\n\nauto batch_update_ignore_rules(Core::State::AppState& app_state,\n                               const std::vector<Types::IgnoreRule>& rules)\n    -> std::expected<void, std::string> {\n  if (rules.empty()) {\n    return {};\n  }\n\n  return Core::Database::execute_transaction(\n      *app_state.database,\n      [&](Core::Database::State::DatabaseState& db_state) -> std::expected<void, std::string> {\n        for (const auto& rule : rules) {\n          auto update_result = update_ignore_rule(app_state, rule);\n          if (!update_result) {\n            return std::unexpected(\"Failed to update rule ID \" + std::to_string(rule.id) + \": \" +\n                                   update_result.error());\n          }\n        }\n\n        return {};\n      });\n}\n\nauto delete_rules_by_folder_id(Core::State::AppState& app_state, std::int64_t folder_id)\n    -> std::expected<int, std::string> {\n  std::string sql = \"DELETE FROM ignore_rules WHERE folder_id = ?\";\n\n  auto result = Core::Database::execute(*app_state.database, sql, {folder_id});\n  if (!result) {\n    return std::unexpected(\"Failed to delete rules by folder_id: \" + result.error());\n  }\n\n  // 获取删除的行数\n  auto count_result = Core::Database::query_scalar<int>(*app_state.database, \"SELECT changes()\");\n  int deleted_count = count_result && count_result->has_value() ? count_result->value() : 0;\n\n  Logger().info(\"Deleted {} ignore rules for folder_id {}\", deleted_count, folder_id);\n  return deleted_count;\n}\n\n// ============= 规则管理和维护 =============\n\nauto toggle_rule_enabled(Core::State::AppState& app_state, std::int64_t id, bool enabled)\n    -> std::expected<void, std::string> {\n  std::string sql = R\"(\n    UPDATE ignore_rules \n    SET is_enabled = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n    WHERE id = ?\n  )\";\n\n  auto result = Core::Database::execute(*app_state.database, sql, {enabled ? 1 : 0, id});\n  if (!result) {\n    return std::unexpected(\"Failed to toggle rule enabled status: \" + result.error());\n  }\n\n  Logger().debug(\"Toggled ignore rule ID {} to {}\", id, enabled ? \"enabled\" : \"disabled\");\n  return {};\n}\n\nauto cleanup_orphaned_rules(Core::State::AppState& app_state) -> std::expected<int, std::string> {\n  std::string sql = R\"(\n    DELETE FROM ignore_rules \n    WHERE folder_id IS NOT NULL \n      AND folder_id NOT IN (SELECT id FROM folders)\n  )\";\n\n  auto result = Core::Database::execute(*app_state.database, sql);\n  if (!result) {\n    return std::unexpected(\"Failed to cleanup orphaned rules: \" + result.error());\n  }\n\n  auto count_result = Core::Database::query_scalar<int>(*app_state.database, \"SELECT changes()\");\n  int deleted_count = count_result && count_result->has_value() ? count_result->value() : 0;\n\n  if (deleted_count > 0) {\n    Logger().info(\"Cleaned up {} orphaned ignore rules\", deleted_count);\n  }\n\n  return deleted_count;\n}\n\nauto count_rules(Core::State::AppState& app_state, std::optional<std::int64_t> folder_id)\n    -> std::expected<int, std::string> {\n  std::string sql = \"SELECT COUNT(*) FROM ignore_rules WHERE is_enabled = 1\";\n  std::vector<Core::Database::Types::DbParam> params;\n\n  if (folder_id.has_value()) {\n    sql += \" AND folder_id = ?\";\n    params.push_back(folder_id.value());\n  }\n\n  auto result = Core::Database::query_scalar<int>(*app_state.database, sql, params);\n  if (!result) {\n    return std::unexpected(\"Failed to count ignore rules: \" + result.error());\n  }\n\n  return result->value_or(0);\n}\n\n}  // namespace Features::Gallery::Ignore::Repository\n"
  },
  {
    "path": "src/features/gallery/ignore/repository.ixx",
    "content": "module;\n\nexport module Features.Gallery.Ignore.Repository;\n\nimport std;\nimport Core.State;\nimport Features.Gallery.Types;\n\nnamespace Features::Gallery::Ignore::Repository {\n\nexport auto create_ignore_rule(Core::State::AppState& app_state, const Types::IgnoreRule& rule)\n    -> std::expected<std::int64_t, std::string>;\n\nexport auto get_ignore_rule_by_id(Core::State::AppState& app_state, std::int64_t id)\n    -> std::expected<std::optional<Types::IgnoreRule>, std::string>;\n\nexport auto update_ignore_rule(Core::State::AppState& app_state, const Types::IgnoreRule& rule)\n    -> std::expected<void, std::string>;\n\nexport auto delete_ignore_rule(Core::State::AppState& app_state, std::int64_t id)\n    -> std::expected<void, std::string>;\n\nexport auto get_rules_by_folder_id(Core::State::AppState& app_state, std::int64_t folder_id)\n    -> std::expected<std::vector<Types::IgnoreRule>, std::string>;\n\nexport auto get_rules_by_directory_path(Core::State::AppState& app_state,\n                                        const std::string& directory_path)\n    -> std::expected<std::vector<Types::IgnoreRule>, std::string>;\n\nexport auto get_global_rules(Core::State::AppState& app_state)\n    -> std::expected<std::vector<Types::IgnoreRule>, std::string>;\n\nexport auto replace_rules_by_folder_id(Core::State::AppState& app_state, std::int64_t folder_id,\n                                       const std::vector<Types::ScanIgnoreRule>& scan_rules)\n    -> std::expected<void, std::string>;\n\nexport auto batch_update_ignore_rules(Core::State::AppState& app_state,\n                                      const std::vector<Types::IgnoreRule>& rules)\n    -> std::expected<void, std::string>;\n\nexport auto delete_rules_by_folder_id(Core::State::AppState& app_state, std::int64_t folder_id)\n    -> std::expected<int, std::string>;\n\nexport auto toggle_rule_enabled(Core::State::AppState& app_state, std::int64_t id, bool enabled)\n    -> std::expected<void, std::string>;\n\nexport auto cleanup_orphaned_rules(Core::State::AppState& app_state)\n    -> std::expected<int, std::string>;\n\nexport auto count_rules(Core::State::AppState& app_state,\n                        std::optional<std::int64_t> folder_id = std::nullopt)\n    -> std::expected<int, std::string>;\n\n}  // namespace Features::Gallery::Ignore::Repository\n"
  },
  {
    "path": "src/features/gallery/ignore/service.cpp",
    "content": "module;\n\nmodule Features.Gallery.Ignore.Service;\n\nimport std;\nimport Core.State;\nimport Features.Gallery.Types;\nimport Features.Gallery.Ignore.Repository;\nimport Features.Gallery.Ignore.Matcher;\nimport Utils.Logger;\n\nnamespace Features::Gallery::Ignore::Service {\n\n// ============= 路径处理辅助函数 =============\n\nauto normalize_path_for_matching(const std::filesystem::path& file_path,\n                                 const std::filesystem::path& base_path) -> std::string {\n  try {\n    // 计算相对路径，自动规范化，并使用统一的正斜杠格式\n    auto relative = std::filesystem::relative(file_path, base_path);\n    return relative.lexically_normal().generic_string();\n  } catch (const std::filesystem::filesystem_error&) {\n    // 如果无法计算相对路径，返回规范化的文件名\n    return file_path.filename().lexically_normal().generic_string();\n  }\n}\n\n// ============= 业务编排函数 =============\n\nauto load_ignore_rules(Core::State::AppState& app_state, std::optional<std::int64_t> folder_id)\n    -> std::expected<std::vector<Types::IgnoreRule>, std::string> {\n  std::vector<Types::IgnoreRule> combined_rules;\n\n  // 1. 先加载全局规则\n  auto global_rules_result = Repository::get_global_rules(app_state);\n  if (!global_rules_result) {\n    return std::unexpected(\"Failed to load global ignore rules: \" + global_rules_result.error());\n  }\n  combined_rules = std::move(global_rules_result.value());\n\n  // 2. 然后追加文件夹特定规则\n  if (folder_id.has_value()) {\n    auto folder_rules_result = Repository::get_rules_by_folder_id(app_state, *folder_id);\n    if (!folder_rules_result) {\n      Logger().warn(\"Failed to load folder-specific ignore rules: {}\", folder_rules_result.error());\n      // 不返回错误，继续使用已加载的全局规则\n    } else {\n      auto& folder_rules = folder_rules_result.value();\n      combined_rules.insert(combined_rules.end(), std::make_move_iterator(folder_rules.begin()),\n                            std::make_move_iterator(folder_rules.end()));\n    }\n  }\n\n  return combined_rules;\n}\n\nauto apply_ignore_rules(const std::filesystem::path& file_path,\n                        const std::filesystem::path& base_path,\n                        const std::vector<Types::IgnoreRule>& rules) -> bool {\n  if (rules.empty()) {\n    return false;  // 没有规则，不忽略\n  }\n\n  auto normalized_path = normalize_path_for_matching(file_path, base_path);\n  bool should_ignore = false;\n\n  // 按顺序应用规则，后面的规则会覆盖前面的结果\n  for (const auto& rule : rules) {\n    if (!rule.is_enabled) {\n      continue;  // 跳过禁用的规则\n    }\n\n    bool matches = false;\n\n    // 根据模式类型选择匹配方法\n    if (rule.pattern_type == \"glob\") {\n      matches = Matcher::match_glob_pattern(rule.rule_pattern, normalized_path);\n    } else if (rule.pattern_type == \"regex\") {\n      matches = Matcher::match_regex_pattern(rule.rule_pattern, normalized_path);\n    } else {\n      Logger().warn(\"Unknown pattern type '{}' for rule: {}\", rule.pattern_type, rule.rule_pattern);\n      continue;\n    }\n\n    if (matches) {\n      // 根据规则类型设置忽略状态\n      should_ignore = (rule.rule_type == \"exclude\");\n    }\n  }\n\n  return should_ignore;\n}\n\n}  // namespace Features::Gallery::Ignore::Service\n"
  },
  {
    "path": "src/features/gallery/ignore/service.ixx",
    "content": "module;\n\nexport module Features.Gallery.Ignore.Service;\n\nimport std;\nimport Core.State;\nimport Features.Gallery.Types;\n\nnamespace Features::Gallery::Ignore::Service {\n\n// 加载并合并忽略规则（先加载全局规则，再追加文件夹规则）\nexport auto load_ignore_rules(Core::State::AppState& app_state,\n                              std::optional<std::int64_t> folder_id = std::nullopt)\n    -> std::expected<std::vector<Types::IgnoreRule>, std::string>;\n\n// 应用忽略规则到单个文件（返回是否应该忽略）\nexport auto apply_ignore_rules(const std::filesystem::path& file_path,\n                               const std::filesystem::path& base_path,\n                               const std::vector<Types::IgnoreRule>& rules) -> bool;\n\n}  // namespace Features::Gallery::Ignore::Service\n"
  },
  {
    "path": "src/features/gallery/original_locator.cpp",
    "content": "module;\n\nmodule Features.Gallery.OriginalLocator;\n\nimport std;\nimport Core.State;\nimport Features.Gallery.Types;\nimport Features.Gallery.Folder.Repository;\nimport Utils.Logger;\nimport Utils.Path;\n\nnamespace Features::Gallery::OriginalLocator {\n\nnamespace Detail {\n\n// 读取图库中的所有 root folders。\n// 当前项目里，parent_id 为空的 folder 就代表一个 watch root。\n// 这里按路径长度倒序排序，避免较短前缀先匹配到错误的 root。\nauto load_root_folders(Core::State::AppState& app_state)\n    -> std::expected<std::vector<Types::Folder>, std::string> {\n  auto folders_result = Features::Gallery::Folder::Repository::list_all_folders(app_state);\n  if (!folders_result) {\n    return std::unexpected(\"Failed to load folders for original locator: \" +\n                           folders_result.error());\n  }\n\n  std::vector<Types::Folder> root_folders;\n  for (const auto& folder : folders_result.value()) {\n    if (!folder.parent_id.has_value()) {\n      root_folders.push_back(folder);\n    }\n  }\n\n  std::ranges::sort(root_folders, [](const Types::Folder& lhs, const Types::Folder& rhs) {\n    return lhs.path.size() > rhs.path.size();\n  });\n\n  return root_folders;\n}\n\n// 为单个 asset 推导 originals locator：\n// - root_id: 资源属于哪个 watch root\n// - relative_path: 文件在该 root 下的相对路径\n//\n// 这里不会改数据库，只是给 RPC 返回前的运行时对象补齐字段。\nauto try_assign_locator_from_roots(const std::vector<Types::Folder>& root_folders,\n                                   Types::Asset& asset) -> std::expected<void, std::string> {\n  asset.root_id.reset();\n  asset.relative_path.reset();\n\n  auto normalized_asset_result = Utils::Path::NormalizePath(std::filesystem::path(asset.path));\n  if (!normalized_asset_result) {\n    Logger().warn(\"Failed to normalize asset path for original locator '{}': {}\", asset.path,\n                  normalized_asset_result.error());\n    return {};\n  }\n\n  auto normalized_asset_path = normalized_asset_result.value();\n\n  for (const auto& root_folder : root_folders) {\n    auto normalized_root_result =\n        Utils::Path::NormalizePath(std::filesystem::path(root_folder.path));\n    if (!normalized_root_result) {\n      Logger().warn(\"Failed to normalize root folder path for original locator '{}': {}\",\n                    root_folder.path, normalized_root_result.error());\n      continue;\n    }\n\n    auto normalized_root_path = normalized_root_result.value();\n    if (!Utils::Path::IsPathWithinBase(normalized_asset_path, normalized_root_path)) {\n      continue;\n    }\n\n    auto relative_path = normalized_asset_path.lexically_relative(normalized_root_path);\n    auto relative_path_string = relative_path.generic_string();\n    if (relative_path_string.empty() || relative_path_string == \".\" ||\n        relative_path_string.starts_with(\"../\")) {\n      Logger().warn(\n          \"Computed invalid original relative path for asset '{}': root='{}', relative='{}'\",\n          asset.path, root_folder.path, relative_path_string);\n      return {};\n    }\n\n    asset.root_id = root_folder.id;\n    asset.relative_path = std::move(relative_path_string);\n    return {};\n  }\n\n  Logger().warn(\"No gallery root folder matched asset path for original locator: {}\", asset.path);\n  return {};\n}\n\n}  // namespace Detail\n\n// 统一约定每个 root 的 WebView host 名称。\n// 例如 root_id=3 时，对应的 host 是 r-3.test。\nauto make_root_host_name(std::int64_t root_id) -> std::wstring {\n  return std::format(L\"r-{}.test\", root_id);\n}\n\n// 为一批资产补齐 originals locator（root_id + relative_path）。\n// 批量走这个接口，root folders 只查一次，避免 N 次重复加载。\nauto populate_asset_locators(Core::State::AppState& app_state, std::vector<Types::Asset>& assets)\n    -> std::expected<void, std::string> {\n  auto root_folders_result = Detail::load_root_folders(app_state);\n  if (!root_folders_result) {\n    return std::unexpected(root_folders_result.error());\n  }\n\n  for (auto& asset : assets) {\n    auto assign_result = Detail::try_assign_locator_from_roots(root_folders_result.value(), asset);\n    if (!assign_result) {\n      return std::unexpected(assign_result.error());\n    }\n  }\n\n  return {};\n}\n\n// 根据 root_id + relative_path 还原真实磁盘路径。\n// dev 浏览器模式下的 HTTP originals resolver 会通过它把 URL 映射回文件系统。\nauto resolve_original_file_path(Core::State::AppState& app_state, std::int64_t root_id,\n                                std::string_view relative_path)\n    -> std::expected<std::filesystem::path, std::string> {\n  if (root_id <= 0) {\n    return std::unexpected(\"Original root id must be greater than 0\");\n  }\n\n  if (relative_path.empty()) {\n    return std::unexpected(\"Original relative path is empty\");\n  }\n\n  auto folder_result = Features::Gallery::Folder::Repository::get_folder_by_id(app_state, root_id);\n  if (!folder_result) {\n    return std::unexpected(\"Failed to query original root folder: \" + folder_result.error());\n  }\n  if (!folder_result->has_value()) {\n    return std::unexpected(\"Original root folder not found\");\n  }\n\n  const auto& folder = folder_result->value();\n  if (folder.parent_id.has_value()) {\n    return std::unexpected(\"Original root id does not refer to a root folder\");\n  }\n\n  std::filesystem::path relative_path_value(relative_path);\n  if (relative_path_value.is_absolute()) {\n    return std::unexpected(\"Original relative path must not be absolute\");\n  }\n\n  auto normalized_root_result = Utils::Path::NormalizePath(std::filesystem::path(folder.path));\n  if (!normalized_root_result) {\n    return std::unexpected(\"Failed to normalize original root folder path: \" +\n                           normalized_root_result.error());\n  }\n\n  auto normalized_root_path = normalized_root_result.value();\n  auto candidate_path = (normalized_root_path / relative_path_value).lexically_normal();\n  if (!Utils::Path::IsPathWithinBase(candidate_path, normalized_root_path)) {\n    return std::unexpected(\"Original relative path escapes root folder\");\n  }\n\n  return candidate_path;\n}\n\n}  // namespace Features::Gallery::OriginalLocator\n"
  },
  {
    "path": "src/features/gallery/original_locator.ixx",
    "content": "module;\n\nexport module Features.Gallery.OriginalLocator;\n\nimport std;\nimport Core.State;\nimport Features.Gallery.Types;\n\nnamespace Features::Gallery::OriginalLocator {\n\nexport auto make_root_host_name(std::int64_t root_id) -> std::wstring;\n\nexport auto populate_asset_locators(Core::State::AppState& app_state,\n                                    std::vector<Types::Asset>& assets)\n    -> std::expected<void, std::string>;\n\nexport auto resolve_original_file_path(Core::State::AppState& app_state, std::int64_t root_id,\n                                       std::string_view relative_path)\n    -> std::expected<std::filesystem::path, std::string>;\n\n}  // namespace Features::Gallery::OriginalLocator\n"
  },
  {
    "path": "src/features/gallery/recovery/repository.cpp",
    "content": "module;\n\nmodule Features.Gallery.Recovery.Repository;\n\nimport std;\nimport Core.State;\nimport Core.Database;\nimport Core.Database.Types;\nimport Features.Gallery.Recovery.Types;\n\nnamespace Features::Gallery::Recovery::Repository {\n\nauto get_state_by_root_path(Core::State::AppState& app_state, const std::string& root_path)\n    -> std::expected<std::optional<Types::WatchRootRecoveryState>, std::string> {\n  // 按 root_path 查询上次保存的恢复检查点。\n  std::string sql = R\"(\n    SELECT root_path, volume_identity, journal_id, checkpoint_usn, rule_fingerprint, updated_at\n    FROM watch_root_recovery_state\n    WHERE root_path = ?\n  )\";\n\n  auto result = Core::Database::query_single<Types::WatchRootRecoveryState>(*app_state.database,\n                                                                            sql, {root_path});\n  if (!result) {\n    return std::unexpected(\"Failed to query watch root recovery state: \" + result.error());\n  }\n\n  return result.value();\n}\n\nauto upsert_state(Core::State::AppState& app_state, const Types::WatchRootRecoveryState& state)\n    -> std::expected<void, std::string> {\n  // root_path 是主键，重复写入时直接覆盖为最新检查点。\n  std::string sql = R\"(\n    INSERT INTO watch_root_recovery_state (\n      root_path, volume_identity, journal_id, checkpoint_usn, rule_fingerprint, updated_at\n    ) VALUES (?, ?, ?, ?, ?, (unixepoch('subsec') * 1000))\n    ON CONFLICT(root_path) DO UPDATE SET\n      volume_identity = excluded.volume_identity,\n      journal_id = excluded.journal_id,\n      checkpoint_usn = excluded.checkpoint_usn,\n      rule_fingerprint = excluded.rule_fingerprint,\n      updated_at = (unixepoch('subsec') * 1000)\n  )\";\n\n  std::vector<Core::Database::Types::DbParam> params = {\n      state.root_path,\n      state.volume_identity,\n      state.journal_id.has_value() ? Core::Database::Types::DbParam{state.journal_id.value()}\n                                   : Core::Database::Types::DbParam{std::monostate{}},\n      state.checkpoint_usn.has_value()\n          ? Core::Database::Types::DbParam{state.checkpoint_usn.value()}\n          : Core::Database::Types::DbParam{std::monostate{}},\n      state.rule_fingerprint,\n  };\n\n  auto result = Core::Database::execute(*app_state.database, sql, params);\n  if (!result) {\n    return std::unexpected(\"Failed to upsert watch root recovery state: \" + result.error());\n  }\n\n  return {};\n}\n\nauto delete_state_by_root_path(Core::State::AppState& app_state, const std::string& root_path)\n    -> std::expected<void, std::string> {\n  // root 被移除时清理对应的恢复状态。\n  auto result = Core::Database::execute(*app_state.database,\n                                        \"DELETE FROM watch_root_recovery_state WHERE root_path = ?\",\n                                        {root_path});\n  if (!result) {\n    return std::unexpected(\"Failed to delete watch root recovery state: \" + result.error());\n  }\n\n  return {};\n}\n\n}  // namespace Features::Gallery::Recovery::Repository\n"
  },
  {
    "path": "src/features/gallery/recovery/repository.ixx",
    "content": "module;\n\nexport module Features.Gallery.Recovery.Repository;\n\nimport std;\nimport Core.State;\nexport import Features.Gallery.Recovery.Types;\n\nnamespace Features::Gallery::Recovery::Repository {\n\nexport auto get_state_by_root_path(Core::State::AppState& app_state, const std::string& root_path)\n    -> std::expected<std::optional<Types::WatchRootRecoveryState>, std::string>;\n\nexport auto upsert_state(Core::State::AppState& app_state,\n                         const Types::WatchRootRecoveryState& state)\n    -> std::expected<void, std::string>;\n\nexport auto delete_state_by_root_path(Core::State::AppState& app_state,\n                                      const std::string& root_path)\n    -> std::expected<void, std::string>;\n\n}  // namespace Features::Gallery::Recovery::Repository\n"
  },
  {
    "path": "src/features/gallery/recovery/service.cpp",
    "content": "module;\n\n#include <windows.h>\n#include <winioctl.h>\n\nmodule Features.Gallery.Recovery.Service;\n\nimport std;\nimport Core.State;\nimport Features.Gallery.State;\nimport Features.Gallery.Types;\nimport Features.Gallery.Recovery.Types;\nimport Features.Gallery.Recovery.Repository;\nimport Features.Gallery.Ignore.Repository;\nimport Features.Gallery.ScanCommon;\nimport Features.Gallery.Asset.Repository;\nimport Utils.Logger;\nimport Utils.Path;\nimport Utils.String;\n\nnamespace Features::Gallery::Recovery::Service::Detail {\n\n// RAII 句柄包装，避免提前 return 时泄漏 HANDLE。\nstruct UniqueHandle {\n  HANDLE value{INVALID_HANDLE_VALUE};\n\n  UniqueHandle() = default;\n  explicit UniqueHandle(HANDLE handle) : value(handle) {}\n\n  UniqueHandle(const UniqueHandle&) = delete;\n  auto operator=(const UniqueHandle&) -> UniqueHandle& = delete;\n\n  UniqueHandle(UniqueHandle&& other) noexcept : value(other.value) {\n    other.value = INVALID_HANDLE_VALUE;\n  }\n\n  auto operator=(UniqueHandle&& other) noexcept -> UniqueHandle& {\n    if (this == &other) {\n      return *this;\n    }\n    reset();\n    value = other.value;\n    other.value = INVALID_HANDLE_VALUE;\n    return *this;\n  }\n\n  ~UniqueHandle() { reset(); }\n\n  auto reset(HANDLE handle = INVALID_HANDLE_VALUE) -> void {\n    if (value != INVALID_HANDLE_VALUE && value != nullptr) {\n      CloseHandle(value);\n    }\n    value = handle;\n  }\n\n  [[nodiscard]] auto valid() const -> bool {\n    return value != INVALID_HANDLE_VALUE && value != nullptr;\n  }\n};\n\nstruct JournalSnapshot {\n  // available=false 不代表出错，只是表示当前 root 不支持 USN 恢复（如非 NTFS、网络盘等）。\n  bool available = false;\n  std::string reason;\n  std::wstring volume_root;\n  std::wstring volume_device_path;\n  std::string volume_identity;\n  std::int64_t journal_id = 0;\n  std::int64_t next_usn = 0;\n};\n\nstruct UsnRecordView {\n  // 轻量视图，只保留 recovery 实际需要的字段，屏蔽 Win32 结构体细节。\n  std::int64_t file_reference_number = 0;\n  std::int64_t parent_file_reference_number = 0;\n  std::int64_t usn = 0;\n  DWORD reason = 0;\n  DWORD file_attributes = 0;\n  std::filesystem::path file_name;\n};\n\nauto normalize_existing_path(const std::filesystem::path& path) -> std::filesystem::path {\n  // 跨多数据源（DB、USN、文件系统）比较路径时，统一解析为标准形态，消除大小写 / 分隔符差异。\n  std::error_code ec;\n  if (std::filesystem::exists(path, ec) && !ec) {\n    auto normalized = std::filesystem::weakly_canonical(path, ec);\n    if (!ec) {\n      return normalized;\n    }\n  }\n  return path.lexically_normal();\n}\n\nauto make_path_compare_key(const std::filesystem::path& path) -> std::string {\n  // NTFS 路径大小写不敏感，统一转小写 generic path 用于比较。\n  auto normalized = normalize_existing_path(path).generic_wstring();\n  std::transform(normalized.begin(), normalized.end(), normalized.begin(),\n                 [](wchar_t ch) { return static_cast<wchar_t>(std::towlower(ch)); });\n  return Utils::String::ToUtf8(normalized);\n}\n\nauto is_path_under_root(const std::filesystem::path& path, const std::string& root_key) -> bool {\n  // USN 是按卷读取的，记录可能属于同卷上其他目录。\n  // 这里根据预计算的 root_key 过滤，只保留当前监视根目录下的路径。\n  auto path_key = make_path_compare_key(path);\n  if (path_key == root_key) {\n    return true;\n  }\n  if (!path_key.starts_with(root_key)) {\n    return false;\n  }\n  return path_key.size() > root_key.size() && path_key[root_key.size()] == '/';\n}\n\nauto strip_extended_path_prefix(std::wstring value) -> std::wstring {\n  // GetFinalPathNameByHandleW 返回的路径常带 \\\\?\\ 或 \\\\?\\UNC\\ 前缀，去掉以保持一致。\n  constexpr std::wstring_view extended_prefix = L\"\\\\\\\\?\\\\\";\n  constexpr std::wstring_view unc_prefix = L\"UNC\\\\\";\n  if (value.starts_with(extended_prefix)) {\n    value.erase(0, extended_prefix.size());\n    if (value.starts_with(unc_prefix)) {\n      value.erase(0, unc_prefix.size());\n      value.insert(0, L\"\\\\\\\\\");\n    }\n  }\n  return value;\n}\n\nauto get_volume_root_for_path(const std::filesystem::path& path)\n    -> std::expected<std::wstring, std::string> {\n  auto path_w = normalize_existing_path(path).wstring();\n  std::wstring buffer(MAX_PATH, L'\\0');\n  if (!GetVolumePathNameW(path_w.c_str(), buffer.data(), static_cast<DWORD>(buffer.size()))) {\n    return std::unexpected(\"GetVolumePathNameW failed: \" + std::to_string(GetLastError()));\n  }\n  buffer.resize(std::wcslen(buffer.c_str()));\n  return buffer;\n}\n\nauto make_volume_device_path(const std::wstring& volume_root)\n    -> std::expected<std::wstring, std::string> {\n  if (volume_root.size() >= 2 && volume_root[1] == L':') {\n    return std::wstring{L\"\\\\\\\\.\\\\\"} + volume_root.substr(0, 2);\n  }\n  return std::unexpected(\"Only drive-letter NTFS volumes are supported for USN recovery\");\n}\n\nauto open_volume_handle(const std::wstring& volume_device_path)\n    -> std::expected<UniqueHandle, std::string> {\n  HANDLE handle = CreateFileW(volume_device_path.c_str(), GENERIC_READ,\n                              FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, nullptr,\n                              OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS, nullptr);\n  if (handle == INVALID_HANDLE_VALUE) {\n    return std::unexpected(\"Failed to open volume handle: \" + std::to_string(GetLastError()));\n  }\n  return UniqueHandle{handle};\n}\n\n// 查询指定 root 所在卷的 USN Journal 快照。root_path 必须已归一化。\nauto query_journal_snapshot(const std::filesystem::path& root_path)\n    -> std::expected<JournalSnapshot, std::string> {\n  JournalSnapshot snapshot;\n\n  auto volume_root_result = get_volume_root_for_path(root_path);\n  if (!volume_root_result) {\n    return std::unexpected(volume_root_result.error());\n  }\n  snapshot.volume_root = volume_root_result.value();\n\n  // v1 只支持本地 NTFS 卷，网络盘 / 非 NTFS 直接视为不支持 USN。\n  if (GetDriveTypeW(snapshot.volume_root.c_str()) == DRIVE_REMOTE) {\n    snapshot.reason = \"remote volume does not expose a local USN journal\";\n    return snapshot;\n  }\n\n  wchar_t file_system_name[MAX_PATH]{};\n  DWORD volume_serial_number = 0;\n  if (!GetVolumeInformationW(snapshot.volume_root.c_str(), nullptr, 0, &volume_serial_number,\n                             nullptr, nullptr, file_system_name, MAX_PATH)) {\n    return std::unexpected(\"GetVolumeInformationW failed: \" + std::to_string(GetLastError()));\n  }\n\n  auto file_system =\n      Utils::String::ToLowerAscii(Utils::String::ToUtf8(std::wstring(file_system_name)));\n  if (file_system != \"ntfs\") {\n    snapshot.reason = \"only NTFS volumes use USN startup recovery\";\n    return snapshot;\n  }\n\n  // 查询当前 Journal 的 ID 和 next_usn，启动恢复将用它们与 DB 中保存的检查点做比对。\n  auto volume_device_path_result = make_volume_device_path(snapshot.volume_root);\n  if (!volume_device_path_result) {\n    snapshot.reason = volume_device_path_result.error();\n    return snapshot;\n  }\n  snapshot.volume_device_path = volume_device_path_result.value();\n\n  auto volume_handle_result = open_volume_handle(snapshot.volume_device_path);\n  if (!volume_handle_result) {\n    snapshot.reason = volume_handle_result.error();\n    return snapshot;\n  }\n\n  USN_JOURNAL_DATA_V1 journal_data{};\n  DWORD bytes_returned = 0;\n  if (!DeviceIoControl(volume_handle_result->value, FSCTL_QUERY_USN_JOURNAL, nullptr, 0,\n                       &journal_data, sizeof(journal_data), &bytes_returned, nullptr)) {\n    snapshot.reason = \"FSCTL_QUERY_USN_JOURNAL failed: \" + std::to_string(GetLastError());\n    return snapshot;\n  }\n\n  snapshot.available = true;\n  snapshot.volume_identity = std::format(\"ntfs:{:08X}\", volume_serial_number);\n  snapshot.journal_id = static_cast<std::int64_t>(journal_data.UsnJournalID);\n  snapshot.next_usn = static_cast<std::int64_t>(journal_data.NextUsn);\n  return snapshot;\n}\n\n// 加载指定 root 的全部忽略规则（global + root-specific）。root_path 必须已归一化。\nauto get_root_rules(Core::State::AppState& app_state, const std::filesystem::path& root_path)\n    -> std::expected<std::vector<Features::Gallery::Types::IgnoreRule>, std::string> {\n  // fingerprint 需要覆盖所有影响扫描结果的规则，因此合并 global 和 root-specific 规则。\n  auto global_rules_result = Features::Gallery::Ignore::Repository::get_global_rules(app_state);\n  if (!global_rules_result) {\n    return std::unexpected(global_rules_result.error());\n  }\n\n  auto root_rules_result = Features::Gallery::Ignore::Repository::get_rules_by_directory_path(\n      app_state, root_path.string());\n  if (!root_rules_result) {\n    return std::unexpected(root_rules_result.error());\n  }\n\n  auto rules = std::move(global_rules_result.value());\n  auto root_rules = std::move(root_rules_result.value());\n  rules.insert(rules.end(), std::make_move_iterator(root_rules.begin()),\n               std::make_move_iterator(root_rules.end()));\n  return rules;\n}\n\nauto make_rule_fingerprint(Core::State::AppState& app_state, const std::filesystem::path& root_path,\n                           const Features::Gallery::Types::ScanOptions& scan_options)\n    -> std::expected<std::string, std::string> {\n  // v1 不需要加密哈希，稳定排序后拼为文本即可。目标是检测扫描规则是否变化。\n  std::vector<std::string> lines;\n\n  auto supported_extensions = scan_options.supported_extensions.has_value()\n                                  ? *scan_options.supported_extensions\n                                  : Features::Gallery::ScanCommon::default_supported_extensions();\n  for (auto& extension : supported_extensions) {\n    extension = Utils::String::ToLowerAscii(extension);\n  }\n  std::ranges::sort(supported_extensions);\n  for (const auto& extension : supported_extensions) {\n    lines.push_back(\"ext:\" + extension);\n  }\n\n  auto rules_result = get_root_rules(app_state, root_path);\n  if (!rules_result) {\n    return std::unexpected(\"Failed to build rule fingerprint: \" + rules_result.error());\n  }\n\n  for (const auto& rule : rules_result.value()) {\n    auto scope = rule.folder_id.has_value() ? \"root\" : \"global\";\n    lines.push_back(std::format(\"rule:{}|{}|{}|{}\", scope, rule.pattern_type, rule.rule_type,\n                                rule.rule_pattern));\n  }\n\n  lines.push_back(\"scan_semantics:v1\");\n  std::ranges::sort(lines);\n\n  std::string fingerprint;\n  for (const auto& line : lines) {\n    fingerprint += line;\n    fingerprint.push_back('\\n');\n  }\n  return fingerprint;\n}\n\nauto make_file_id_descriptor(std::int64_t file_reference_number) -> FILE_ID_DESCRIPTOR {\n  FILE_ID_DESCRIPTOR descriptor{};\n  descriptor.dwSize = sizeof(descriptor);\n  descriptor.Type = FileIdType;\n  descriptor.FileId.QuadPart = static_cast<LONGLONG>(file_reference_number);\n  return descriptor;\n}\n\nauto resolve_path_by_file_reference(\n    HANDLE volume_handle, std::int64_t file_reference_number,\n    std::unordered_map<std::int64_t, std::optional<std::filesystem::path>>& cache)\n    -> std::expected<std::optional<std::filesystem::path>, std::string> {\n  // USN 记录通常只给 file reference number (FRN)，\n  // 需要通过 OpenFileById 将 FRN 解析为绝对路径，才能供 gallery 增量链路使用。\n  if (auto it = cache.find(file_reference_number); it != cache.end()) {\n    return it->second;\n  }\n\n  auto descriptor = make_file_id_descriptor(file_reference_number);\n  HANDLE handle = OpenFileById(volume_handle, &descriptor, FILE_READ_ATTRIBUTES,\n                               FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, nullptr,\n                               FILE_FLAG_BACKUP_SEMANTICS);\n  // 文件已删除或无权限访问时 OpenFileById 会失败。\n  // USN 按卷读取，常遇到回收站、System Volume Information 等受保护路径，\n  // 这些通常不是 gallery 关心的资源，与 FILE_NOT_FOUND 同等处理即可。\n  if (handle == INVALID_HANDLE_VALUE) {\n    auto error = GetLastError();\n    if (error == ERROR_FILE_NOT_FOUND || error == ERROR_PATH_NOT_FOUND ||\n        error == ERROR_INVALID_PARAMETER || error == ERROR_ACCESS_DENIED) {\n      cache[file_reference_number] = std::nullopt;\n      return std::optional<std::filesystem::path>{};\n    }\n    return std::unexpected(\"OpenFileById failed: \" + std::to_string(error));\n  }\n\n  UniqueHandle scoped_handle{handle};\n\n  std::wstring buffer(MAX_PATH, L'\\0');\n  DWORD length = GetFinalPathNameByHandleW(scoped_handle.value, buffer.data(),\n                                           static_cast<DWORD>(buffer.size()), FILE_NAME_NORMALIZED);\n  if (length == 0) {\n    return std::unexpected(\"GetFinalPathNameByHandleW failed: \" + std::to_string(GetLastError()));\n  }\n  if (length >= buffer.size()) {\n    buffer.resize(length + 1, L'\\0');\n    length = GetFinalPathNameByHandleW(scoped_handle.value, buffer.data(),\n                                       static_cast<DWORD>(buffer.size()), FILE_NAME_NORMALIZED);\n    if (length == 0) {\n      return std::unexpected(\"GetFinalPathNameByHandleW failed: \" + std::to_string(GetLastError()));\n    }\n  }\n\n  buffer.resize(length);\n  auto resolved_path =\n      normalize_existing_path(std::filesystem::path(strip_extended_path_prefix(buffer)));\n  cache[file_reference_number] = resolved_path;\n  return resolved_path;\n}\n\nauto parse_usn_record(const std::byte* record_bytes) -> std::expected<UsnRecordView, std::string> {\n  // 先只处理 V2 记录，其他版本明确拒绝以避免误解析。\n  auto* record_header = reinterpret_cast<const USN_RECORD_COMMON_HEADER*>(record_bytes);\n  if (record_header->MajorVersion != 2) {\n    return std::unexpected(\"Only USN_RECORD_V2 is supported in v1 startup recovery\");\n  }\n\n  auto* record = reinterpret_cast<const USN_RECORD_V2*>(record_bytes);\n  auto file_name_chars = record->FileNameLength / sizeof(WCHAR);\n  auto file_name_ptr = reinterpret_cast<const wchar_t*>(reinterpret_cast<const std::byte*>(record) +\n                                                        record->FileNameOffset);\n\n  return UsnRecordView{\n      .file_reference_number = static_cast<std::int64_t>(record->FileReferenceNumber),\n      .parent_file_reference_number = static_cast<std::int64_t>(record->ParentFileReferenceNumber),\n      .usn = static_cast<std::int64_t>(record->Usn),\n      .reason = record->Reason,\n      .file_attributes = record->FileAttributes,\n      .file_name = std::filesystem::path(std::wstring(file_name_ptr, file_name_chars)),\n  };\n}\n\nauto is_directory_record(const UsnRecordView& record) -> bool {\n  return (record.file_attributes & FILE_ATTRIBUTE_DIRECTORY) != 0;\n}\n\nauto resolve_parent_based_path(\n    HANDLE volume_handle, const UsnRecordView& record,\n    std::unordered_map<std::int64_t, std::optional<std::filesystem::path>>& path_cache)\n    -> std::expected<std::optional<std::filesystem::path>, std::string> {\n  // 删除 / rename-old-name 时目标文件可能已不存在，\n  // 只能通过父目录路径 + 文件名来重建旧路径。\n  auto parent_path_result = resolve_path_by_file_reference(\n      volume_handle, record.parent_file_reference_number, path_cache);\n  if (!parent_path_result) {\n    return std::unexpected(parent_path_result.error());\n  }\n  if (!parent_path_result->has_value()) {\n    return std::optional<std::filesystem::path>{};\n  }\n\n  return parent_path_result->value() / record.file_name;\n}\n\nauto add_or_replace_change(\n    std::unordered_map<std::string, Features::Gallery::Types::ScanChangeAction>& changes_by_path,\n    const std::filesystem::path& path, Features::Gallery::Types::ScanChangeAction action) -> void {\n  // 同一路径离线期间可能被多次修改，只保留最终需要达到的状态。\n  changes_by_path[normalize_existing_path(path).string()] = action;\n}\n\nauto directory_record_requires_full_rescan(Core::State::AppState& app_state,\n                                           const std::filesystem::path& directory_path,\n                                           DWORD reason) -> std::expected<bool, std::string> {\n  // 目录“创建”本身不会让已有资产失真：后续真正重要的是里面是否出现文件记录。\n  // 只有目录“删除/改名”才可能让数据库里已经存在的旧路径整体失效。\n  if ((reason &\n       (USN_REASON_FILE_DELETE | USN_REASON_RENAME_OLD_NAME | USN_REASON_RENAME_NEW_NAME)) == 0) {\n    return false;\n  }\n\n  // 如果这个目录前缀下根本没有已入库资产，就没必要因为它回退 full scan。\n  return Features::Gallery::Asset::Repository::has_assets_under_path_prefix(\n      app_state, normalize_existing_path(directory_path).string());\n}\n\nauto collect_usn_changes(Core::State::AppState& app_state, const std::filesystem::path& root_path,\n                         std::int64_t start_usn, const JournalSnapshot& snapshot)\n    -> std::expected<std::vector<Features::Gallery::Types::ScanChange>, std::string> {\n  // 核心流程：从上次检查点 start_usn 开始读取 Journal，\n  // 筛出当前 root 下的文件变更，翻译成 ScanChange 列表。\n  if (start_usn >= snapshot.next_usn) {\n    return std::vector<Features::Gallery::Types::ScanChange>{};\n  }\n\n  auto volume_handle_result = open_volume_handle(snapshot.volume_device_path);\n  if (!volume_handle_result) {\n    return std::unexpected(volume_handle_result.error());\n  }\n\n  auto normalized_root = normalize_existing_path(root_path);\n  auto root_key = make_path_compare_key(normalized_root);\n  std::unordered_map<std::int64_t, std::optional<std::filesystem::path>> path_cache;\n  std::unordered_map<std::string, Features::Gallery::Types::ScanChangeAction> changes_by_path;\n\n  constexpr DWORD kBufferSize = 64 * 1024;\n  std::vector<std::byte> buffer(kBufferSize);\n\n  // READ_USN_JOURNAL_DATA_V1 指定读取起点、目标 Journal、及可接受的记录版本。\n  READ_USN_JOURNAL_DATA_V1 read_data{};\n  read_data.StartUsn = static_cast<USN>(start_usn);\n  read_data.ReasonMask = 0xFFFFFFFF;\n  read_data.ReturnOnlyOnClose = FALSE;\n  read_data.Timeout = 0;\n  read_data.BytesToWaitFor = 0;\n  read_data.UsnJournalID = static_cast<DWORDLONG>(snapshot.journal_id);\n  read_data.MinMajorVersion = 2;\n  read_data.MaxMajorVersion = 2;\n\n  while (static_cast<std::int64_t>(read_data.StartUsn) < snapshot.next_usn) {\n    DWORD bytes_returned = 0;\n    if (!DeviceIoControl(volume_handle_result->value, FSCTL_READ_USN_JOURNAL, &read_data,\n                         sizeof(read_data), buffer.data(), static_cast<DWORD>(buffer.size()),\n                         &bytes_returned, nullptr)) {\n      auto error = GetLastError();\n      if (error == ERROR_HANDLE_EOF) {\n        break;\n      }\n      return std::unexpected(\"FSCTL_READ_USN_JOURNAL failed: \" + std::to_string(error));\n    }\n\n    if (bytes_returned < sizeof(USN)) {\n      break;\n    }\n\n    auto next_start_usn = *reinterpret_cast<const USN*>(buffer.data());\n    std::size_t offset = sizeof(USN);\n\n    while (offset + sizeof(USN_RECORD_COMMON_HEADER) <= bytes_returned) {\n      auto* record_base = buffer.data() + offset;\n      auto* common_header = reinterpret_cast<const USN_RECORD_COMMON_HEADER*>(record_base);\n      if (common_header->RecordLength == 0 ||\n          offset + common_header->RecordLength > bytes_returned) {\n        break;\n      }\n\n      auto record_result = parse_usn_record(record_base);\n      if (!record_result) {\n        return std::unexpected(record_result.error());\n      }\n\n      const auto& record = record_result.value();\n      if (record.usn > snapshot.next_usn) {\n        break;\n      }\n\n      std::optional<std::filesystem::path> candidate_path;\n      if ((record.reason & (USN_REASON_FILE_DELETE | USN_REASON_RENAME_OLD_NAME)) != 0) {\n        auto parent_based_result =\n            resolve_parent_based_path(volume_handle_result->value, record, path_cache);\n        if (!parent_based_result) {\n          return std::unexpected(parent_based_result.error());\n        }\n        candidate_path = parent_based_result.value();\n      } else {\n        auto resolved_path_result = resolve_path_by_file_reference(\n            volume_handle_result->value, record.file_reference_number, path_cache);\n        if (!resolved_path_result) {\n          return std::unexpected(resolved_path_result.error());\n        }\n        candidate_path = resolved_path_result.value();\n      }\n\n      if (!candidate_path.has_value() || !is_path_under_root(*candidate_path, root_key)) {\n        offset += common_header->RecordLength;\n        continue;\n      }\n\n      // 这里看到的是“目录记录”而不是“文件记录”。\n      // 我们不再像以前那样一刀切 full scan，\n      // 而是只在它真的影响到已入库资产路径时才保守回退。\n      if (is_directory_record(record)) {\n        auto require_full_scan_result =\n            directory_record_requires_full_rescan(app_state, *candidate_path, record.reason);\n        if (!require_full_scan_result) {\n          return std::unexpected(require_full_scan_result.error());\n        }\n        if (require_full_scan_result.value()) {\n          return std::unexpected(\"Directory structural changes require a full rescan\");\n        }\n\n        offset += common_header->RecordLength;\n        continue;\n      }\n\n      if ((record.reason & (USN_REASON_FILE_DELETE | USN_REASON_RENAME_OLD_NAME)) != 0) {\n        add_or_replace_change(changes_by_path, *candidate_path,\n                              Features::Gallery::Types::ScanChangeAction::REMOVE);\n      }\n\n      if ((record.reason & (USN_REASON_FILE_CREATE | USN_REASON_DATA_OVERWRITE |\n                            USN_REASON_DATA_EXTEND | USN_REASON_DATA_TRUNCATION |\n                            USN_REASON_BASIC_INFO_CHANGE | USN_REASON_RENAME_NEW_NAME)) != 0) {\n        add_or_replace_change(changes_by_path, *candidate_path,\n                              Features::Gallery::Types::ScanChangeAction::UPSERT);\n      }\n\n      offset += common_header->RecordLength;\n    }\n\n    if (next_start_usn <= read_data.StartUsn) {\n      break;\n    }\n    read_data.StartUsn = next_start_usn;\n  }\n\n  std::vector<Features::Gallery::Types::ScanChange> changes;\n  changes.reserve(changes_by_path.size());\n  for (const auto& [path, action] : changes_by_path) {\n    changes.push_back(Features::Gallery::Types::ScanChange{.path = path, .action = action});\n  }\n  std::ranges::sort(changes, [](const auto& lhs, const auto& rhs) { return lhs.path < rhs.path; });\n  return changes;\n}\n\n}  // namespace Features::Gallery::Recovery::Service::Detail\n\nnamespace Features::Gallery::Recovery::Service {\n\nauto prepare_startup_recovery(Core::State::AppState& app_state,\n                              const std::filesystem::path& root_path,\n                              const Features::Gallery::Types::ScanOptions& scan_options)\n    -> std::expected<Types::StartupRecoveryPlan, std::string> {\n  Types::StartupRecoveryPlan plan;\n\n  // 返回启动恢复决策：当前 root 应走 USN 增量还是 FullScan？\n  // 只做决策，不启动 watcher。\n\n  auto normalized_root_result = Utils::Path::NormalizePath(root_path);\n  if (!normalized_root_result) {\n    return std::unexpected(\"Failed to normalize root path: \" + normalized_root_result.error());\n  }\n  plan.root_path = normalized_root_result->string();\n\n  auto journal_snapshot_result = Detail::query_journal_snapshot(*normalized_root_result);\n  if (!journal_snapshot_result) {\n    return std::unexpected(\"Failed to inspect journal capability: \" +\n                           journal_snapshot_result.error());\n  }\n\n  const auto& snapshot = journal_snapshot_result.value();\n\n  // 无论最终走 USN 还是 FullScan，卷快照信息都需要填入 plan，\n  // 便于上层在恢复完成后直接持久化。\n  plan.volume_identity = snapshot.volume_identity;\n  plan.journal_id = snapshot.journal_id;\n  plan.checkpoint_usn = snapshot.next_usn;\n\n  if (!snapshot.available) {\n    // 不支持 USN 不是错误，只是正常的 FullScan 回退路径。\n    plan.mode = Types::StartupRecoveryMode::FullScan;\n    plan.reason =\n        snapshot.reason.empty() ? \"journal is not available for this root\" : snapshot.reason;\n    return plan;\n  }\n\n  auto fingerprint_result =\n      Detail::make_rule_fingerprint(app_state, *normalized_root_result, scan_options);\n  if (!fingerprint_result) {\n    return std::unexpected(fingerprint_result.error());\n  }\n  plan.rule_fingerprint = *fingerprint_result;\n\n  auto stored_state_result =\n      Repository::get_state_by_root_path(app_state, normalized_root_result->string());\n  if (!stored_state_result) {\n    return std::unexpected(stored_state_result.error());\n  }\n\n  if (!stored_state_result->has_value()) {\n    // 没有历史检查点，可能是首次运行或旧状态已丢失，只能全量扫描。\n    plan.mode = Types::StartupRecoveryMode::FullScan;\n    plan.reason = \"no persisted recovery checkpoint\";\n    return plan;\n  }\n\n  const auto& stored_state = stored_state_result->value();\n  if (stored_state.volume_identity != snapshot.volume_identity) {\n    plan.mode = Types::StartupRecoveryMode::FullScan;\n    plan.reason = \"volume identity changed\";\n    return plan;\n  }\n\n  if (!stored_state.journal_id.has_value() || *stored_state.journal_id != snapshot.journal_id) {\n    plan.mode = Types::StartupRecoveryMode::FullScan;\n    plan.reason = \"journal identity changed\";\n    return plan;\n  }\n\n  if (stored_state.rule_fingerprint != *fingerprint_result) {\n    plan.mode = Types::StartupRecoveryMode::FullScan;\n    plan.reason = \"scan rules changed\";\n    return plan;\n  }\n\n  if (!stored_state.checkpoint_usn.has_value()) {\n    plan.mode = Types::StartupRecoveryMode::FullScan;\n    plan.reason = \"checkpoint is missing\";\n    return plan;\n  }\n\n  auto changes_result = Detail::collect_usn_changes(app_state, *normalized_root_result,\n                                                    *stored_state.checkpoint_usn, snapshot);\n  if (!changes_result) {\n    // 离线追账出现不确定情况，宁可回退全量扫描也不猜测增量。\n    plan.mode = Types::StartupRecoveryMode::FullScan;\n    plan.reason = \"USN recovery fallback: \" + changes_result.error();\n    return plan;\n  }\n\n  plan.mode = Types::StartupRecoveryMode::UsnJournal;\n  plan.reason = \"USN recovery is available\";\n  plan.changes = std::move(changes_result.value());\n  return plan;\n}\n\nauto persist_recovery_checkpoint(Core::State::AppState& app_state,\n                                 const std::filesystem::path& root_path,\n                                 const Features::Gallery::Types::ScanOptions& scan_options,\n                                 std::optional<std::int64_t> checkpoint_usn)\n    -> std::expected<void, std::string> {\n  // 正常退出时保存检查点。需查询当前 journal 快照和规则指纹。\n  // 启动恢复路径应使用轻量的 persist_recovery_state。\n  auto normalized_root_result = Utils::Path::NormalizePath(root_path);\n  if (!normalized_root_result) {\n    return std::unexpected(\"Failed to normalize root path: \" + normalized_root_result.error());\n  }\n\n  auto journal_snapshot_result = Detail::query_journal_snapshot(*normalized_root_result);\n  if (!journal_snapshot_result) {\n    return std::unexpected(journal_snapshot_result.error());\n  }\n\n  const auto& snapshot = journal_snapshot_result.value();\n  if (!snapshot.available) {\n    return {};\n  }\n\n  auto fingerprint_result =\n      Detail::make_rule_fingerprint(app_state, *normalized_root_result, scan_options);\n  if (!fingerprint_result) {\n    return std::unexpected(fingerprint_result.error());\n  }\n\n  Types::WatchRootRecoveryState state{\n      .root_path = normalized_root_result->string(),\n      .volume_identity = snapshot.volume_identity,\n      .journal_id = snapshot.journal_id,\n      .checkpoint_usn = checkpoint_usn.has_value() ? checkpoint_usn\n                                                   : std::optional<std::int64_t>{snapshot.next_usn},\n      .rule_fingerprint = *fingerprint_result,\n  };\n\n  return Repository::upsert_state(app_state, state);\n}\n\nauto persist_recovery_state(Core::State::AppState& app_state,\n                            const Types::WatchRootRecoveryState& state)\n    -> std::expected<void, std::string> {\n  return Repository::upsert_state(app_state, state);\n}\n\nauto persist_registered_root_checkpoints(Core::State::AppState& app_state) -> void {\n  if (!app_state.gallery) {\n    return;\n  }\n\n  // 先复制 root 列表再逐个 persist，避免在持久化期间长时间占用 watcher 全局锁。\n\n  std::vector<std::pair<std::filesystem::path, Features::Gallery::Types::ScanOptions>> roots;\n  {\n    std::lock_guard<std::mutex> lock(app_state.gallery->folder_watchers_mutex);\n    roots.reserve(app_state.gallery->folder_watchers.size());\n    for (const auto& [_, watcher] : app_state.gallery->folder_watchers) {\n      std::lock_guard<std::mutex> pending_lock(watcher->pending_mutex);\n      roots.emplace_back(watcher->root_path, watcher->scan_options);\n    }\n  }\n\n  for (const auto& [root_path, scan_options] : roots) {\n    auto persist_result = persist_recovery_checkpoint(app_state, root_path, scan_options);\n    if (!persist_result) {\n      Logger().warn(\"Failed to persist gallery recovery checkpoint for '{}': {}\",\n                    root_path.string(), persist_result.error());\n    }\n  }\n}\n\n}  // namespace Features::Gallery::Recovery::Service\n"
  },
  {
    "path": "src/features/gallery/recovery/service.ixx",
    "content": "module;\n\nexport module Features.Gallery.Recovery.Service;\n\nimport std;\nimport Core.State;\nexport import Features.Gallery.Recovery.Types;\nimport Features.Gallery.Types;\n\nnamespace Features::Gallery::Recovery::Service {\n\n// 判断指定 root 启动时应走 USN 增量还是 FullScan，返回完整的恢复计划。\nexport auto prepare_startup_recovery(Core::State::AppState& app_state,\n                                     const std::filesystem::path& root_path,\n                                     const Features::Gallery::Types::ScanOptions& scan_options)\n    -> std::expected<Types::StartupRecoveryPlan, std::string>;\n\n// 正常退出时保存恢复检查点（需重新查询 journal 快照）。\nexport auto persist_recovery_checkpoint(Core::State::AppState& app_state,\n                                        const std::filesystem::path& root_path,\n                                        const Features::Gallery::Types::ScanOptions& scan_options,\n                                        std::optional<std::int64_t> checkpoint_usn = std::nullopt)\n    -> std::expected<void, std::string>;\n\n// 启动恢复专用的轻量 persist：plan 中已携带完整快照信息，无需再次查询 journal。\nexport auto persist_recovery_state(Core::State::AppState& app_state,\n                                   const Types::WatchRootRecoveryState& state)\n    -> std::expected<void, std::string>;\n\n// 应用退出时批量保存所有已注册 root 的恢复检查点。\nexport auto persist_registered_root_checkpoints(Core::State::AppState& app_state) -> void;\n\n}  // namespace Features::Gallery::Recovery::Service\n"
  },
  {
    "path": "src/features/gallery/recovery/types.ixx",
    "content": "module;\n\nexport module Features.Gallery.Recovery.Types;\n\nimport std;\nimport Features.Gallery.Types;\n\nnamespace Features::Gallery::Recovery::Types {\n\n// 持久化到 DB 的恢复检查点，记录\"下次启动时从哪里开始读 USN Journal\"。\nexport struct WatchRootRecoveryState {\n  std::string root_path;                       // 监视的根目录路径\n  std::string volume_identity;                 // 卷标识（如 \"ntfs:ABCD1234\"），用于检测磁盘更换\n  std::optional<std::int64_t> journal_id;      // USN Journal ID，Journal 被重建时会变化\n  std::optional<std::int64_t> checkpoint_usn;  // 上次读到的 USN 位置\n  std::string rule_fingerprint;                // 扫描规则指纹，规则变化时需要全量重扫\n  std::int64_t updated_at = 0;\n};\n\n// 启动恢复模式\nexport enum class StartupRecoveryMode {\n  UsnJournal,  // 读取 USN Journal 增量恢复离线期间的变更\n  FullScan,    // 无法增量恢复，回退到全量扫描\n};\n\n// 启动恢复决策结果。由 recovery service 生成，watcher 消费。\n// 同时携带当前卷快照信息，watcher 可直接用于 persist 而无需重新查询。\nexport struct StartupRecoveryPlan {\n  StartupRecoveryMode mode = StartupRecoveryMode::FullScan;\n  std::string reason;                                         // 决策原因（用于日志）\n  std::vector<Features::Gallery::Types::ScanChange> changes;  // USN 模式下收集到的离线变更\n  // 以下字段从当前卷快照中填充，供 watcher 在恢复完成后直接持久化检查点。\n  std::string root_path;\n  std::string volume_identity;\n  std::string rule_fingerprint;\n  std::optional<std::int64_t> journal_id;\n  std::optional<std::int64_t> checkpoint_usn;\n};\n\n}  // namespace Features::Gallery::Recovery::Types\n"
  },
  {
    "path": "src/features/gallery/scan_common.cpp",
    "content": "module;\n\nmodule Features.Gallery.ScanCommon;\n\nimport std;\nimport Utils.String;\nimport Vendor.BuildConfig;\nimport Vendor.XXHash;\n\nnamespace Features::Gallery::ScanCommon {\n\nauto default_supported_extensions() -> const std::vector<std::string>& {\n  // 与 web GalleryScanDialog 默认列表、RPC 扫描选项保持一致，避免前后端可扫范围不一致。\n  static const std::vector<std::string> kDefaultSupportedExtensions{\n      \".jpg\", \".jpeg\", \".png\", \".bmp\", \".webp\", \".tiff\", \".tif\",\n      \".mp4\", \".avi\",  \".mov\", \".mkv\", \".wmv\",  \".webm\"};\n  return kDefaultSupportedExtensions;\n}\n\nauto lower_extension(const std::filesystem::path& file_path) -> std::string {\n  if (!file_path.has_extension()) {\n    return {};\n  }\n  return Utils::String::ToLowerAscii(file_path.extension().string());\n}\n\nauto is_photo_extension(const std::string& extension) -> bool {\n  return extension == \".jpg\" || extension == \".jpeg\" || extension == \".png\" ||\n         extension == \".bmp\" || extension == \".webp\" || extension == \".tiff\" || extension == \".tif\";\n}\n\nauto is_video_extension(const std::string& extension) -> bool {\n  return extension == \".mp4\" || extension == \".avi\" || extension == \".mov\" || extension == \".mkv\" ||\n         extension == \".wmv\" || extension == \".webm\";\n}\n\nauto is_supported_file(const std::filesystem::path& file_path,\n                       const std::vector<std::string>& supported_extensions) -> bool {\n  auto extension = lower_extension(file_path);\n  if (extension.empty()) {\n    return false;\n  }\n\n  return std::ranges::find(supported_extensions, extension) != supported_extensions.end();\n}\n\nauto is_photo_file(const std::filesystem::path& file_path) -> bool {\n  return is_photo_extension(lower_extension(file_path));\n}\n\nauto detect_asset_type(const std::filesystem::path& file_path) -> std::string {\n  auto extension = lower_extension(file_path);\n  if (extension.empty()) {\n    return \"unknown\";\n  }\n\n  if (is_photo_extension(extension)) {\n    return \"photo\";\n  }\n\n  if (is_video_extension(extension)) {\n    return \"video\";\n  }\n\n  return \"unknown\";\n}\n\nauto calculate_file_hash(const std::filesystem::path& file_path)\n    -> std::expected<std::string, std::string> {\n  if (Vendor::BuildConfig::is_debug_build()) {\n    auto path_str = file_path.string();\n    auto hash = std::hash<std::string>{}(path_str);\n    return std::format(\"{:016x}\", hash);\n  }\n\n  std::ifstream file(file_path, std::ios::binary);\n  if (!file) {\n    return std::unexpected(\"Cannot open file for hashing: \" + file_path.string());\n  }\n\n  std::vector<char> buffer((std::istreambuf_iterator<char>(file)),\n                           std::istreambuf_iterator<char>());\n  if (buffer.empty()) {\n    return std::unexpected(\"File is empty: \" + file_path.string());\n  }\n\n  return Vendor::XXHash::HashCharVectorToHex(buffer);\n}\n\n}  // namespace Features::Gallery::ScanCommon\n"
  },
  {
    "path": "src/features/gallery/scan_common.ixx",
    "content": "module;\n\nexport module Features.Gallery.ScanCommon;\n\nimport std;\n\nnamespace Features::Gallery::ScanCommon {\n\nexport auto default_supported_extensions() -> const std::vector<std::string>&;\n\nexport auto is_supported_file(const std::filesystem::path& file_path,\n                              const std::vector<std::string>& supported_extensions) -> bool;\n\nexport auto is_photo_file(const std::filesystem::path& file_path) -> bool;\n\nexport auto detect_asset_type(const std::filesystem::path& file_path) -> std::string;\n\nexport auto calculate_file_hash(const std::filesystem::path& file_path)\n    -> std::expected<std::string, std::string>;\n\n}  // namespace Features::Gallery::ScanCommon\n"
  },
  {
    "path": "src/features/gallery/scanner.cpp",
    "content": "module;\n\nmodule Features.Gallery.Scanner;\n\nimport std;\nimport Core.State;\nimport Core.WorkerPool;\nimport Features.Gallery.Types;\nimport Features.Gallery.ScanCommon;\nimport Features.Gallery.Asset.Repository;\nimport Features.Gallery.Asset.Service;\nimport Features.Gallery.Asset.Thumbnail;\nimport Features.Gallery.Color.Types;\nimport Features.Gallery.Color.Extractor;\nimport Features.Gallery.Color.Repository;\nimport Features.Gallery.Folder.Repository;\nimport Features.Gallery.Folder.Service;\nimport Features.Gallery.Ignore.Repository;\nimport Features.Gallery.Ignore.Service;\nimport Utils.Media.VideoAsset;\nimport Utils.Image;\nimport Utils.Logger;\nimport Utils.Path;\nimport Utils.String;\nimport Utils.Time;\n\nnamespace Features::Gallery::Scanner {\n\n// 核心逻辑：扫描路径并应用过滤规则\nauto scan_paths(Core::State::AppState& app_state, const std::filesystem::path& directory,\n                const Types::ScanOptions& options, std::optional<std::int64_t> folder_id)\n    -> std::expected<std::vector<std::filesystem::path>, std::string> {\n  if (!std::filesystem::exists(directory)) {\n    return std::unexpected(\"Directory does not exist: \" + directory.string());\n  }\n\n  if (!std::filesystem::is_directory(directory)) {\n    return std::unexpected(\"Path is not a directory: \" + directory.string());\n  }\n\n  try {\n    // 加载并合并忽略规则\n    auto rules_result = Ignore::Service::load_ignore_rules(app_state, folder_id);\n    if (!rules_result) {\n      Logger().warn(\"Failed to load ignore rules: {}\", rules_result.error());\n    }\n    auto combined_rules = rules_result.value_or(std::vector<Types::IgnoreRule>{});\n    auto supported_extensions =\n        options.supported_extensions.value_or(ScanCommon::default_supported_extensions());\n\n    // 使用递归目录迭代器和 ranges 管道进行函数式过滤和转换\n    // 整个流程惰性求值，直到最后的 to<std::vector> 才实际收集结果\n    auto found_files =\n        std::ranges::subrange(std::filesystem::recursive_directory_iterator(directory),\n                              std::filesystem::recursive_directory_iterator{}) |\n        // 步骤 1: 过滤掉不可访问的文件或跳过目录本身，只保留常规文件。捕获文件系统错误。\n        std::views::filter([](const std::filesystem::directory_entry& entry) {\n          try {\n            return entry.is_regular_file();\n          } catch (const std::filesystem::filesystem_error& e) {\n            Logger().warn(\"Skipping file due to access error: {}\", e.what());\n            return false;\n          }\n        }) |\n        // 步骤 2: 过滤扩展名。基于 options.supported_extensions 确定的列表（如 .jpg, .png）。\n        std::views::filter([&supported_extensions](const std::filesystem::directory_entry& entry) {\n          return ScanCommon::is_supported_file(entry.path(), supported_extensions);\n        }) |\n        // 步骤 3: 结合 .ignore 规则（类似 .gitignore）进行深层次忽略检查。\n        std::views::filter(\n            [&directory, &combined_rules](const std::filesystem::directory_entry& entry) {\n              bool should_ignore =\n                  Ignore::Service::apply_ignore_rules(entry.path(), directory, combined_rules);\n              return !should_ignore;\n            }) |\n        // 步骤 4: 提取符合条件的 std::filesystem::path 用于后续处理。\n        std::views::transform(\n            [](const std::filesystem::directory_entry& entry) { return entry.path(); }) |\n        std::ranges::to<std::vector>();\n\n    return found_files;\n\n  } catch (const std::filesystem::filesystem_error& e) {\n    return std::unexpected(\"Filesystem error: \" + std::string(e.what()));\n  } catch (const std::exception& e) {\n    return std::unexpected(\"Exception in scan_paths: \" + std::string(e.what()));\n  }\n}\n\nauto report_scan_progress(const std::function<void(const Types::ScanProgress&)>& progress_callback,\n                          std::string stage, std::int64_t current, std::int64_t total,\n                          std::optional<double> percent = std::nullopt,\n                          std::optional<std::string> message = std::nullopt) -> void {\n  if (!progress_callback) {\n    return;\n  }\n\n  try {\n    progress_callback(Types::ScanProgress{.stage = std::move(stage),\n                                          .current = current,\n                                          .total = total,\n                                          .percent = percent,\n                                          .message = std::move(message)});\n  } catch (const std::exception& e) {\n    Logger().warn(\"Scan progress callback failed: {}\", e.what());\n  }\n}\n\nauto steady_clock_millis() -> std::int64_t {\n  return std::chrono::duration_cast<std::chrono::milliseconds>(\n             std::chrono::steady_clock::now().time_since_epoch())\n      .count();\n}\n\n// 进度追踪器：支持加权计算和频率限制，防止 UI 阻塞\nstruct ProcessingProgressTracker {\n  static constexpr std::int64_t kMinReportIntervalMillis = 200;\n\n  const std::function<void(const Types::ScanProgress&)>& progress_callback;\n  const std::int64_t total_files;\n  const std::int64_t total_thumbnails;\n  const std::int64_t total_units;\n  const std::int64_t thumbnail_weight;\n  const double percent_start;\n  const double percent_end;\n\n  std::atomic<std::int64_t> completed_files = 0;\n  std::atomic<std::int64_t> completed_thumbnails = 0;\n  std::atomic<std::int64_t> completed_units = 0;\n\n  std::mutex report_mutex;\n  int last_reported_percent = -1;\n  std::int64_t last_report_millis = 0;\n\n  ProcessingProgressTracker(const std::function<void(const Types::ScanProgress&)>& callback,\n                            std::int64_t files, std::int64_t thumbnails, std::int64_t units,\n                            std::int64_t thumbnail_weight_value, double start_percent,\n                            double end_percent)\n      : progress_callback(callback),\n        total_files(files),\n        total_thumbnails(thumbnails),\n        total_units(units),\n        thumbnail_weight(thumbnail_weight_value),\n        percent_start(start_percent),\n        percent_end(end_percent) {}\n\n  auto report(bool force = false, std::optional<std::string> message = std::nullopt) -> void {\n    if (!progress_callback || total_units <= 0) {\n      return;\n    }\n\n    auto units_done = std::min(completed_units.load(std::memory_order_relaxed), total_units);\n    if (force) {\n      units_done = total_units;\n    }\n\n    const auto ratio = static_cast<double>(units_done) / static_cast<double>(total_units);\n    auto percent = percent_start + (percent_end - percent_start) * ratio;\n    percent = std::clamp(percent, percent_start, percent_end);\n\n    const auto files_done = std::min(completed_files.load(std::memory_order_relaxed), total_files);\n    const auto thumbnails_done =\n        std::min(completed_thumbnails.load(std::memory_order_relaxed), total_thumbnails);\n\n    auto now = steady_clock_millis();\n\n    {\n      std::lock_guard<std::mutex> lock(report_mutex);\n      auto rounded_percent = static_cast<int>(std::floor(percent));\n\n      if (!force) {\n        if (rounded_percent <= last_reported_percent) {\n          return;\n        }\n\n        if (now - last_report_millis < kMinReportIntervalMillis) {\n          return;\n        }\n      }\n\n      if (rounded_percent > last_reported_percent) {\n        last_reported_percent = rounded_percent;\n      }\n\n      if (force && last_reported_percent >= 0 &&\n          percent < static_cast<double>(last_reported_percent)) {\n        percent = static_cast<double>(last_reported_percent);\n      }\n\n      last_report_millis = now;\n    }\n\n    if (!message.has_value()) {\n      if (total_thumbnails > 0) {\n        message = std::format(\"Processed {} / {} files, thumbnails {} / {}\", files_done,\n                              total_files, thumbnails_done, total_thumbnails);\n      } else {\n        message = std::format(\"Processed {} / {} files\", files_done, total_files);\n      }\n    }\n\n    report_scan_progress(progress_callback, \"processing\", units_done, total_units, percent,\n                         std::move(message));\n  }\n\n  auto mark_file_processed() -> void {\n    completed_files.fetch_add(1, std::memory_order_relaxed);\n    completed_units.fetch_add(1, std::memory_order_relaxed);\n    report();\n  }\n\n  auto mark_thumbnail_processed() -> void {\n    if (total_thumbnails <= 0) {\n      return;\n    }\n\n    completed_thumbnails.fetch_add(1, std::memory_order_relaxed);\n    completed_units.fetch_add(thumbnail_weight, std::memory_order_relaxed);\n    report();\n  }\n};\n\nauto is_path_under_root(const std::string& candidate_path, const std::string& root_path) -> bool {\n  if (candidate_path.size() < root_path.size()) {\n    return false;\n  }\n\n  if (!candidate_path.starts_with(root_path)) {\n    return false;\n  }\n\n  if (candidate_path.size() == root_path.size()) {\n    return true;\n  }\n\n  return candidate_path[root_path.size()] == '/';\n}\n\n// 清理磁盘已删除但数据库仍存在的资产\nauto cleanup_removed_assets(Core::State::AppState& app_state,\n                            const std::filesystem::path& normalized_scan_root,\n                            const std::vector<Types::FileSystemInfo>& file_infos,\n                            const std::unordered_map<std::string, Types::Metadata>& asset_cache)\n    -> int {\n  auto root_str = normalized_scan_root.string();\n\n  // 本次磁盘上实际存在的文件。\n  std::unordered_set<std::string> existing_paths;\n  existing_paths.reserve(file_infos.size());\n  for (const auto& file_info : file_infos) {\n    existing_paths.insert(file_info.path.string());\n  }\n\n  std::vector<Types::Metadata> removed_assets;\n  for (const auto& [cached_path, metadata] : asset_cache) {\n    if (!is_path_under_root(cached_path, root_str)) {\n      continue;\n    }\n\n    if (!existing_paths.contains(cached_path)) {\n      // DB 有但磁盘没有，后面要删掉。\n      removed_assets.push_back(metadata);\n    }\n  }\n\n  int deleted_count = 0;\n  for (const auto& metadata : removed_assets) {\n    auto asset_result = Asset::Repository::get_asset_by_id(app_state, metadata.id);\n    if (asset_result && asset_result->has_value()) {\n      auto thumbnail_result = Asset::Thumbnail::delete_thumbnail(app_state, asset_result->value());\n      if (!thumbnail_result) {\n        Logger().debug(\"Thumbnail cleanup skipped for removed asset {}: {}\", metadata.id,\n                       thumbnail_result.error());\n      }\n    }\n\n    auto delete_result = Asset::Repository::delete_asset(app_state, metadata.id);\n    if (!delete_result) {\n      Logger().warn(\"Failed to delete removed asset {}: {}\", metadata.id, delete_result.error());\n      continue;\n    }\n\n    deleted_count++;\n  }\n\n  if (deleted_count > 0) {\n    Logger().info(\"Deleted {} removed assets under '{}'\", deleted_count, root_str);\n  }\n\n  return deleted_count;\n}\n\nauto build_expected_folder_paths(const std::vector<Types::FileSystemInfo>& file_infos,\n                                 const std::filesystem::path& normalized_scan_root)\n    -> std::unordered_set<std::string> {\n  std::vector<std::filesystem::path> file_paths;\n  file_paths.reserve(file_infos.size());\n  for (const auto& file_info : file_infos) {\n    file_paths.push_back(file_info.path);\n  }\n\n  auto folder_paths =\n      Folder::Service::extract_unique_folder_paths(file_paths, normalized_scan_root);\n  std::unordered_set<std::string> expected_paths;\n  expected_paths.reserve(folder_paths.size() + 1);\n  for (const auto& folder_path : folder_paths) {\n    expected_paths.insert(folder_path.string());\n  }\n  expected_paths.insert(normalized_scan_root.string());\n\n  return expected_paths;\n}\n\nauto cleanup_missing_folders(Core::State::AppState& app_state,\n                             const std::filesystem::path& normalized_scan_root,\n                             const std::vector<Types::FileSystemInfo>& file_infos) -> int {\n  auto all_folders_result = Folder::Repository::list_all_folders(app_state);\n  if (!all_folders_result) {\n    Logger().warn(\"Failed to list folders for cleanup: {}\", all_folders_result.error());\n    return 0;\n  }\n\n  auto root_str = normalized_scan_root.string();\n  auto expected_paths = build_expected_folder_paths(file_infos, normalized_scan_root);\n\n  struct MissingFolder {\n    std::int64_t id;\n    std::string path;\n  };\n\n  std::vector<MissingFolder> missing_folders;\n  for (const auto& folder : all_folders_result.value()) {\n    if (folder.path == root_str) {\n      continue;\n    }\n\n    if (!is_path_under_root(folder.path, root_str)) {\n      continue;\n    }\n\n    if (!expected_paths.contains(folder.path)) {\n      // DB 中存在目录记录，但本次扫描后它已不属于有效文件夹集合。\n      missing_folders.push_back(MissingFolder{.id = folder.id, .path = folder.path});\n    }\n  }\n\n  // 先删更深的目录，减少父子删除冲突。\n  std::ranges::sort(missing_folders, [](const MissingFolder& a, const MissingFolder& b) {\n    return a.path.size() > b.path.size();\n  });\n\n  int deleted_folders = 0;\n  for (const auto& folder : missing_folders) {\n    auto delete_result = Folder::Repository::delete_folder(app_state, folder.id);\n    if (!delete_result) {\n      Logger().debug(\"Skip folder cleanup for '{}' (id={}): {}\", folder.path, folder.id,\n                     delete_result.error());\n      continue;\n    }\n    deleted_folders++;\n  }\n\n  if (deleted_folders > 0) {\n    Logger().info(\"Deleted {} stale folders under '{}'\", deleted_folders, root_str);\n  }\n\n  return deleted_folders;\n}\n\n// 扫描目录并获取文件信息\nauto scan_file_info(Core::State::AppState& app_state, const std::filesystem::path& directory,\n                    const Types::ScanOptions& options, std::optional<std::int64_t> folder_id)\n    -> std::expected<std::vector<Types::FileSystemInfo>, std::string> {\n  auto files_result = scan_paths(app_state, directory, options, folder_id);\n  if (!files_result) {\n    return std::unexpected(\"Failed to scan directory \" + directory.string() + \": \" +\n                           files_result.error());\n  }\n\n  auto found_files = std::move(files_result.value());\n  std::vector<Types::FileSystemInfo> result;\n  result.reserve(found_files.size());\n\n  for (const auto& file_path : found_files) {\n    std::error_code ec;\n    auto file_size = std::filesystem::file_size(file_path, ec);\n    if (ec) continue;\n\n    auto last_write_time = std::filesystem::last_write_time(file_path, ec);\n    if (ec) continue;\n\n    auto creation_time_result = Utils::Time::get_file_creation_time_millis(file_path);\n    if (!creation_time_result) {\n      Logger().debug(\"Could not get creation time for {}: {}\", file_path.string(),\n                     creation_time_result.error());\n      continue;\n    }\n\n    // 规范化路径，确保路径格式统一（统一使用正斜杠）\n    auto normalized_path_result = Utils::Path::NormalizePath(file_path);\n    if (!normalized_path_result) {\n      Logger().warn(\"Failed to normalize path '{}': {}\", file_path.string(),\n                    normalized_path_result.error());\n      continue;\n    }\n\n    Types::FileSystemInfo info{\n        .path = normalized_path_result.value(),\n        .size = static_cast<std::int64_t>(file_size),\n        .file_modified_millis = Utils::Time::file_time_to_millis(last_write_time),\n        .file_created_millis = creation_time_result.value(),\n        .hash = \"\"};\n\n    result.push_back(std::move(info));\n  }\n\n  Logger().info(\"Scanned {} files with ignore rules applied\", result.size());\n  return result;\n}\n\n// 状态分析：通过大小和修改时间初步判断文件状态\nauto analyze_file_changes(const std::vector<Types::FileSystemInfo>& file_infos,\n                          const std::unordered_map<std::string, Types::Metadata>& asset_cache,\n                          bool force_reanalyze) -> std::vector<Types::FileAnalysisResult> {\n  std::vector<Types::FileAnalysisResult> results;\n  results.reserve(file_infos.size());\n\n  for (const auto& file_info : file_infos) {\n    Types::FileAnalysisResult analysis;\n    analysis.file_info = file_info;\n\n    auto it = asset_cache.find(file_info.path.string());\n    if (it == asset_cache.end()) {\n      // 数据库中没有此路径记录，说明是全新文件，将在后续提取完整元数据。\n      analysis.status = Types::FileStatus::NEW;\n    } else {\n      // 文件在数据库中有记录，但需进一步评估是否被修改过。\n      const auto& cached_metadata = it->second;\n      analysis.existing_metadata = cached_metadata;\n\n      if (force_reanalyze) {\n        // 强制重分析也先走哈希计算，避免后续缩略图流程拿到空 hash。\n        analysis.status = Types::FileStatus::NEEDS_HASH_CHECK;\n        results.push_back(std::move(analysis));\n        continue;\n      }\n\n      // 快速比较法：检查文件大小和系统修改时间。\n      // 因为计算文件 Hash 代价昂贵，所以仅当尺寸或修改时间改变时才触发 NEEDS_HASH_CHECK。\n      if (cached_metadata.size != file_info.size ||\n          cached_metadata.file_modified_at != file_info.file_modified_millis) {\n        analysis.status = Types::FileStatus::NEEDS_HASH_CHECK;\n      } else {\n        // 大小和修改时间均一致，极大可能未发生内容改变，跳过 Hash 计算直接标记为完毕。\n        analysis.status = Types::FileStatus::UNCHANGED;\n      }\n    }\n\n    results.push_back(std::move(analysis));\n  }\n\n  return results;\n}\n\n// 并行校检：对变动文件计算哈希，精准识别内容变化\nauto calculate_hash_for_targets(Core::State::AppState& app_state,\n                                std::vector<Types::FileAnalysisResult>& analysis_results)\n    -> std::expected<void, std::string> {\n  // 1. 使用 C++20 ranges 枚举并过滤出所有状态为 NEW 或 NEEDS_HASH_CHECK 的待处理文件。\n  // 保留了原始索引(idx)，这是为了在并发算完哈希后能无缝写回原数组。\n  auto targets_with_index = analysis_results | std::views::enumerate |\n                            std::views::filter([](const auto& pair) {\n                              const auto& [idx, analysis] = pair;\n                              return analysis.status == Types::FileStatus::NEW ||\n                                     analysis.status == Types::FileStatus::NEEDS_HASH_CHECK;\n                            }) |\n                            std::ranges::to<std::vector>();\n\n  if (targets_with_index.empty()) {\n    return {};\n  }\n\n  // 使用 batches 把超大文件列表切分成每个容量32的小块，以供线程池粗粒度分发。\n  constexpr size_t HASH_BATCH_SIZE = 32;\n  auto batches =\n      targets_with_index | std::views::chunk(HASH_BATCH_SIZE) | std::ranges::to<std::vector>();\n\n  // std::latch 用于同步协调：等待所有子批次执行完毕后再释放控制流。\n  std::latch completion_latch(batches.size());\n  std::vector<std::pair<size_t, std::string>> all_hashes;\n  std::mutex results_mutex;\n\n  // 2. 并行处理批次\n  for (const auto& batch : batches) {\n    bool submitted = Core::WorkerPool::submit_task(\n        *app_state.worker_pool,\n        [&all_hashes, &results_mutex, &completion_latch, batch, &analysis_results]() {\n          auto batch_hashes =\n              batch |\n              std::views::transform(\n                  [](const auto& pair) -> std::optional<std::pair<size_t, std::string>> {\n                    const auto& [idx, analysis] = pair;\n\n                    auto hash_result = ScanCommon::calculate_file_hash(analysis.file_info.path);\n                    if (hash_result) {\n                      return std::make_pair(idx, std::move(hash_result.value()));\n                    } else {\n                      Logger().warn(\"Failed to calculate hash for {}: {}\",\n                                    analysis.file_info.path.string(), hash_result.error());\n                      return std::nullopt;\n                    }\n                  }) |\n              std::views::filter([](const auto& opt) { return opt.has_value(); }) |\n              std::views::transform([](auto&& opt) { return std::move(opt.value()); }) |\n              std::ranges::to<std::vector>();\n\n          // 合并结果\n          if (!batch_hashes.empty()) {\n            std::lock_guard<std::mutex> lock(results_mutex);\n            all_hashes.insert(all_hashes.end(), std::make_move_iterator(batch_hashes.begin()),\n                              std::make_move_iterator(batch_hashes.end()));\n          }\n\n          completion_latch.count_down();\n        });\n\n    if (!submitted) {\n      return std::unexpected(\"Failed to submit hash calculation task to worker pool\");\n    }\n  }\n\n  // 等待所有批次完成\n  completion_latch.wait();\n\n  // 3. 更新分析结果\n  std::ranges::for_each(all_hashes, [&analysis_results](const auto& hash_pair) {\n    const auto& [idx, hash] = hash_pair;\n    auto& analysis = analysis_results[idx];\n    analysis.file_info.hash = hash;\n\n    // 根据哈希结果更新状态\n    if (analysis.status == Types::FileStatus::NEEDS_HASH_CHECK) {\n      const bool hash_unchanged = analysis.existing_metadata &&\n                                  !analysis.existing_metadata->hash.empty() &&\n                                  analysis.existing_metadata->hash == hash;\n\n      analysis.status = hash_unchanged ? Types::FileStatus::UNCHANGED : Types::FileStatus::MODIFIED;\n    }\n  });\n\n  return {};\n}\n\nstruct ProcessedAssetEntry {\n  Types::Asset asset;\n  std::vector<Features::Gallery::Color::Types::ExtractedColor> colors;\n};\n\nstruct FileProcessingBatchResult {\n  std::vector<ProcessedAssetEntry> new_assets;\n  std::vector<ProcessedAssetEntry> updated_assets;\n  std::vector<std::string> errors;\n};\n\n// 处理单个文件\nauto process_single_file(Core::State::AppState& app_state, Utils::Image::WICFactory& wic_factory,\n                         const Types::FileAnalysisResult& analysis,\n                         const Types::ScanOptions& options,\n                         const std::unordered_map<std::string, std::int64_t>& folder_mapping,\n                         ProcessingProgressTracker* progress_tracker)\n    -> std::expected<ProcessedAssetEntry, std::string> {\n  const auto& file_info = analysis.file_info;\n  const auto& file_path = file_info.path;\n\n  auto asset_type = ScanCommon::detect_asset_type(file_path);\n\n  ProcessedAssetEntry processed;\n  auto& asset = processed.asset;\n\n  // 如果是更新，保留原有ID\n  if (analysis.status == Types::FileStatus::MODIFIED && analysis.existing_metadata) {\n    asset.id = analysis.existing_metadata->id;\n  }\n\n  asset.name = file_path.filename().string();\n  asset.path = file_path.string();\n  asset.type = asset_type;\n  asset.size = file_info.size;\n\n  // 设置文件扩展名（小写格式，包含点号）\n  if (file_path.has_extension()) {\n    auto extension = Utils::String::ToLowerAscii(file_path.extension().string());\n    asset.extension = extension;\n  } else {\n    asset.extension = std::nullopt;\n  }\n\n  asset.hash = file_info.hash.empty() ? std::nullopt : std::optional<std::string>{file_info.hash};\n\n  // 设置文件系统时间戳\n  asset.file_created_at = file_info.file_created_millis;\n  asset.file_modified_at = file_info.file_modified_millis;\n\n  // 数据库记录管理时间戳由数据库自动设置\n\n  // 根据文件路径查找最匹配的父级 folder_id\n  if (!folder_mapping.empty()) {\n    auto parent_path = file_path.parent_path().string();\n    if (auto it = folder_mapping.find(parent_path); it != folder_mapping.end()) {\n      asset.folder_id = it->second;\n    }\n  }\n\n  // 针对图片资产(Photo)获取 WIC 特征\n  if (asset_type == \"photo\") {\n    // 解析具体的宽高、Mimetype，比如 1920x1080 image/png\n    auto image_info_result = Utils::Image::get_image_info(wic_factory.get(), file_path);\n    if (image_info_result) {\n      auto image_info = std::move(*image_info_result);\n      asset.width = image_info.width;\n      asset.height = image_info.height;\n      asset.mime_type = std::move(image_info.mime_type);\n    } else {\n      Logger().warn(\"Could not extract image info from {}: {}\", file_path.string(),\n                    image_info_result.error());\n      asset.width = 0;\n      asset.height = 0;\n      asset.mime_type = \"application/octet-stream\";  // 兜底处理\n    }\n\n    auto color_result =\n        Features::Gallery::Color::Extractor::extract_main_colors(wic_factory, file_path);\n    if (color_result) {\n      processed.colors = std::move(color_result.value());\n    } else {\n      Logger().warn(\"Failed to extract main colors for {}: {}\", file_path.string(),\n                    color_result.error());\n      processed.colors.clear();\n    }\n\n    // 生成或更新这幅图的缩略图到缓存目录（用于无感滚动列表展示）\n    if (options.generate_thumbnails.value_or(true)) {\n      if (file_info.hash.empty()) {\n        Logger().warn(\"Skip thumbnail generation for {}: empty hash\", file_path.string());\n      } else {\n        auto thumbnail_result = Asset::Thumbnail::generate_thumbnail(\n            app_state, wic_factory, file_path, file_info.hash,\n            options.thumbnail_short_edge.value_or(480), options.rebuild_thumbnails.value_or(false));\n\n        if (!thumbnail_result) {\n          Logger().warn(\"Failed to generate thumbnail for {}: {}\", file_path.string(),\n                        thumbnail_result.error());\n        }\n      }\n\n      if (progress_tracker) {\n        // 利用权重推进总体缩略图进度，保证进度条平滑增长\n        progress_tracker->mark_thumbnail_processed();\n      }\n    }\n  } else if (asset_type == \"video\") {\n    // MF：分辨率/时长 + 可选封面 WebP；不做主色（processed.colors 在分支末尾 clear）。\n    auto video_result = Utils::Media::VideoAsset::analyze_video_file(\n        file_path, options.generate_thumbnails.value_or(true)\n                       ? std::optional<std::uint32_t>{options.thumbnail_short_edge.value_or(480)}\n                       : std::nullopt);\n    if (video_result) {\n      asset.width = static_cast<std::int32_t>(video_result->width);\n      asset.height = static_cast<std::int32_t>(video_result->height);\n      asset.mime_type = video_result->mime_type;\n\n      if (video_result->thumbnail.has_value()) {\n        if (file_info.hash.empty()) {\n          Logger().warn(\"Skip video thumbnail save for {}: empty hash\", file_path.string());\n        } else {\n          auto save_result = Asset::Thumbnail::save_thumbnail_data(\n              app_state, file_info.hash, *video_result->thumbnail,\n              options.rebuild_thumbnails.value_or(false));\n          if (!save_result) {\n            Logger().warn(\"Failed to save video thumbnail for {}: {}\", file_path.string(),\n                          save_result.error());\n          }\n        }\n      }\n    } else {\n      Logger().warn(\"Could not analyze video file {}: {}\", file_path.string(),\n                    video_result.error());\n      asset.width = 0;\n      asset.height = 0;\n      asset.mime_type = \"application/octet-stream\";\n    }\n\n    if (options.generate_thumbnails.value_or(true) && progress_tracker) {\n      progress_tracker->mark_thumbnail_processed();\n    }\n\n    processed.colors.clear();\n  } else {\n    // 非图片类型\n    asset.width = 0;\n    asset.height = 0;\n    asset.mime_type = \"application/octet-stream\";\n    processed.colors.clear();\n  }\n\n  return processed;\n}\n\n// 并行处理：提取元数据并生成缩略图\nauto process_files_in_parallel(Core::State::AppState& app_state,\n                               const std::vector<Types::FileAnalysisResult>& files_to_process,\n                               const Types::ScanOptions& options,\n                               const std::unordered_map<std::string, std::int64_t>& folder_mapping,\n                               ProcessingProgressTracker* progress_tracker)\n    -> std::expected<FileProcessingBatchResult, std::string> {\n  if (files_to_process.empty()) {\n    return FileProcessingBatchResult{};\n  }\n\n  constexpr size_t PROCESS_BATCH_SIZE = 16;  // 每批处理16个文件，可根据实际情况调整\n  size_t total_batches = (files_to_process.size() + PROCESS_BATCH_SIZE - 1) / PROCESS_BATCH_SIZE;\n\n  std::latch completion_latch(total_batches);\n\n  FileProcessingBatchResult final_result;\n  std::mutex results_mutex;\n\n  // 提交所有批次任务\n  for (size_t batch_idx = 0; batch_idx < total_batches; ++batch_idx) {\n    size_t start = batch_idx * PROCESS_BATCH_SIZE;\n    size_t end = std::min(start + PROCESS_BATCH_SIZE, files_to_process.size());\n\n    bool submitted = Core::WorkerPool::submit_task(\n        *app_state.worker_pool,\n        [&final_result, &results_mutex, &completion_latch, &app_state, &files_to_process, start,\n         end, &options, &folder_mapping, progress_tracker]() {\n          auto thread_wic_factory_result = Utils::Image::get_thread_wic_factory();\n          if (!thread_wic_factory_result) {\n            {\n              std::lock_guard<std::mutex> lock(results_mutex);\n              final_result.errors.push_back(\"Failed to get thread WIC factory: \" +\n                                            thread_wic_factory_result.error());\n            }\n            completion_latch.count_down();\n            return;\n          }\n          auto thread_wic_factory = std::move(thread_wic_factory_result.value());\n\n          // 本批次的结果\n          FileProcessingBatchResult batch_result;\n\n          for (size_t idx = start; idx < end; ++idx) {\n            const auto& analysis = files_to_process[idx];\n\n            auto asset_result = process_single_file(app_state, thread_wic_factory, analysis,\n                                                    options, folder_mapping, progress_tracker);\n            if (asset_result) {\n              if (analysis.status == Types::FileStatus::NEW) {\n                batch_result.new_assets.push_back(std::move(asset_result.value()));\n              } else if (analysis.status == Types::FileStatus::MODIFIED) {\n                batch_result.updated_assets.push_back(std::move(asset_result.value()));\n              }\n            } else {\n              batch_result.errors.push_back(\n                  std::format(\"{}: {}\", analysis.file_info.path.string(), asset_result.error()));\n            }\n\n            if (progress_tracker) {\n              progress_tracker->mark_file_processed();\n            }\n          }\n\n          // 合并批次结果到最终结果\n          {\n            std::lock_guard<std::mutex> lock(results_mutex);\n\n            final_result.new_assets.insert(final_result.new_assets.end(),\n                                           std::make_move_iterator(batch_result.new_assets.begin()),\n                                           std::make_move_iterator(batch_result.new_assets.end()));\n\n            final_result.updated_assets.insert(\n                final_result.updated_assets.end(),\n                std::make_move_iterator(batch_result.updated_assets.begin()),\n                std::make_move_iterator(batch_result.updated_assets.end()));\n\n            final_result.errors.insert(final_result.errors.end(),\n                                       std::make_move_iterator(batch_result.errors.begin()),\n                                       std::make_move_iterator(batch_result.errors.end()));\n          }\n\n          completion_latch.count_down();\n        });\n\n    if (!submitted) {\n      return std::unexpected(\"Failed to submit file processing task to worker pool\");\n    }\n  }\n\n  // 等待所有批次完成\n  completion_latch.wait();\n\n  return final_result;\n}\n\nconstexpr double kPreparingPercent = 2.0;\nconstexpr double kDiscoveringStartPercent = 10.0;\nconstexpr double kDiscoveringEndPercent = 25.0;\nconstexpr double kHashingStartPercent = 35.0;\nconstexpr double kHashingEndPercent = 60.0;\nconstexpr std::int64_t kThumbnailProgressWeight = 3;\nconstexpr double kProcessingStartPercent = 60.0;\nconstexpr double kProcessingEndPercent = 92.0;\nconstexpr double kCleanupPercent = 96.0;\n\nstruct ScanPreparationContext {\n  std::filesystem::path normalized_scan_root;\n  std::filesystem::path directory;\n  std::int64_t folder_id = 0;\n  std::unordered_map<std::string, Types::Metadata> asset_cache;\n};\n\nstruct ProcessingPhaseResult {\n  FileProcessingBatchResult batch_result;\n  bool all_db_success = true;\n};\n\n// 准备阶段：规范化路径并加载缓存\nauto prepare_scan_context(Core::State::AppState& app_state, const Types::ScanOptions& options)\n    -> std::expected<ScanPreparationContext, std::string> {\n  auto normalized_scan_root_result = Utils::Path::NormalizePath(options.directory);\n  if (!normalized_scan_root_result) {\n    return std::unexpected(\"Failed to normalize scan root path: \" +\n                           normalized_scan_root_result.error());\n  }\n  auto normalized_scan_root = normalized_scan_root_result.value();\n\n  Logger().info(\"Starting folder-aware asset scan for directory '{}' with {} ignore rules\",\n                normalized_scan_root.string(),\n                options.ignore_rules.value_or(std::vector<Types::ScanIgnoreRule>{}).size());\n\n  std::vector<std::filesystem::path> root_folder_paths = {normalized_scan_root};\n  auto root_folder_mapping_result =\n      Folder::Service::batch_create_folders_for_paths(app_state, root_folder_paths);\n  if (!root_folder_mapping_result) {\n    return std::unexpected(\"Failed to create root folder record: \" +\n                           root_folder_mapping_result.error());\n  }\n\n  auto root_folder_map = std::move(root_folder_mapping_result.value());\n  std::int64_t folder_id = root_folder_map.at(normalized_scan_root.string());\n\n  if (options.ignore_rules.has_value()) {\n    auto persist_result = Ignore::Repository::replace_rules_by_folder_id(\n        app_state, folder_id, options.ignore_rules.value());\n    if (!persist_result) {\n      return std::unexpected(\"Failed to persist ignore rules: \" + persist_result.error());\n    }\n    Logger().info(\"Persisted {} ignore rules for folder_id {}\", options.ignore_rules->size(),\n                  folder_id);\n  } else {\n    Logger().debug(\"No ignore rules provided for '{}', keeping existing rules\",\n                   normalized_scan_root.string());\n  }\n\n  auto asset_cache_result = Asset::Service::load_asset_cache(app_state);\n  if (!asset_cache_result) {\n    return std::unexpected(\"Failed to load asset cache: \" + asset_cache_result.error());\n  }\n\n  return ScanPreparationContext{\n      .normalized_scan_root = normalized_scan_root,\n      .directory = normalized_scan_root,\n      .folder_id = folder_id,\n      .asset_cache = std::move(asset_cache_result.value()),\n  };\n}\n\nauto run_discovery_phase(Core::State::AppState& app_state, const ScanPreparationContext& context,\n                         const Types::ScanOptions& options,\n                         const std::function<void(const Types::ScanProgress&)>& progress_callback)\n    -> std::expected<std::vector<Types::FileSystemInfo>, std::string> {\n  report_scan_progress(progress_callback, \"discovering\", 0, 1, kDiscoveringStartPercent,\n                       \"Scanning files from disk\");\n\n  auto file_info_result = scan_file_info(app_state, context.directory, options, context.folder_id);\n  if (!file_info_result) {\n    return std::unexpected(\"File info scanning failed: \" + file_info_result.error());\n  }\n\n  auto file_infos = std::move(file_info_result.value());\n  report_scan_progress(progress_callback, \"discovering\",\n                       static_cast<std::int64_t>(file_infos.size()),\n                       static_cast<std::int64_t>(file_infos.size()), kDiscoveringEndPercent,\n                       std::format(\"Discovered {} candidate files\", file_infos.size()));\n\n  Logger().info(\"Scanned {} files in directory '{}' (after ignore rules)\", file_infos.size(),\n                context.normalized_scan_root.string());\n  return file_infos;\n}\n\nauto run_hash_analysis_phase(\n    Core::State::AppState& app_state, const std::vector<Types::FileSystemInfo>& file_infos,\n    const std::unordered_map<std::string, Types::Metadata>& asset_cache,\n    const Types::ScanOptions& options,\n    const std::function<void(const Types::ScanProgress&)>& progress_callback)\n    -> std::expected<std::vector<Types::FileAnalysisResult>, std::string> {\n  auto analysis_results =\n      analyze_file_changes(file_infos, asset_cache, options.force_reanalyze.value_or(false));\n\n  report_scan_progress(progress_callback, \"hashing\", 0,\n                       static_cast<std::int64_t>(analysis_results.size()), kHashingStartPercent,\n                       \"Calculating file hashes\");\n\n  if (auto hash_phase = calculate_hash_for_targets(app_state, analysis_results); !hash_phase) {\n    return std::unexpected(\"Hash calculation failed: \" + hash_phase.error());\n  }\n\n  if (options.force_reanalyze.value_or(false)) {\n    for (auto& analysis : analysis_results) {\n      if (analysis.existing_metadata.has_value()) {\n        analysis.status = Types::FileStatus::MODIFIED;\n      }\n    }\n  }\n\n  report_scan_progress(progress_callback, \"hashing\",\n                       static_cast<std::int64_t>(analysis_results.size()),\n                       static_cast<std::int64_t>(analysis_results.size()), kHashingEndPercent,\n                       \"Hash calculation completed\");\n\n  Logger().info(\"Calculated hashes for {} files\", analysis_results.size());\n\n  std::vector<Types::FileAnalysisResult> files_to_process;\n  std::ranges::copy_if(analysis_results, std::back_inserter(files_to_process),\n                       [](const Types::FileAnalysisResult& result) {\n                         return result.status == Types::FileStatus::NEW ||\n                                result.status == Types::FileStatus::MODIFIED;\n                       });\n\n  Logger().info(\"Found {} files that need processing\", files_to_process.size());\n  return files_to_process;\n}\n\nauto run_processing_phase(Core::State::AppState& app_state, const std::filesystem::path& directory,\n                          const std::vector<Types::FileAnalysisResult>& files_to_process,\n                          const Types::ScanOptions& options,\n                          const std::function<void(const Types::ScanProgress&)>& progress_callback)\n    -> std::expected<ProcessingPhaseResult, std::string> {\n  std::int64_t thumbnail_targets = 0;\n  if (options.generate_thumbnails.value_or(true)) {\n    thumbnail_targets = static_cast<std::int64_t>(\n        std::ranges::count_if(files_to_process, [](const Types::FileAnalysisResult& result) {\n          auto asset_type = ScanCommon::detect_asset_type(result.file_info.path);\n          // 视频与照片一样计入「缩略图任务」，封面失败时仍会 mark 进度，避免条卡住。\n          return asset_type == \"photo\" || asset_type == \"video\";\n        }));\n  }\n\n  std::int64_t processing_total_units = static_cast<std::int64_t>(files_to_process.size()) +\n                                        thumbnail_targets * kThumbnailProgressWeight;\n\n  auto processing_start_message =\n      thumbnail_targets > 0 ? std::format(\"Processing {} changed files ({} thumbnails)\",\n                                          files_to_process.size(), thumbnail_targets)\n                            : std::format(\"Processing {} changed files\", files_to_process.size());\n\n  report_scan_progress(progress_callback, \"processing\", 0, processing_total_units,\n                       kProcessingStartPercent, std::move(processing_start_message));\n\n  ProcessingPhaseResult result{};\n  if (files_to_process.empty()) {\n    Logger().info(\"Folder-aware asset scan found no new or modified files\");\n    report_scan_progress(progress_callback, \"processing\", 0, 0, kProcessingEndPercent,\n                         \"No changed files found\");\n    return result;\n  }\n\n  std::vector<std::filesystem::path> file_paths;\n  file_paths.reserve(files_to_process.size());\n  for (const auto& analysis : files_to_process) {\n    file_paths.push_back(analysis.file_info.path);\n  }\n\n  std::unordered_map<std::string, std::int64_t> path_to_folder_id;\n  auto folder_paths = Folder::Service::extract_unique_folder_paths(file_paths, directory);\n  auto folder_mapping_result =\n      Folder::Service::batch_create_folders_for_paths(app_state, folder_paths);\n  if (folder_mapping_result) {\n    path_to_folder_id = std::move(folder_mapping_result.value());\n    Logger().info(\"Pre-created {} folder records with complete hierarchy\",\n                  path_to_folder_id.size());\n  } else {\n    Logger().warn(\"Failed to pre-create folders: {}\", folder_mapping_result.error());\n  }\n\n  std::optional<ProcessingProgressTracker> processing_tracker;\n  if (processing_total_units > 0) {\n    processing_tracker.emplace(progress_callback,\n                               static_cast<std::int64_t>(files_to_process.size()),\n                               thumbnail_targets, processing_total_units, kThumbnailProgressWeight,\n                               kProcessingStartPercent, kProcessingEndPercent);\n  }\n\n  auto processing_result =\n      process_files_in_parallel(app_state, files_to_process, options, path_to_folder_id,\n                                processing_tracker ? &(*processing_tracker) : nullptr);\n  if (!processing_result) {\n    return std::unexpected(\"File processing failed: \" + processing_result.error());\n  }\n\n  result.batch_result = std::move(processing_result.value());\n\n  if (!result.batch_result.new_assets.empty()) {\n    std::vector<Types::Asset> new_assets_to_create;\n    new_assets_to_create.reserve(result.batch_result.new_assets.size());\n    for (const auto& entry : result.batch_result.new_assets) {\n      new_assets_to_create.push_back(entry.asset);\n    }\n\n    auto create_result = Asset::Repository::batch_create_asset(app_state, new_assets_to_create);\n    if (create_result) {\n      Logger().info(\"Successfully created {} new asset items\",\n                    result.batch_result.new_assets.size());\n\n      const auto& inserted_ids = create_result.value();\n      if (inserted_ids.size() != result.batch_result.new_assets.size()) {\n        Logger().error(\"Inserted asset count mismatch when replacing colors. assets={}, ids={}\",\n                       result.batch_result.new_assets.size(), inserted_ids.size());\n        result.all_db_success = false;\n      } else {\n        std::vector<Features::Gallery::Color::Repository::ColorReplaceBatchItem> color_items;\n        color_items.reserve(result.batch_result.new_assets.size());\n\n        for (size_t i = 0; i < inserted_ids.size(); ++i) {\n          color_items.push_back(Features::Gallery::Color::Repository::ColorReplaceBatchItem{\n              .asset_id = inserted_ids[i],\n              .colors = result.batch_result.new_assets[i].colors,\n          });\n        }\n\n        auto color_result = Features::Gallery::Color::Repository::batch_replace_asset_colors(\n            app_state, color_items);\n        if (!color_result) {\n          Logger().error(\"Failed to batch replace colors for new assets: {}\", color_result.error());\n          result.all_db_success = false;\n        }\n      }\n    } else {\n      Logger().error(\"Failed to batch create asset items: {}\", create_result.error());\n      result.all_db_success = false;\n    }\n  }\n\n  if (!result.batch_result.updated_assets.empty()) {\n    std::vector<Types::Asset> updated_assets_to_save;\n    updated_assets_to_save.reserve(result.batch_result.updated_assets.size());\n    for (const auto& entry : result.batch_result.updated_assets) {\n      updated_assets_to_save.push_back(entry.asset);\n    }\n\n    auto update_result = Asset::Repository::batch_update_asset(app_state, updated_assets_to_save);\n    if (update_result) {\n      Logger().info(\"Successfully updated {} asset items\",\n                    result.batch_result.updated_assets.size());\n\n      std::vector<Features::Gallery::Color::Repository::ColorReplaceBatchItem> color_items;\n      color_items.reserve(result.batch_result.updated_assets.size());\n      for (const auto& entry : result.batch_result.updated_assets) {\n        if (entry.asset.id <= 0) {\n          Logger().warn(\"Skip replacing colors for updated asset with invalid id: {}\",\n                        entry.asset.path);\n          continue;\n        }\n        color_items.push_back(Features::Gallery::Color::Repository::ColorReplaceBatchItem{\n            .asset_id = entry.asset.id,\n            .colors = entry.colors,\n        });\n      }\n\n      auto color_result =\n          Features::Gallery::Color::Repository::batch_replace_asset_colors(app_state, color_items);\n      if (!color_result) {\n        Logger().error(\"Failed to batch replace colors for updated assets: {}\",\n                       color_result.error());\n        result.all_db_success = false;\n      }\n    } else {\n      Logger().error(\"Failed to batch update asset items: {}\", update_result.error());\n      result.all_db_success = false;\n    }\n  }\n\n  if (processing_tracker) {\n    processing_tracker->report(true, \"File processing completed\");\n  } else {\n    report_scan_progress(progress_callback, \"processing\", processing_total_units,\n                         processing_total_units, kProcessingEndPercent,\n                         \"File processing completed\");\n  }\n\n  return result;\n}\n\nauto run_cleanup_phase(Core::State::AppState& app_state,\n                       const std::filesystem::path& normalized_scan_root,\n                       const std::vector<Types::FileSystemInfo>& file_infos,\n                       const std::unordered_map<std::string, Types::Metadata>& asset_cache,\n                       const std::function<void(const Types::ScanProgress&)>& progress_callback)\n    -> int {\n  report_scan_progress(progress_callback, \"cleanup\", 0, 1, kCleanupPercent,\n                       \"Reconciling deleted files\");\n\n  int deleted_items =\n      cleanup_removed_assets(app_state, normalized_scan_root, file_infos, asset_cache);\n  [[maybe_unused]] int deleted_folders =\n      cleanup_missing_folders(app_state, normalized_scan_root, file_infos);\n  return deleted_items;\n}\n\n// =============================================================================\n// 主扫描入口：协调五个阶段完成资产同步\n// =============================================================================\nauto scan_asset_directory(Core::State::AppState& app_state, const Types::ScanOptions& options,\n                          std::function<void(const Types::ScanProgress&)> progress_callback)\n    -> std::expected<Types::ScanResult, std::string> {\n  auto start_time = std::chrono::steady_clock::now();\n  report_scan_progress(progress_callback, \"preparing\", 0, 1, kPreparingPercent,\n                       \"Preparing gallery scan context\");\n\n  // 步骤 1: 准备阶段 (获取规范化路径，预先载入数据库中的资产缓存)\n  auto context_result = prepare_scan_context(app_state, options);\n  if (!context_result) {\n    return std::unexpected(context_result.error());\n  }\n  auto context = std::move(context_result.value());\n\n  // 步骤 2: 发现阶段 (遍历磁盘系统，按照扩展名和忽略规则筛选文件)\n  auto file_infos_result = run_discovery_phase(app_state, context, options, progress_callback);\n  if (!file_infos_result) {\n    return std::unexpected(file_infos_result.error());\n  }\n  auto file_infos = std::move(file_infos_result.value());\n\n  // 步骤 3: 哈希分析阶段 (对比缓存，对于变动的文件进行哈希校检来确定状态)\n  auto files_to_process_result = run_hash_analysis_phase(app_state, file_infos, context.asset_cache,\n                                                         options, progress_callback);\n  if (!files_to_process_result) {\n    return std::unexpected(files_to_process_result.error());\n  }\n  auto files_to_process = std::move(files_to_process_result.value());\n\n  // 步骤 4: 处理阶段 (提取全新或发生修改的文件的元信息，并生成缩略图)\n  auto processing_result = run_processing_phase(app_state, context.directory, files_to_process,\n                                                options, progress_callback);\n  if (!processing_result) {\n    return std::unexpected(processing_result.error());\n  }\n  auto processing_phase = std::move(processing_result.value());\n\n  // 步骤 5: 清理阶段 (从数据库中移除那些在本次磁盘扫描中已经不存在的资产)\n  int deleted_items = run_cleanup_phase(app_state, context.normalized_scan_root, file_infos,\n                                        context.asset_cache, progress_callback);\n\n  Types::ScanResult result{\n      .total_files = static_cast<int>(file_infos.size()),\n      .new_items = static_cast<int>(processing_phase.batch_result.new_assets.size()),\n      .updated_items = static_cast<int>(processing_phase.batch_result.updated_assets.size()),\n      .deleted_items = deleted_items,\n      .errors = std::move(processing_phase.batch_result.errors),\n  };\n\n  auto end_time = std::chrono::steady_clock::now();\n  auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(end_time - start_time);\n  result.scan_duration = std::format(\"{}ms\", duration.count());\n\n  if (!processing_phase.all_db_success) {\n    result.errors.push_back(\"Some database operations failed\");\n  }\n\n  Logger().info(\n      \"Folder-aware asset scan completed. Total: {}, New: {}, Updated: {}, Deleted: {}, Errors: \"\n      \"{}, Duration: {}\",\n      result.total_files, result.new_items, result.updated_items, result.deleted_items,\n      result.errors.size(), result.scan_duration);\n\n  report_scan_progress(\n      progress_callback, \"completed\", static_cast<std::int64_t>(result.total_files),\n      static_cast<std::int64_t>(result.total_files), 100.0, \"Gallery scan completed\");\n\n  return result;\n}\n\n}  // namespace Features::Gallery::Scanner\n"
  },
  {
    "path": "src/features/gallery/scanner.ixx",
    "content": "module;\n\nexport module Features.Gallery.Scanner;\n\nimport std;\nimport Core.State;\nimport Features.Gallery.Types;\n\nnamespace Features::Gallery::Scanner {\n\nexport auto scan_asset_directory(\n    Core::State::AppState& app_state, const Types::ScanOptions& options,\n    std::function<void(const Types::ScanProgress&)> progress_callback = nullptr)\n    -> std::expected<Types::ScanResult, std::string>;\n\n}  // namespace Features::Gallery::Scanner\n"
  },
  {
    "path": "src/features/gallery/state.ixx",
    "content": "module;\n\nexport module Features.Gallery.State;\n\nimport std;\nimport Features.Gallery.Types;\n\nnamespace Features::Gallery::State {\n\nexport enum class PendingFileChangeAction { UPSERT, REMOVE };\n\nexport struct PendingStableFileChange {\n  // 目前稳定队列只用于延迟 UPSERT；REMOVE 仍然直接落到最终队列。\n  PendingFileChangeAction action{PendingFileChangeAction::UPSERT};\n  // 最近一次观测到的文件大小和修改时间，用于判断文件是否仍在被写入。\n  std::optional<std::int64_t> last_seen_size;\n  std::optional<std::int64_t> last_seen_modified_at;\n  // 到达这个时间点后才允许再次探测并决定是否提升到最终队列。\n  std::chrono::steady_clock::time_point ready_not_before{};\n};\n\nexport struct FolderWatcherState {\n  // 监听的根目录（已规范化）\n  std::filesystem::path root_path;\n  // watcher 同步时使用的运行时扫描选项（不包含 ignore_rules）。\n  Types::ScanOptions scan_options{};\n  std::jthread watch_thread;\n  std::atomic<bool> scan_in_progress{false};\n  std::atomic<bool> pending_rescan{false};\n  std::atomic<bool> stop_requested{false};\n  std::atomic<void*> directory_handle{nullptr};\n  std::mutex pending_mutex;\n\n  // true 表示要做全量扫描（会清空增量列表）\n  bool require_full_rescan{false};\n\n  // 待处理的文件改动（同一路径只保留最后一次）\n  std::unordered_map<std::string, PendingFileChangeAction> pending_file_changes;\n\n  // 已收到事件，但仍在等待稳定的文件改动候选\n  std::unordered_map<std::string, PendingStableFileChange> pending_stable_file_changes;\n\n  // 扫描完成后的回调（可选），由注册方注入，扫描有变化时触发\n  std::function<void(const Types::ScanResult&)> post_scan_callback;\n};\n\nexport struct GalleryState {\n  struct ManualMoveIgnoreEntry {\n    int in_flight_count = 0;\n    std::chrono::steady_clock::time_point ignore_until{};\n  };\n\n  // 缩略图目录路径\n  std::filesystem::path thumbnails_directory;\n\n  // 应用关闭时置为 true，用于阻止后台启动恢复继续推进。\n  std::atomic<bool> shutdown_requested{false};\n\n  // 后台 watcher 启动恢复任务的 future，shutdown 时等待其结束。\n  std::optional<std::future<void>> startup_watchers_future;\n\n  // 根目录 watcher 状态（key = 规范化路径字符串）\n  std::unordered_map<std::string, std::shared_ptr<FolderWatcherState>> folder_watchers;\n  std::mutex folder_watchers_mutex;\n\n  // 手动 move 操作的 watcher 去重表（key 为大小写归一化后的路径比较键）。\n  std::unordered_map<std::wstring, ManualMoveIgnoreEntry> manual_move_ignore_paths;\n  std::mutex manual_move_ignore_mutex;\n};\n\n}  // namespace Features::Gallery::State\n"
  },
  {
    "path": "src/features/gallery/static_resolver.cpp",
    "content": "module;\n\nmodule Features.Gallery.StaticResolver;\n\nimport std;\nimport Core.State;\nimport Core.HttpServer.Static;\nimport Core.HttpServer.Types;\nimport Core.WebView;\nimport Core.WebView.State;\nimport Features.Gallery.State;\nimport Features.Gallery.OriginalLocator;\nimport Utils.Logger;\nimport Utils.Path;\nimport Utils.String;\nimport Vendor.BuildConfig;\n\nnamespace Features::Gallery::StaticResolver {\n\n// 从 URL 提取相对路径（通用模板版本）\ntemplate <typename CharT>\nauto extract_relative_path_generic(std::basic_string_view<CharT> url,\n                                   std::basic_string_view<CharT> prefix)\n    -> std::optional<std::basic_string<CharT>> {\n  if (!url.starts_with(prefix)) {\n    return std::nullopt;\n  }\n\n  auto relative = url.substr(prefix.size());\n  if (relative.empty()) {\n    return std::nullopt;\n  }\n\n  // 去掉前导 '/'\n  if (relative.front() == static_cast<CharT>('/')) {\n    relative = relative.substr(1);\n  }\n\n  // 去掉查询参数和 fragment（找到第一个出现的位置）\n  auto end_pos =\n      std::min(relative.find(static_cast<CharT>('?')), relative.find(static_cast<CharT>('#')));\n  if (end_pos != std::basic_string_view<CharT>::npos) {\n    relative = relative.substr(0, end_pos);\n  }\n\n  if (relative.empty()) {\n    return std::nullopt;\n  }\n\n  return std::basic_string<CharT>(relative);\n}\n\n// 显示实例化模板函数\ntemplate auto extract_relative_path_generic<char>(std::basic_string_view<char>,\n                                                  std::basic_string_view<char>)\n    -> std::optional<std::basic_string<char>>;\n\n// 从 URL 提取相对路径（narrow 版本）\nauto extract_relative_path(std::string_view url, std::string_view prefix)\n    -> std::optional<std::string> {\n  return extract_relative_path_generic(url, prefix);\n}\n\n// 把 URL 中的 %XX 编码还原成原始字节串。\n// 因为 relative_path 可能包含中文、空格、#、% 等字符，所以 dev 路由必须先解码。\nauto decode_percent_encoded_string(std::string_view input) -> std::optional<std::string> {\n  std::string decoded;\n  decoded.reserve(input.size());\n\n  for (std::size_t index = 0; index < input.size(); ++index) {\n    auto ch = input[index];\n    if (ch == '%') {\n      if (index + 2 >= input.size()) {\n        return std::nullopt;\n      }\n\n      unsigned int value = 0;\n      auto hex = input.substr(index + 1, 2);\n      auto [ptr, ec] = std::from_chars(hex.data(), hex.data() + hex.size(), value, 16);\n      if (ec != std::errc{} || ptr != hex.data() + hex.size()) {\n        return std::nullopt;\n      }\n\n      decoded.push_back(static_cast<char>(value));\n      index += 2;\n      continue;\n    }\n\n    decoded.push_back(ch == '+' ? ' ' : ch);\n  }\n\n  return decoded;\n}\n\nstruct OriginalRequestLocator {\n  std::int64_t root_id = 0;\n  std::string relative_path;\n};\n\n// 从 dev HTTP originals 路由中提取 root_id 和 relative_path。\n// 路径形态是：/static/assets/originals/by-root/<rootId>/<relativePath>?v=<hash>\nauto extract_original_request_locator(std::string_view url, std::string_view prefix)\n    -> std::optional<OriginalRequestLocator> {\n  auto relative = extract_relative_path(url, prefix);\n  if (!relative) {\n    return std::nullopt;\n  }\n\n  auto separator = relative->find('/');\n  if (separator == std::string::npos) {\n    return std::nullopt;\n  }\n\n  std::int64_t root_id = 0;\n  auto root_id_part = std::string_view(*relative).substr(0, separator);\n  auto [ptr, ec] =\n      std::from_chars(root_id_part.data(), root_id_part.data() + root_id_part.size(), root_id);\n  if (ec != std::errc{} || ptr != root_id_part.data() + root_id_part.size() || root_id <= 0) {\n    return std::nullopt;\n  }\n\n  auto encoded_relative_path = std::string_view(*relative).substr(separator + 1);\n  if (encoded_relative_path.empty()) {\n    return std::nullopt;\n  }\n\n  auto decoded_relative_path = decode_percent_encoded_string(encoded_relative_path);\n  if (!decoded_relative_path || decoded_relative_path->empty()) {\n    return std::nullopt;\n  }\n\n  return OriginalRequestLocator{.root_id = root_id,\n                                .relative_path = std::move(*decoded_relative_path)};\n}\n\n// resolver 用的最小文件校验。\n// 这里不做业务层判断，只确认文件存在且是普通文件。\nauto validate_asset_file(const std::filesystem::path& path) -> bool {\n  std::error_code ec;\n  return std::filesystem::exists(path, ec) && std::filesystem::is_regular_file(path, ec) && !ec;\n}\n\n// ============= HTTP 静态服务解析器 =============\n\nauto register_http_resolvers(Core::State::AppState& state) -> void {\n  // 缩略图解析器\n  Core::HttpServer::Static::register_path_resolver(\n      state,  // 传递 AppState\n      \"/static/assets/thumbnails/\",\n      [&state](std::string_view url_path) -> Core::HttpServer::Types::PathResolution {\n        auto relative_path = extract_relative_path(url_path, \"/static/assets/thumbnails/\");\n        if (!relative_path) {\n          return std::unexpected(\"Invalid thumbnail path\");\n        }\n\n        if (!state.gallery || state.gallery->thumbnails_directory.empty()) {\n          return std::unexpected(\"Thumbnails directory not initialized\");\n        }\n\n        auto full_path = state.gallery->thumbnails_directory / *relative_path;\n\n        if (!Utils::Path::IsPathWithinBase(full_path, state.gallery->thumbnails_directory)) {\n          Logger().warn(\"Unsafe thumbnail path requested: {}\", full_path.string());\n          return std::unexpected(\"Unsafe thumbnail path\");\n        }\n        if (!validate_asset_file(full_path)) {\n          Logger().debug(\"Thumbnail file not found: {}\", full_path.string());\n          return std::unexpected(\"Thumbnail file not found\");\n        }\n\n        Logger().debug(\"Resolved thumbnail path: {}\", full_path.string());\n        return Core::HttpServer::Types::PathResolutionData{\n            .file_path = full_path,\n            .cache_duration = std::chrono::seconds{86400},\n            .cache_control_header = std::string{\"public, max-age=31536000, immutable\"}};\n      });\n\n  // 原图解析器（基于 root_id + relative_path）。\n  // 这条链路主要给浏览器 dev 环境使用；release WebView 会尽量直接走 root host mapping。\n  Core::HttpServer::Static::register_path_resolver(\n      state, \"/static/assets/originals/by-root/\",\n      [&state](std::string_view url_path) -> Core::HttpServer::Types::PathResolution {\n        auto locator =\n            extract_original_request_locator(url_path, \"/static/assets/originals/by-root/\");\n        if (!locator) {\n          return std::unexpected(\"Invalid original locator\");\n        }\n\n        auto path_result = Features::Gallery::OriginalLocator::resolve_original_file_path(\n            state, locator->root_id, locator->relative_path);\n        if (!path_result) {\n          Logger().warn(\"Failed to resolve original locator {}/{}: {}\", locator->root_id,\n                        locator->relative_path, path_result.error());\n          return std::unexpected(path_result.error());\n        }\n\n        if (!validate_asset_file(*path_result)) {\n          Logger().warn(\"Original file not found: {}\", path_result->string());\n          return std::unexpected(\"Asset file not found\");\n        }\n\n        Logger().debug(\"Resolved original locator {}/{} to {}\", locator->root_id,\n                       locator->relative_path, path_result->string());\n        return Core::HttpServer::Types::PathResolutionData{\n            .file_path = *path_result,\n            .cache_duration = std::chrono::seconds{0},\n            .cache_control_header = std::string{\"private, no-cache\"}};\n      });\n\n  Logger().info(\"Registered HTTP static resolvers for gallery\");\n}\n\n// ============= WebView 资源解析器 =============\n\nauto register_webview_resolvers(Core::State::AppState& state) -> void {\n  if (!state.webview) {\n    Logger().warn(\"WebView state not initialized, skipping resolver registration\");\n    return;\n  }\n\n  if (!state.gallery || state.gallery->thumbnails_directory.empty()) {\n    Logger().warn(\"Thumbnails directory not initialized, skipping WebView thumbnail setup\");\n    return;\n  }\n\n  if (!Vendor::BuildConfig::is_debug_build()) {\n    // Release WebView 直接把整个缩略图目录映射成独立 host。\n    // 这样 `<img src>` 会直接从文件夹读取，不再绕回 static.test 的动态拦截链路。\n    Core::WebView::register_virtual_host_folder_mapping(\n        state, state.webview->config.thumbnail_host_name,\n        state.gallery->thumbnails_directory.wstring(),\n        Core::WebView::State::VirtualHostResourceAccessKind::allow);\n\n    Logger().info(\"Registered WebView thumbnail host mapping: {} -> {}\",\n                  Utils::String::ToUtf8(state.webview->config.thumbnail_host_name),\n                  state.gallery->thumbnails_directory.string());\n  }\n}\n// ============= 清理函数 =============\n\nauto unregister_all_resolvers(Core::State::AppState& state) -> void {\n  Core::HttpServer::Static::unregister_path_resolver(state, \"/static/assets/thumbnails/\");\n  Core::HttpServer::Static::unregister_path_resolver(state, \"/static/assets/originals/by-root/\");\n\n  if (state.webview) {\n    Core::WebView::unregister_virtual_host_folder_mapping(\n        state, state.webview->config.thumbnail_host_name);\n  }\n\n  Logger().info(\"Unregistered gallery static resolvers\");\n}\n}  // namespace Features::Gallery::StaticResolver\n"
  },
  {
    "path": "src/features/gallery/static_resolver.ixx",
    "content": "module;\n\nexport module Features.Gallery.StaticResolver;\n\nimport std;\nimport Core.State;\n\nnamespace Features::Gallery::StaticResolver {\n\n// 为 HTTP 静态服务注册解析器\nexport auto register_http_resolvers(Core::State::AppState& state) -> void;\n\n// 为 WebView 注册解析器\nexport auto register_webview_resolvers(Core::State::AppState& state) -> void;\n\n// 注销所有解析器（清理时调用）\nexport auto unregister_all_resolvers(Core::State::AppState& state) -> void;\n\n}  // namespace Features::Gallery::StaticResolver\n"
  },
  {
    "path": "src/features/gallery/tag/repository.cpp",
    "content": "module;\n\nmodule Features.Gallery.Tag.Repository;\n\nimport std;\nimport Core.State;\nimport Core.Database;\nimport Core.Database.State;\nimport Core.Database.Types;\nimport Features.Gallery.Types;\nimport Utils.Logger;\n\nnamespace Features::Gallery::Tag::Repository {\n\n// ============= 基本 CRUD 操作 =============\n\nauto create_tag(Core::State::AppState& app_state, const Types::CreateTagParams& params)\n    -> std::expected<std::int64_t, std::string> {\n  std::string sql = R\"(\n            INSERT INTO tags (name, parent_id, sort_order)\n            VALUES (?, ?, ?)\n        )\";\n\n  std::vector<Core::Database::Types::DbParam> db_params;\n  db_params.push_back(params.name);\n\n  db_params.push_back(params.parent_id.has_value()\n                          ? Core::Database::Types::DbParam{params.parent_id.value()}\n                          : Core::Database::Types::DbParam{std::monostate{}});\n\n  db_params.push_back(static_cast<std::int64_t>(params.sort_order.value_or(0)));\n\n  auto result = Core::Database::execute(*app_state.database, sql, db_params);\n  if (!result) {\n    return std::unexpected(\"Failed to create tag: \" + result.error());\n  }\n\n  // 获取插入的 ID\n  auto id_result =\n      Core::Database::query_scalar<std::int64_t>(*app_state.database, \"SELECT last_insert_rowid()\");\n  if (!id_result) {\n    return std::unexpected(\"Failed to get inserted tag ID: \" + id_result.error());\n  }\n\n  return id_result->value_or(0);\n}\n\nauto get_tag_by_id(Core::State::AppState& app_state, std::int64_t id)\n    -> std::expected<std::optional<Types::Tag>, std::string> {\n  std::string sql = R\"(\n            SELECT id, name, parent_id, sort_order, created_at, updated_at\n            FROM tags\n            WHERE id = ?\n        )\";\n\n  std::vector<Core::Database::Types::DbParam> params = {id};\n\n  auto result = Core::Database::query_single<Types::Tag>(*app_state.database, sql, params);\n  if (!result) {\n    return std::unexpected(\"Failed to query tag by id: \" + result.error());\n  }\n\n  return result.value();\n}\n\nauto get_tag_by_name(Core::State::AppState& app_state, const std::string& name,\n                     std::optional<std::int64_t> parent_id)\n    -> std::expected<std::optional<Types::Tag>, std::string> {\n  std::string sql;\n  std::vector<Core::Database::Types::DbParam> params;\n\n  if (parent_id.has_value()) {\n    sql = R\"(\n            SELECT id, name, parent_id, sort_order, created_at, updated_at\n            FROM tags\n            WHERE name = ? AND parent_id = ?\n            LIMIT 1\n        )\";\n    params.push_back(name);\n    params.push_back(parent_id.value());\n  } else {\n    sql = R\"(\n            SELECT id, name, parent_id, sort_order, created_at, updated_at\n            FROM tags\n            WHERE name = ? AND parent_id IS NULL\n            LIMIT 1\n        )\";\n    params.push_back(name);\n  }\n\n  auto result = Core::Database::query_single<Types::Tag>(*app_state.database, sql, params);\n  if (!result) {\n    return std::unexpected(\"Failed to query tag by name: \" + result.error());\n  }\n\n  return result.value();\n}\n\nauto update_tag(Core::State::AppState& app_state, const Types::UpdateTagParams& params)\n    -> std::expected<void, std::string> {\n  // 动态构建 UPDATE 语句\n  std::vector<std::string> set_clauses;\n  std::vector<Core::Database::Types::DbParam> db_params;\n\n  if (params.name.has_value()) {\n    set_clauses.push_back(\"name = ?\");\n    db_params.push_back(params.name.value());\n  }\n\n  if (params.parent_id.has_value()) {\n    set_clauses.push_back(\"parent_id = ?\");\n    db_params.push_back(params.parent_id.value());\n  }\n\n  if (params.sort_order.has_value()) {\n    set_clauses.push_back(\"sort_order = ?\");\n    db_params.push_back(static_cast<std::int64_t>(params.sort_order.value()));\n  }\n\n  if (set_clauses.empty()) {\n    return std::unexpected(\"No fields to update\");\n  }\n\n  std::string sql = \"UPDATE tags SET \" +\n                    std::ranges::fold_left(set_clauses, std::string{},\n                                           [](const std::string& acc, const std::string& clause) {\n                                             return acc.empty() ? clause : acc + \", \" + clause;\n                                           }) +\n                    \" WHERE id = ?\";\n\n  db_params.push_back(params.id);\n\n  auto result = Core::Database::execute(*app_state.database, sql, db_params);\n  if (!result) {\n    return std::unexpected(\"Failed to update tag: \" + result.error());\n  }\n\n  return {};\n}\n\nauto delete_tag(Core::State::AppState& app_state, std::int64_t id)\n    -> std::expected<void, std::string> {\n  // 数据库中已设置 ON DELETE CASCADE，会自动删除子标签和 asset_tags 关联\n  std::string sql = \"DELETE FROM tags WHERE id = ?\";\n  std::vector<Core::Database::Types::DbParam> params = {id};\n\n  auto result = Core::Database::execute(*app_state.database, sql, params);\n  if (!result) {\n    return std::unexpected(\"Failed to delete tag: \" + result.error());\n  }\n\n  return {};\n}\n\nauto list_all_tags(Core::State::AppState& app_state)\n    -> std::expected<std::vector<Types::Tag>, std::string> {\n  std::string sql = R\"(\n            SELECT id, name, parent_id, sort_order, created_at, updated_at\n            FROM tags\n            ORDER BY sort_order, name\n        )\";\n\n  auto result = Core::Database::query<Types::Tag>(*app_state.database, sql);\n  if (!result) {\n    return std::unexpected(\"Failed to list all tags: \" + result.error());\n  }\n\n  return result.value();\n}\n\n// ============= 资产-标签关联操作 =============\n\nauto add_tags_to_asset(Core::State::AppState& app_state, const Types::AddTagsToAssetParams& params)\n    -> std::expected<void, std::string> {\n  if (params.tag_ids.empty()) {\n    return {};  // 没有标签要添加，直接返回成功\n  }\n\n  // 使用事务批量插入，使用 INSERT OR IGNORE 避免重复\n  return Core::Database::execute_transaction(\n      *app_state.database,\n      [&](Core::Database::State::DatabaseState& db_state) -> std::expected<void, std::string> {\n        std::string sql = R\"(\n                INSERT OR IGNORE INTO asset_tags (asset_id, tag_id)\n                VALUES (?, ?)\n            )\";\n\n        for (const auto& tag_id : params.tag_ids) {\n          std::vector<Core::Database::Types::DbParam> db_params = {params.asset_id, tag_id};\n          auto result = Core::Database::execute(db_state, sql, db_params);\n          if (!result) {\n            return std::unexpected(\"Failed to add tag to asset: \" + result.error());\n          }\n        }\n\n        return {};\n      });\n}\n\nauto add_tag_to_assets(Core::State::AppState& app_state, const Types::AddTagToAssetsParams& params)\n    -> std::expected<Types::OperationResult, std::string> {\n  if (params.tag_id <= 0) {\n    return std::unexpected(\"Tag id must be greater than 0\");\n  }\n\n  std::vector<std::int64_t> normalized_asset_ids;\n  normalized_asset_ids.reserve(params.asset_ids.size());\n  std::unordered_set<std::int64_t> seen_asset_ids;\n  std::int64_t invalid_count = 0;\n\n  for (const auto asset_id : params.asset_ids) {\n    if (asset_id <= 0) {\n      ++invalid_count;\n      continue;\n    }\n    if (seen_asset_ids.insert(asset_id).second) {\n      normalized_asset_ids.push_back(asset_id);\n    }\n  }\n\n  if (normalized_asset_ids.empty()) {\n    return Types::OperationResult{\n        .success = true,\n        .message = \"No valid assets to tag\",\n        .affected_count = 0,\n        .failed_count =\n            invalid_count > 0 ? std::optional<std::int64_t>{invalid_count} : std::nullopt,\n        .unchanged_count = 0,\n    };\n  }\n\n  auto write_result = Core::Database::execute_transaction(\n      *app_state.database,\n      [&](Core::Database::State::DatabaseState& db_state)\n          -> std::expected<std::int64_t, std::string> {\n        std::int64_t affected_count = 0;\n        constexpr std::string_view kInsertSql = R\"(\n                INSERT OR IGNORE INTO asset_tags (asset_id, tag_id)\n                VALUES (?, ?)\n            )\";\n\n        for (const auto asset_id : normalized_asset_ids) {\n          auto insert_result =\n              Core::Database::execute(db_state, std::string(kInsertSql), {asset_id, params.tag_id});\n          if (!insert_result) {\n            return std::unexpected(\"Failed to add tag to asset: \" + insert_result.error());\n          }\n\n          auto changes_result =\n              Core::Database::query_scalar<std::int64_t>(db_state, \"SELECT changes()\");\n          if (!changes_result) {\n            return std::unexpected(\"Failed to query inserted tag relation count: \" +\n                                   changes_result.error());\n          }\n          affected_count += changes_result->value_or(0);\n        }\n\n        return affected_count;\n      });\n\n  if (!write_result) {\n    return std::unexpected(write_result.error());\n  }\n\n  const auto affected_count = write_result.value();\n  const auto unchanged_count =\n      static_cast<std::int64_t>(normalized_asset_ids.size()) - affected_count;\n\n  return Types::OperationResult{\n      .success = true,\n      .message = \"Tag added to assets successfully\",\n      .affected_count = affected_count,\n      .failed_count = invalid_count > 0 ? std::optional<std::int64_t>{invalid_count} : std::nullopt,\n      .unchanged_count = unchanged_count,\n  };\n}\n\nauto remove_tags_from_asset(Core::State::AppState& app_state,\n                            const Types::RemoveTagsFromAssetParams& params)\n    -> std::expected<void, std::string> {\n  if (params.tag_ids.empty()) {\n    return {};  // 没有标签要移除，直接返回成功\n  }\n\n  // 构建 IN 子句\n  std::string placeholders = std::string(params.tag_ids.size() * 2 - 1, '?');\n  for (size_t i = 1; i < params.tag_ids.size(); ++i) {\n    placeholders[i * 2 - 1] = ',';\n  }\n\n  std::string sql =\n      std::format(\"DELETE FROM asset_tags WHERE asset_id = ? AND tag_id IN ({})\", placeholders);\n\n  std::vector<Core::Database::Types::DbParam> db_params;\n  db_params.push_back(params.asset_id);\n  for (const auto& tag_id : params.tag_ids) {\n    db_params.push_back(tag_id);\n  }\n\n  auto result = Core::Database::execute(*app_state.database, sql, db_params);\n  if (!result) {\n    return std::unexpected(\"Failed to remove tags from asset: \" + result.error());\n  }\n\n  return {};\n}\n\nauto get_asset_tags(Core::State::AppState& app_state, std::int64_t asset_id)\n    -> std::expected<std::vector<Types::Tag>, std::string> {\n  std::string sql = R\"(\n            SELECT t.id, t.name, t.parent_id, t.sort_order, t.created_at, t.updated_at\n            FROM tags t\n            INNER JOIN asset_tags at ON t.id = at.tag_id\n            WHERE at.asset_id = ?\n            ORDER BY t.sort_order, t.name\n        )\";\n\n  std::vector<Core::Database::Types::DbParam> params = {asset_id};\n\n  auto result = Core::Database::query<Types::Tag>(*app_state.database, sql, params);\n  if (!result) {\n    return std::unexpected(\"Failed to get asset tags: \" + result.error());\n  }\n\n  return result.value();\n}\n\nauto get_tags_by_asset_ids(Core::State::AppState& app_state,\n                           const std::vector<std::int64_t>& asset_ids)\n    -> std::expected<std::unordered_map<std::int64_t, std::vector<Types::Tag>>, std::string> {\n  if (asset_ids.empty()) {\n    return std::unordered_map<std::int64_t, std::vector<Types::Tag>>{};\n  }\n\n  // 构建 IN 子句\n  std::string placeholders = std::string(asset_ids.size() * 2 - 1, '?');\n  for (size_t i = 1; i < asset_ids.size(); ++i) {\n    placeholders[i * 2 - 1] = ',';\n  }\n\n  std::string sql = std::format(R\"(\n            SELECT at.asset_id, t.id, t.name, t.parent_id, t.sort_order, t.created_at, t.updated_at\n            FROM tags t\n            INNER JOIN asset_tags at ON t.id = at.tag_id\n            WHERE at.asset_id IN ({})\n            ORDER BY at.asset_id, t.sort_order, t.name\n        )\",\n                                placeholders);\n\n  std::vector<Core::Database::Types::DbParam> params;\n  for (const auto& asset_id : asset_ids) {\n    params.push_back(asset_id);\n  }\n\n  // 定义查询结果结构（包含 asset_id）\n  struct AssetTagRow {\n    std::int64_t asset_id;\n    std::int64_t id;\n    std::string name;\n    std::optional<std::int64_t> parent_id;\n    int sort_order;\n    std::int64_t created_at;\n    std::int64_t updated_at;\n  };\n\n  auto result = Core::Database::query<AssetTagRow>(*app_state.database, sql, params);\n  if (!result) {\n    return std::unexpected(\"Failed to get tags by asset ids: \" + result.error());\n  }\n\n  // 组织结果为映射\n  std::unordered_map<std::int64_t, std::vector<Types::Tag>> tag_map;\n  for (const auto& row : result.value()) {\n    Types::Tag tag{.id = row.id,\n                   .name = row.name,\n                   .parent_id = row.parent_id,\n                   .sort_order = row.sort_order,\n                   .created_at = row.created_at,\n                   .updated_at = row.updated_at};\n    tag_map[row.asset_id].push_back(std::move(tag));\n  }\n\n  return tag_map;\n}\n\n// ============= 统计功能 =============\n\nauto get_tag_stats(Core::State::AppState& app_state)\n    -> std::expected<std::vector<Types::TagStats>, std::string> {\n  std::string sql = R\"(\n            SELECT t.id as tag_id, t.name as tag_name, COUNT(at.asset_id) as asset_count\n            FROM tags t\n            LEFT JOIN asset_tags at ON t.id = at.tag_id\n            GROUP BY t.id, t.name\n            ORDER BY asset_count DESC, t.name\n        )\";\n\n  auto result = Core::Database::query<Types::TagStats>(*app_state.database, sql);\n  if (!result) {\n    return std::unexpected(\"Failed to get tag stats: \" + result.error());\n  }\n\n  return result.value();\n}\n\n// ============= 标签树构建 =============\n\nauto get_tag_tree(Core::State::AppState& app_state)\n    -> std::expected<std::vector<Types::TagTreeNode>, std::string> {\n  // 1. 获取所有标签\n  auto tags_result = list_all_tags(app_state);\n  if (!tags_result) {\n    return std::unexpected(\"Failed to get all tags: \" + tags_result.error());\n  }\n\n  const auto& tags = tags_result.value();\n\n  // 2. 查询每个标签的直接资产数量（不包含子标签）\n  std::unordered_map<std::int64_t, std::int64_t> direct_asset_counts;\n  std::string count_sql = R\"(\n            SELECT tag_id, COUNT(DISTINCT asset_id) as count\n            FROM asset_tags\n            GROUP BY tag_id\n        )\";\n\n  struct TagAssetCount {\n    std::int64_t tag_id;\n    std::int64_t count;\n  };\n\n  auto count_result = Core::Database::query<TagAssetCount>(*app_state.database, count_sql);\n  if (!count_result) {\n    return std::unexpected(\"Failed to query asset counts: \" + count_result.error());\n  }\n\n  // 填充直接资产数量映射\n  for (const auto& item : count_result.value()) {\n    direct_asset_counts[item.tag_id] = item.count;\n  }\n\n  // 3. 创建 id -> TagTreeNode 的映射\n  std::unordered_map<std::int64_t, Types::TagTreeNode> node_map;\n\n  // 第一次遍历：创建所有节点\n  for (const auto& tag : tags) {\n    Types::TagTreeNode node{.id = tag.id,\n                            .name = tag.name,\n                            .parent_id = tag.parent_id,\n                            .sort_order = tag.sort_order,\n                            .created_at = tag.created_at,\n                            .updated_at = tag.updated_at,\n                            .children = {}};\n\n    node_map[tag.id] = std::move(node);\n  }\n\n  // 4. 第二次遍历：构建父子关系\n  std::unordered_map<std::int64_t, std::vector<std::int64_t>> parent_to_children;\n  std::vector<std::int64_t> root_ids;\n\n  for (const auto& tag : tags) {\n    if (tag.parent_id.has_value()) {\n      parent_to_children[tag.parent_id.value()].push_back(tag.id);\n    } else {\n      root_ids.push_back(tag.id);\n    }\n  }\n\n  // 5. 递归构建树结构\n  std::function<Types::TagTreeNode(std::int64_t)> build_tree;\n  build_tree = [&](std::int64_t tag_id) -> Types::TagTreeNode {\n    auto node_it = node_map.find(tag_id);\n    if (node_it == node_map.end()) {\n      Logger().error(\"Tag {} not found in node_map\", tag_id);\n      return Types::TagTreeNode{};\n    }\n\n    Types::TagTreeNode node = std::move(node_it->second);\n\n    // 递归构建子节点\n    auto children_it = parent_to_children.find(tag_id);\n    if (children_it != parent_to_children.end()) {\n      for (std::int64_t child_id : children_it->second) {\n        node.children.push_back(build_tree(child_id));\n      }\n    }\n\n    return node;\n  };\n\n  // 6. 构建所有根节点\n  std::vector<Types::TagTreeNode> root_nodes;\n  for (std::int64_t root_id : root_ids) {\n    root_nodes.push_back(build_tree(root_id));\n  }\n\n  // 7. 对根节点按 sort_order 和 name 排序\n  std::sort(root_nodes.begin(), root_nodes.end(),\n            [](const Types::TagTreeNode& a, const Types::TagTreeNode& b) {\n              if (a.sort_order != b.sort_order) {\n                return a.sort_order < b.sort_order;\n              }\n              return a.name < b.name;\n            });\n\n  // 递归排序所有子节点\n  std::function<void(Types::TagTreeNode&)> sort_children;\n  sort_children = [&](Types::TagTreeNode& node) {\n    std::sort(node.children.begin(), node.children.end(),\n              [](const Types::TagTreeNode& a, const Types::TagTreeNode& b) {\n                if (a.sort_order != b.sort_order) {\n                  return a.sort_order < b.sort_order;\n                }\n                return a.name < b.name;\n              });\n\n    for (auto& child : node.children) {\n      sort_children(child);\n    }\n  };\n\n  for (auto& root : root_nodes) {\n    sort_children(root);\n  }\n\n  // 8. 递归计算每个标签的 asset_count（包含所有子标签）\n  std::function<std::int64_t(Types::TagTreeNode&)> calculate_total_assets;\n  calculate_total_assets = [&](Types::TagTreeNode& node) -> std::int64_t {\n    // 当前标签的直接资产数量\n    std::int64_t total = 0;\n    auto it = direct_asset_counts.find(node.id);\n    if (it != direct_asset_counts.end()) {\n      total = it->second;\n    }\n\n    // 递归累加所有子标签的资产（注意：不重复计算同一资产）\n    // 这里简化处理：直接累加，实际可能有资产同时属于父子标签\n    for (auto& child : node.children) {\n      total += calculate_total_assets(child);\n    }\n\n    node.asset_count = total;\n    return total;\n  };\n\n  // 对所有根节点执行计算\n  for (auto& root : root_nodes) {\n    calculate_total_assets(root);\n  }\n\n  return root_nodes;\n}\n\n}  // namespace Features::Gallery::Tag::Repository\n"
  },
  {
    "path": "src/features/gallery/tag/repository.ixx",
    "content": "module;\n\nexport module Features.Gallery.Tag.Repository;\n\nimport std;\nimport Core.State;\nimport Features.Gallery.Types;\n\nnamespace Features::Gallery::Tag::Repository {\n\nexport auto create_tag(Core::State::AppState& app_state, const Types::CreateTagParams& params)\n    -> std::expected<std::int64_t, std::string>;\n\nexport auto get_tag_by_id(Core::State::AppState& app_state, std::int64_t id)\n    -> std::expected<std::optional<Types::Tag>, std::string>;\n\nexport auto get_tag_by_name(Core::State::AppState& app_state, const std::string& name,\n                            std::optional<std::int64_t> parent_id = std::nullopt)\n    -> std::expected<std::optional<Types::Tag>, std::string>;\n\nexport auto update_tag(Core::State::AppState& app_state, const Types::UpdateTagParams& params)\n    -> std::expected<void, std::string>;\n\nexport auto delete_tag(Core::State::AppState& app_state, std::int64_t id)\n    -> std::expected<void, std::string>;\n\nexport auto add_tags_to_asset(Core::State::AppState& app_state,\n                              const Types::AddTagsToAssetParams& params)\n    -> std::expected<void, std::string>;\n\nexport auto add_tag_to_assets(Core::State::AppState& app_state,\n                              const Types::AddTagToAssetsParams& params)\n    -> std::expected<Types::OperationResult, std::string>;\n\nexport auto remove_tags_from_asset(Core::State::AppState& app_state,\n                                   const Types::RemoveTagsFromAssetParams& params)\n    -> std::expected<void, std::string>;\n\nexport auto get_asset_tags(Core::State::AppState& app_state, std::int64_t asset_id)\n    -> std::expected<std::vector<Types::Tag>, std::string>;\n\nexport auto get_tags_by_asset_ids(Core::State::AppState& app_state,\n                                  const std::vector<std::int64_t>& asset_ids)\n    -> std::expected<std::unordered_map<std::int64_t, std::vector<Types::Tag>>, std::string>;\n\nexport auto get_tag_stats(Core::State::AppState& app_state)\n    -> std::expected<std::vector<Types::TagStats>, std::string>;\n\nexport auto get_tag_tree(Core::State::AppState& app_state)\n    -> std::expected<std::vector<Types::TagTreeNode>, std::string>;\n\n}  // namespace Features::Gallery::Tag::Repository\n"
  },
  {
    "path": "src/features/gallery/tag/service.cpp",
    "content": "module;\n\nmodule Features.Gallery.Tag.Service;\n\nimport std;\nimport Core.State;\nimport Features.Gallery.Types;\nimport Features.Gallery.Tag.Repository;\nimport Utils.Logger;\n\nnamespace Features::Gallery::Tag::Service {\n\n// TODO: 实现业务逻辑函数\n// 当前 Tag 模块的业务逻辑较简单，主要通过 Repository 直接提供\n// 未来可以在这里添加复杂的标签管理功能，例如：\n// - 批量标签操作\n// - 基于文件夹的自动标签\n// - 标签合并和重组\n// - 标签推荐和智能分类\n\n}  // namespace Features::Gallery::Tag::Service\n"
  },
  {
    "path": "src/features/gallery/tag/service.ixx",
    "content": "module;\n\nexport module Features.Gallery.Tag.Service;\n\nimport std;\nimport Core.State;\nimport Features.Gallery.Types;\n\nnamespace Features::Gallery::Tag::Service {\n\n// TODO: 未来可添加的业务逻辑函数\n// 例如：\n// - auto batch_tag_assets(...) -> std::expected<void, std::string>;\n// - auto auto_tag_by_folder(...) -> std::expected<void, std::string>;\n// - auto merge_tags(...) -> std::expected<void, std::string>;\n\n}  // namespace Features::Gallery::Tag::Service\n"
  },
  {
    "path": "src/features/gallery/types.ixx",
    "content": "module;\n\nexport module Features.Gallery.Types;\n\nimport std;\n\nexport namespace Features::Gallery::Types {\n\n// ============= 核心数据类型 =============\n\nstruct Asset {\n  std::int64_t id;\n  std::string name;\n  std::string path;\n  std::string type;  // photo, video, live_photo, unknown\n  std::optional<std::string> dominant_color_hex;\n  int rating = 0;\n  std::string review_flag = \"none\";\n\n  std::optional<std::string> description;\n  std::optional<std::int32_t> width;\n  std::optional<std::int32_t> height;\n  std::optional<std::int64_t> size;\n  std::optional<std::string> extension;\n  std::string mime_type;\n  std::optional<std::string> hash;  // xxh3哈希\n  std::optional<std::int64_t> root_id;\n  std::optional<std::string> relative_path;\n  std::optional<std::int64_t> folder_id;\n\n  std::optional<std::int64_t> file_created_at;\n  std::optional<std::int64_t> file_modified_at;\n\n  std::int64_t created_at;\n  std::int64_t updated_at;\n};\n\nstruct Folder {\n  std::int64_t id;\n  std::string path;\n  std::optional<std::int64_t> parent_id;\n  std::string name;\n  std::optional<std::string> display_name;\n  std::optional<std::int64_t> cover_asset_id;\n  int sort_order = 0;\n  int is_hidden = 0;\n  std::int64_t created_at;\n  std::int64_t updated_at;\n};\n\nstruct FolderTreeNode {\n  std::int64_t id;\n  std::string path;\n  std::optional<std::int64_t> parent_id;\n  std::string name;\n  std::optional<std::string> display_name;\n  std::optional<std::int64_t> cover_asset_id;\n  int sort_order = 0;\n  int is_hidden = 0;\n  std::int64_t created_at;\n  std::int64_t updated_at;\n  std::int64_t asset_count = 0;\n  std::vector<FolderTreeNode> children;\n};\n\nstruct IgnoreRule {\n  std::int64_t id;\n  std::optional<std::int64_t> folder_id;\n  std::string rule_pattern;\n  std::string pattern_type = \"glob\";\n  std::string rule_type = \"exclude\";\n  int is_enabled = 1;\n  std::optional<std::string> description;\n  std::int64_t created_at;\n  std::int64_t updated_at;\n};\n\nstruct Tag {\n  std::int64_t id;\n  std::string name;\n  std::optional<std::int64_t> parent_id;\n  int sort_order = 0;\n  std::int64_t created_at;\n  std::int64_t updated_at;\n};\n\nstruct TagTreeNode {\n  std::int64_t id;\n  std::string name;\n  std::optional<std::int64_t> parent_id;\n  int sort_order = 0;\n  std::int64_t created_at;\n  std::int64_t updated_at;\n  std::int64_t asset_count = 0;  // 使用该标签（包含子标签）的资产总数\n  std::vector<TagTreeNode> children;\n};\n\nstruct AssetTag {\n  std::int64_t asset_id;\n  std::int64_t tag_id;\n  std::int64_t created_at;\n};\n\nstruct AssetMainColor {\n  std::int64_t r = 0;\n  std::int64_t g = 0;\n  std::int64_t b = 0;\n  double weight = 0.0;\n};\n\n// ============= 辅助数据类型 =============\n\nstruct Info {\n  std::uint32_t width;\n  std::uint32_t height;\n  std::int64_t size;\n  std::string mime_type;\n  std::string detected_type;\n};\n\nstruct Stats {\n  int total_count = 0;\n  int photo_count = 0;\n  int video_count = 0;\n  int live_photo_count = 0;\n  std::int64_t total_size = 0;\n  std::string oldest_item_date;\n  std::string newest_item_date;\n};\n\nstruct HomeStats {\n  int total_count = 0;\n  int photo_count = 0;\n  int video_count = 0;\n  int live_photo_count = 0;\n  std::int64_t total_size = 0;\n  int today_added_count = 0;\n};\n\nstruct TagStats {\n  std::int64_t tag_id;\n  std::string tag_name;\n  std::int64_t asset_count;  // 使用该标签的资产数量\n};\n\nstruct TypeCountResult {\n  std::string type;\n  int count;\n};\n\nstruct FolderHierarchy {\n  std::string path;\n  std::optional<std::string> parent_path;\n  std::string name;\n  int level = 0;\n};\n\n// ============= 扫描相关类型 =============\n\n//  忽略规则（用于前端请求）\nstruct ScanIgnoreRule {\n  std::string pattern;\n  std::string pattern_type = \"glob\";  // \"glob\" 或 \"regex\"\n  std::string rule_type = \"exclude\";  // \"exclude\" 或 \"include\"\n  std::optional<std::string> description;\n};\n\nstruct ScanOptions {\n  std::string directory;\n  std::optional<bool> generate_thumbnails = true;\n  std::optional<std::uint32_t> thumbnail_short_edge = 480;\n  std::optional<bool> force_reanalyze = false;\n  std::optional<bool> rebuild_thumbnails = false;\n  // 留空时统一回落到 ScanCommon::default_supported_extensions()，避免多处维护默认列表。\n  std::optional<std::vector<std::string>> supported_extensions;\n  std::optional<std::vector<ScanIgnoreRule>> ignore_rules;\n};\n\nstruct ScanProgress {\n  std::string stage;\n  std::int64_t current = 0;\n  std::int64_t total = 0;\n  std::optional<double> percent;\n  std::optional<std::string> message;\n};\n\nenum class ScanChangeAction {\n  UPSERT,\n  REMOVE,\n};\n\n// 扫描输出的最小变化单元。\n// 供运行时增量消费者（如 Infinity Nikki ScreenShot 硬链接同步）直接复用，\n// 避免再次全量遍历文件系统推导“这次到底哪些文件变了”。\n// REMOVE 表示监视根下该路径对应的文件已从磁盘消失；与索引中是否仍能删到一行资产无关。\nstruct ScanChange {\n  std::string path;\n  ScanChangeAction action = ScanChangeAction::UPSERT;\n};\n\nstruct ScanResult {\n  int total_files = 0;\n  int new_items = 0;\n  int updated_items = 0;\n  int deleted_items = 0;\n  std::vector<std::string> errors = {};\n  std::string scan_duration = \"\";\n  // changes 主要在 watcher 增量同步场景下填充；\n  // 全量扫描允许为空，因为它关注的是“最终一致性”而非“逐文件变化集”。\n  std::vector<ScanChange> changes = {};\n};\n\nenum class FileStatus { NEW, UNCHANGED, MODIFIED, NEEDS_HASH_CHECK, DELETED };\n\nstruct Metadata {\n  std::int64_t id;\n  std::string path;\n  std::int64_t size;\n  std::int64_t file_modified_at;\n  std::string hash;\n};\n\nstruct FileSystemInfo {\n  std::filesystem::path path;\n  std::int64_t size;\n  std::int64_t file_modified_millis;\n  std::int64_t file_created_millis;\n  std::string hash;\n};\n\nstruct FileAnalysisResult {\n  FileSystemInfo file_info;\n  FileStatus status;\n  std::optional<Metadata> existing_metadata;\n};\n\n// ============= RPC参数类型 =============\n\nstruct ListResponse {\n  std::vector<Asset> items;\n  std::int32_t total_count;\n  std::int32_t current_page;\n  std::int32_t per_page;\n  std::int32_t total_pages;\n  std::optional<std::int64_t> active_asset_index;\n};\n\nstruct PhotoMapPoint {\n  std::int64_t asset_id;\n  std::string name;\n  std::optional<std::string> hash;\n  std::optional<std::int64_t> file_created_at;\n  double nikki_loc_x;\n  double nikki_loc_y;\n  std::optional<double> nikki_loc_z;\n  // asset_index：该照片在“当前 gallery 排序结果集”里的 0-based 下标\n  // 用于地图点击后原子地对齐灯箱 activeIndex，避免闪一下。\n  std::int64_t asset_index;\n};\n\nstruct GetParams {\n  std::int64_t id;\n};\n\nstruct GetInfinityNikkiDetailsParams {\n  std::int64_t asset_id;\n};\n\nstruct GetAssetMainColorsParams {\n  std::int64_t asset_id;\n};\n\nstruct AssetIdsParams {\n  std::vector<std::int64_t> ids;\n};\n\nstruct MoveAssetsToFolderParams {\n  std::vector<std::int64_t> ids;\n  std::int64_t target_folder_id = 0;\n};\n\nstruct DeleteParams {\n  std::int64_t id;\n  std::optional<bool> delete_file = false;\n};\n\nstruct GetStatsParams {};\n\nstruct ListAssetsParams {\n  std::optional<std::int64_t> folder_id;\n  std::optional<bool> include_subfolders = false;\n  // 分页和排序参数（复用ListParams的逻辑）\n  std::optional<std::int32_t> page = 1;\n  std::optional<std::int32_t> per_page = 50;\n  std::optional<std::string> sort_by = \"created_at\";\n  std::optional<std::string> sort_order = \"desc\";\n};\n\nstruct OperationResult {\n  bool success;\n  std::string message;\n  std::optional<std::int64_t> affected_count;\n  std::optional<std::int64_t> failed_count = std::nullopt;\n  std::optional<std::int64_t> not_found_count = std::nullopt;\n  std::optional<std::int64_t> unchanged_count = std::nullopt;\n};\n\n// ============= 时间线相关类型 =============\n\nstruct TimelineBucket {\n  std::string month;  // \"2024-10\" 格式\n  int count;          // 该月照片数量\n};\n\nstruct TimelineBucketsParams {\n  std::optional<std::int64_t> folder_id;\n  std::optional<bool> include_subfolders = false;\n  std::optional<std::string> sort_order = \"desc\";  // \"asc\" | \"desc\"\n  std::optional<std::int64_t> active_asset_id;\n  std::optional<std::string> type;\n  std::optional<std::string> search;\n  std::optional<int> rating;\n  std::optional<std::string> review_flag;\n  std::optional<std::vector<std::int64_t>> tag_ids;\n  std::optional<std::string> tag_match_mode = \"any\";  // \"any\" (OR) | \"all\" (AND)\n  std::optional<std::vector<std::int64_t>> cloth_ids;\n  std::optional<std::string> cloth_match_mode = \"any\";  // \"any\" (OR) | \"all\" (AND)\n  std::optional<std::vector<std::string>> color_hexes;\n  std::optional<std::string> color_match_mode = \"any\";  // \"any\" (OR) | \"all\" (AND)\n  std::optional<double> color_distance = 18.0;\n};\n\nstruct TimelineBucketsResponse {\n  std::vector<TimelineBucket> buckets;\n  int total_count;  // 总照片数\n  std::optional<std::int64_t> active_asset_index;\n};\n\nstruct GetAssetsByMonthParams {\n  std::string month;  // \"2024-10\" 格式\n  std::optional<std::int64_t> folder_id;\n  std::optional<bool> include_subfolders = false;\n  std::optional<std::string> sort_order = \"desc\";  // \"asc\" | \"desc\"\n  std::optional<std::string> type;\n  std::optional<std::string> search;\n  std::optional<int> rating;\n  std::optional<std::string> review_flag;\n  std::optional<std::vector<std::int64_t>> tag_ids;\n  std::optional<std::string> tag_match_mode = \"any\";  // \"any\" (OR) | \"all\" (AND)\n  std::optional<std::vector<std::int64_t>> cloth_ids;\n  std::optional<std::string> cloth_match_mode = \"any\";  // \"any\" (OR) | \"all\" (AND)\n  std::optional<std::vector<std::string>> color_hexes;\n  std::optional<std::string> color_match_mode = \"any\";  // \"any\" (OR) | \"all\" (AND)\n  std::optional<double> color_distance = 18.0;\n};\n\nstruct GetAssetsByMonthResponse {\n  std::string month;\n  std::vector<Asset> assets;\n  int count;\n};\n\n// ============= 统一查询相关类型 =============\n\nstruct QueryAssetsFilters {\n  std::optional<std::int64_t> folder_id;\n  std::optional<bool> include_subfolders = false;\n  std::optional<std::string> month;        // \"2024-10\" 格式\n  std::optional<std::string> year;         // \"2024\" 格式\n  std::optional<std::string> type;         // \"photo\" | \"video\" | \"live_photo\"\n  std::optional<std::string> search;       // 搜索关键词\n  std::optional<int> rating;               // 0 表示未评分，其它为 1~5 星\n  std::optional<std::string> review_flag;  // \"none\" | \"picked\" | \"rejected\"\n  std::optional<std::vector<std::int64_t>> tag_ids;\n  std::optional<std::string> tag_match_mode = \"any\";  // \"any\" (OR) | \"all\" (AND)\n  std::optional<std::vector<std::int64_t>> cloth_ids;\n  std::optional<std::string> cloth_match_mode = \"any\";  // \"any\" (OR) | \"all\" (AND)\n  std::optional<std::vector<std::string>> color_hexes;\n  std::optional<std::string> color_match_mode = \"any\";  // \"any\" (OR) | \"all\" (AND)\n  std::optional<double> color_distance = 18.0;\n};\n\nstruct QueryAssetsParams {\n  QueryAssetsFilters filters;\n  std::optional<std::string> sort_by = \"created_at\";\n  std::optional<std::string> sort_order = \"desc\";\n  std::optional<std::int64_t> active_asset_id;\n  // 分页是可选的：传page就分页，不传就返回所有结果\n  std::optional<std::int32_t> page;\n  std::optional<std::int32_t> per_page;\n};\n\nstruct AssetLayoutMetaItem {\n  std::int64_t id;\n  std::optional<std::int32_t> width;\n  std::optional<std::int32_t> height;\n};\n\nstruct QueryAssetLayoutMetaParams {\n  QueryAssetsFilters filters;\n  std::optional<std::string> sort_by = \"created_at\";\n  std::optional<std::string> sort_order = \"desc\";\n};\n\nstruct QueryAssetLayoutMetaResponse {\n  std::vector<AssetLayoutMetaItem> items;\n  std::int32_t total_count;\n};\n\nstruct QueryPhotoMapPointsParams {\n  QueryAssetsFilters filters;\n  std::optional<std::string> sort_by = \"created_at\";\n  std::optional<std::string> sort_order = \"desc\";  // \"asc\" | \"desc\"\n};\n\nstruct InfinityNikkiExtractedParams {\n  std::optional<std::string> camera_params;\n  std::optional<std::int64_t> time_hour;\n  std::optional<std::int64_t> time_min;\n  std::optional<double> camera_focal_length;\n  std::optional<double> rotation;\n  std::optional<double> aperture_value;\n  std::optional<std::int64_t> filter_id;\n  std::optional<double> filter_strength;\n  std::optional<double> vignette_intensity;\n  std::optional<std::int64_t> light_id;\n  std::optional<double> light_strength;\n  std::optional<std::int64_t> vertical;\n  std::optional<double> bloom_intensity;\n  std::optional<double> bloom_threshold;\n  std::optional<double> brightness;\n  std::optional<double> exposure;\n  std::optional<double> contrast;\n  std::optional<double> saturation;\n  std::optional<double> vibrance;\n  std::optional<double> highlights;\n  std::optional<double> shadow;\n  std::optional<double> nikki_loc_x;\n  std::optional<double> nikki_loc_y;\n  std::optional<double> nikki_loc_z;\n  std::optional<std::int64_t> nikki_hidden;\n  std::optional<std::int64_t> pose_id;\n};\n\nstruct InfinityNikkiUserRecord {\n  std::string code_type;\n  std::string code_value;\n};\n\nstruct InfinityNikkiDetails {\n  std::optional<InfinityNikkiExtractedParams> extracted;\n  std::optional<InfinityNikkiUserRecord> user_record;\n};\n\nstruct GetInfinityNikkiMetadataNamesParams {\n  std::optional<std::int64_t> filter_id;\n  std::optional<std::int64_t> pose_id;\n  std::optional<std::int64_t> light_id;\n  std::optional<std::string> locale = \"zh-CN\";\n};\n\nstruct InfinityNikkiMetadataNames {\n  std::optional<std::string> filter_name;\n  std::optional<std::string> pose_name;\n  std::optional<std::string> light_name;\n};\n\n// ============= 标签相关参数 =============\n\nstruct CreateTagParams {\n  std::string name;\n  std::optional<std::int64_t> parent_id;\n  std::optional<int> sort_order = 0;\n};\n\nstruct UpdateTagParams {\n  std::int64_t id;\n  std::optional<std::string> name;\n  std::optional<std::int64_t> parent_id;\n  std::optional<int> sort_order;\n};\n\nstruct AddTagsToAssetParams {\n  std::int64_t asset_id;\n  std::vector<std::int64_t> tag_ids;\n};\n\nstruct AddTagToAssetsParams {\n  std::vector<std::int64_t> asset_ids;\n  std::int64_t tag_id = 0;\n};\n\nstruct RemoveTagsFromAssetParams {\n  std::int64_t asset_id;\n  std::vector<std::int64_t> tag_ids;\n};\n\nstruct GetAssetTagsParams {\n  std::int64_t asset_id;\n};\n\nstruct UpdateAssetsReviewStateParams {\n  std::vector<std::int64_t> asset_ids;\n  std::optional<int> rating;\n  std::optional<std::string> review_flag;\n};\n\nstruct UpdateAssetDescriptionParams {\n  std::int64_t asset_id;\n  std::optional<std::string> description;\n};\n\nstruct SetInfinityNikkiUserRecordParams {\n  std::int64_t asset_id;\n  std::string code_type;\n  std::optional<std::string> code_value;\n};\n\nstruct GetTagStatsParams {};\n\n}  // namespace Features::Gallery::Types\n"
  },
  {
    "path": "src/features/gallery/watcher.cpp",
    "content": "module;\n\nmodule Features.Gallery.Watcher;\n\nimport std;\nimport Core.State;\nimport Core.WorkerPool;\nimport Core.RPC.NotificationHub;\nimport Features.Gallery.State;\nimport Features.Gallery.Types;\nimport Features.Gallery.Recovery.Service;\nimport Features.Gallery.Scanner;\nimport Features.Gallery.ScanCommon;\nimport Features.Gallery.Folder.Repository;\nimport Features.Gallery.Folder.Service;\nimport Features.Gallery.Ignore.Service;\nimport Features.Gallery.Asset.Repository;\nimport Features.Gallery.Asset.Thumbnail;\nimport Features.Gallery.Color.Types;\nimport Features.Gallery.Color.Extractor;\nimport Features.Gallery.Color.Repository;\nimport Utils.Media.VideoAsset;\nimport Utils.Image;\nimport Utils.Logger;\nimport Utils.Path;\nimport Utils.String;\nimport Utils.Time;\nimport <windows.h>;\n\nnamespace Features::Gallery::Watcher {\n\nconstexpr std::chrono::milliseconds kDebounceDelay{500};\nconstexpr std::chrono::milliseconds kFileStabilityQuietPeriod{2000};\n// 手动 move 结束后额外缓冲一段时间，吸收“晚到”的文件系统通知。\nconstexpr std::chrono::milliseconds kManualMoveIgnoreGracePeriod{3000};\n// 监听缓冲区；太小更容易溢出。\nconstexpr size_t kWatchBufferSize = 64 * 1024;\n\nauto is_shutdown_requested(const Core::State::AppState& app_state) -> bool {\n  return app_state.gallery && app_state.gallery->shutdown_requested.load(std::memory_order_acquire);\n}\n\nauto build_ignore_key(const std::filesystem::path& path)\n    -> std::expected<std::wstring, std::string> {\n  auto normalized_result = Utils::Path::NormalizePath(path);\n  if (!normalized_result) {\n    return std::unexpected(\"Failed to normalize ignore path: \" + normalized_result.error());\n  }\n  return Utils::Path::NormalizeForComparison(normalized_result.value());\n}\n\nauto cleanup_expired_manual_move_ignores(Core::State::AppState& app_state) -> void {\n  auto now = std::chrono::steady_clock::now();\n  std::erase_if(app_state.gallery->manual_move_ignore_paths, [now](const auto& pair) {\n    const auto& entry = pair.second;\n    return entry.in_flight_count <= 0 && entry.ignore_until <= now;\n  });\n}\n\nauto is_path_in_manual_move_ignore(Core::State::AppState& app_state,\n                                   const std::filesystem::path& path) -> bool {\n  auto key_result = build_ignore_key(path);\n  if (!key_result) {\n    return false;\n  }\n\n  std::lock_guard<std::mutex> lock(app_state.gallery->manual_move_ignore_mutex);\n  cleanup_expired_manual_move_ignores(app_state);\n  auto it = app_state.gallery->manual_move_ignore_paths.find(key_result.value());\n  if (it == app_state.gallery->manual_move_ignore_paths.end()) {\n    return false;\n  }\n\n  const auto now = std::chrono::steady_clock::now();\n  return it->second.in_flight_count > 0 || it->second.ignore_until > now;\n}\n\n// 待处理变更快照，用于描述需要增量或全量同步的状态\nstruct PendingSnapshot {\n  // true 走全量，false 按 file_changes 走增量。\n  bool require_full_rescan = false;\n  // key: 路径，value: 最终动作（同一路径会被合并）\n  std::unordered_map<std::string, State::PendingFileChangeAction> file_changes;\n};\n\n// 解析后的目录监听事件\nstruct ParsedNotification {\n  std::filesystem::path path;\n  DWORD action = 0;\n  bool is_directory = false;\n};\n\nstruct ProbedFileState {\n  std::int64_t size = 0;\n  std::int64_t modified_at = 0;\n};\n\n// 生成默认的目录扫描配置\nauto make_default_scan_options(const std::filesystem::path& root_path) -> Types::ScanOptions {\n  Types::ScanOptions options;\n  options.directory = root_path.string();\n  return options;\n}\n\n// 更新监听器的扫描配置\nauto update_watcher_scan_options(const std::shared_ptr<State::FolderWatcherState>& watcher,\n                                 const std::optional<Types::ScanOptions>& scan_options) -> void {\n  std::lock_guard<std::mutex> lock(watcher->pending_mutex);\n  watcher->scan_options = scan_options.value_or(make_default_scan_options(watcher->root_path));\n  watcher->scan_options.directory = watcher->root_path.string();\n  // watcher 同步阶段始终以 DB 中已持久化规则为准。\n  watcher->scan_options.ignore_rules.reset();\n}\n\n// 更新扫描完成后的回调函数\nauto update_post_scan_callback(const std::shared_ptr<State::FolderWatcherState>& watcher,\n                               std::function<void(const Types::ScanResult&)> post_scan_callback)\n    -> void {\n  if (!post_scan_callback) {\n    return;\n  }\n\n  std::lock_guard<std::mutex> lock(watcher->pending_mutex);\n  watcher->post_scan_callback = std::move(post_scan_callback);\n}\n\n// 获取监听器配置的扫描完成后回调函数\nauto get_post_scan_callback(const std::shared_ptr<State::FolderWatcherState>& watcher)\n    -> std::function<void(const Types::ScanResult&)> {\n  std::lock_guard<std::mutex> lock(watcher->pending_mutex);\n  return watcher->post_scan_callback;\n}\n\n// 获取监听器当前的扫描配置\nauto get_watcher_scan_options(const std::shared_ptr<State::FolderWatcherState>& watcher)\n    -> Types::ScanOptions {\n  std::lock_guard<std::mutex> lock(watcher->pending_mutex);\n  auto options = watcher->scan_options;\n  options.directory = watcher->root_path.string();\n  options.ignore_rules.reset();\n  return options;\n}\n\nauto directory_notification_requires_full_rescan(Core::State::AppState& app_state,\n                                                 const ParsedNotification& notification)\n    -> std::expected<bool, std::string> {\n  switch (notification.action) {\n    case FILE_ACTION_ADDED:\n      // 运行时新建目录时，先不用急着全量扫描。\n      // 真正需要入库的内容，后面还会通过文件通知继续进入增量链路。\n      return false;\n    case FILE_ACTION_REMOVED:\n    case FILE_ACTION_RENAMED_OLD_NAME:\n    case FILE_ACTION_RENAMED_NEW_NAME:\n      // 目录被删掉或改名时，只有当这个目录下面已经有已入库资产，\n      // 才需要 full scan 去重新校正数据库里的路径状态。\n      return Features::Gallery::Asset::Repository::has_assets_under_path_prefix(\n          app_state, notification.path.string());\n    default:\n      return false;\n  }\n}\n\n// 检查是否有待处理的变更（包含全量重扫标记或文件变更）\nauto has_pending_changes(const std::shared_ptr<State::FolderWatcherState>& watcher) -> bool {\n  std::lock_guard<std::mutex> lock(watcher->pending_mutex);\n  return watcher->require_full_rescan || !watcher->pending_file_changes.empty() ||\n         !watcher->pending_stable_file_changes.empty();\n}\n\n// 获取并清空当前的待处理变更快照\nauto take_pending_snapshot(const std::shared_ptr<State::FolderWatcherState>& watcher)\n    -> PendingSnapshot {\n  std::lock_guard<std::mutex> lock(watcher->pending_mutex);\n\n  PendingSnapshot snapshot;\n  snapshot.require_full_rescan = watcher->require_full_rescan;\n  snapshot.file_changes = std::move(watcher->pending_file_changes);\n\n  watcher->require_full_rescan = false;\n\n  return snapshot;\n}\n\n// 标记当前监听器需要执行全量重扫，同时清空原本的增量变更队列\nauto mark_full_rescan(const std::shared_ptr<State::FolderWatcherState>& watcher) -> void {\n  std::lock_guard<std::mutex> lock(watcher->pending_mutex);\n  // 文件夹有变化时，直接全量扫，逻辑最稳。\n  watcher->require_full_rescan = true;\n  watcher->pending_file_changes.clear();\n  watcher->pending_stable_file_changes.clear();\n  watcher->pending_rescan.store(true, std::memory_order_release);\n}\n\nauto probe_file_state(const std::filesystem::path& path)\n    -> std::expected<std::optional<ProbedFileState>, std::string> {\n  std::error_code ec;\n  if (!std::filesystem::exists(path, ec)) {\n    if (ec) {\n      return std::unexpected(\"Failed to check file existence: \" + ec.message());\n    }\n    return std::optional<ProbedFileState>{std::nullopt};\n  }\n\n  if (!std::filesystem::is_regular_file(path, ec)) {\n    if (ec) {\n      return std::unexpected(\"Failed to check file type: \" + ec.message());\n    }\n    return std::optional<ProbedFileState>{std::nullopt};\n  }\n\n  auto file_size = std::filesystem::file_size(path, ec);\n  if (ec) {\n    return std::unexpected(\"Failed to read file size: \" + ec.message());\n  }\n\n  auto last_write_time = std::filesystem::last_write_time(path, ec);\n  if (ec) {\n    return std::unexpected(\"Failed to read file modified time: \" + ec.message());\n  }\n\n  return ProbedFileState{\n      .size = static_cast<std::int64_t>(file_size),\n      .modified_at = Utils::Time::file_time_to_millis(last_write_time),\n  };\n}\n\n// 将具体的文件变更动作加入到待处理队列，相同路径的新动作会覆盖之前的\nauto queue_file_change(const std::shared_ptr<State::FolderWatcherState>& watcher,\n                       const std::string& normalized_path, State::PendingFileChangeAction action)\n    -> void {\n  std::lock_guard<std::mutex> lock(watcher->pending_mutex);\n\n  // 同一路径只留最后一次动作，避免重复处理。\n  watcher->pending_file_changes[normalized_path] = action;\n  watcher->pending_stable_file_changes.erase(normalized_path);\n\n  watcher->pending_rescan.store(true, std::memory_order_release);\n}\n\n// 将 UPSERT 事件先放入稳定队列，避免录制中的文件被 watcher 当成成品反复分析。\nauto stage_file_change_for_stability(const std::shared_ptr<State::FolderWatcherState>& watcher,\n                                     const std::string& normalized_path) -> void {\n  auto now = std::chrono::steady_clock::now();\n  auto probe_result = probe_file_state(std::filesystem::path(normalized_path));\n\n  std::lock_guard<std::mutex> lock(watcher->pending_mutex);\n\n  watcher->pending_file_changes.erase(normalized_path);\n\n  auto& pending = watcher->pending_stable_file_changes[normalized_path];\n  pending.action = State::PendingFileChangeAction::UPSERT;\n  pending.ready_not_before = now + kFileStabilityQuietPeriod;\n\n  if (!probe_result) {\n    Logger().debug(\"Failed to probe staged gallery file '{}': {}\", normalized_path,\n                   probe_result.error());\n    pending.last_seen_size.reset();\n    pending.last_seen_modified_at.reset();\n  } else if (probe_result->has_value()) {\n    pending.last_seen_size = probe_result->value().size;\n    pending.last_seen_modified_at = probe_result->value().modified_at;\n  } else {\n    pending.last_seen_size.reset();\n    pending.last_seen_modified_at.reset();\n  }\n\n  watcher->pending_rescan.store(true, std::memory_order_release);\n}\n\n// 二次探测到期候选：\n// 只有当文件在一个静默窗口后，大小和修改时间都没有继续变化，才提升为真正的 UPSERT。\nauto promote_stable_file_changes(const std::shared_ptr<State::FolderWatcherState>& watcher)\n    -> void {\n  std::vector<std::pair<std::string, State::PendingStableFileChange>> due_candidates;\n  auto now = std::chrono::steady_clock::now();\n\n  {\n    std::lock_guard<std::mutex> lock(watcher->pending_mutex);\n    if (watcher->require_full_rescan) {\n      return;\n    }\n\n    due_candidates.reserve(watcher->pending_stable_file_changes.size());\n    for (const auto& [path, pending] : watcher->pending_stable_file_changes) {\n      if (pending.ready_not_before <= now) {\n        due_candidates.emplace_back(path, pending);\n      }\n    }\n  }\n\n  for (const auto& [path, pending] : due_candidates) {\n    auto probe_result = probe_file_state(std::filesystem::path(path));\n\n    std::lock_guard<std::mutex> lock(watcher->pending_mutex);\n    auto it = watcher->pending_stable_file_changes.find(path);\n    if (it == watcher->pending_stable_file_changes.end()) {\n      continue;\n    }\n\n    auto& current = it->second;\n    if (current.action != pending.action || current.ready_not_before != pending.ready_not_before) {\n      continue;\n    }\n\n    if (!probe_result) {\n      Logger().debug(\"Failed to re-probe staged gallery file '{}': {}\", path, probe_result.error());\n      current.ready_not_before = std::chrono::steady_clock::now() + kFileStabilityQuietPeriod;\n      continue;\n    }\n\n    if (!probe_result->has_value()) {\n      watcher->pending_stable_file_changes.erase(it);\n      continue;\n    }\n\n    const auto& probed = probe_result->value();\n    if (!current.last_seen_size.has_value() || !current.last_seen_modified_at.has_value() ||\n        current.last_seen_size.value() != probed.size ||\n        current.last_seen_modified_at.value() != probed.modified_at) {\n      current.last_seen_size = probed.size;\n      current.last_seen_modified_at = probed.modified_at;\n      current.ready_not_before = std::chrono::steady_clock::now() + kFileStabilityQuietPeriod;\n      continue;\n    }\n\n    watcher->pending_file_changes[path] = current.action;\n    watcher->pending_stable_file_changes.erase(it);\n  }\n}\n\n// 解析 Windows ReadDirectoryChangesExW 系统调用返回的变更通知缓冲区\nauto parse_notification_buffer(const std::filesystem::path& root_path, const std::byte* buffer,\n                               DWORD bytes_returned) -> std::vector<ParsedNotification> {\n  std::vector<ParsedNotification> parsed_notifications;\n\n  // 通知是链表结构，按 NextEntryOffset 一条条解析。\n  size_t offset = 0;\n  while (offset < bytes_returned) {\n    auto* info = reinterpret_cast<const FILE_NOTIFY_EXTENDED_INFORMATION*>(buffer + offset);\n    size_t filename_len = static_cast<size_t>(info->FileNameLength / sizeof(wchar_t));\n    std::wstring relative_name(info->FileName, filename_len);\n\n    auto full_path = root_path / std::filesystem::path(relative_name);\n    auto normalized_result = Utils::Path::NormalizePath(full_path);\n    if (normalized_result) {\n      parsed_notifications.push_back(ParsedNotification{\n          .path = normalized_result.value(),\n          .action = info->Action,\n          .is_directory = (info->FileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0,\n      });\n    } else {\n      Logger().warn(\"Failed to normalize watcher path '{}': {}\", full_path.string(),\n                    normalized_result.error());\n    }\n\n    if (info->NextEntryOffset == 0) {\n      break;\n    }\n    offset += info->NextEntryOffset;\n  }\n\n  return parsed_notifications;\n}\n\n// 根据文件路径在数据库中删除对应的资产数据和缩略图\nauto remove_asset_by_path(Core::State::AppState& app_state, const std::filesystem::path& path)\n    -> std::expected<bool, std::string> {\n  auto normalized_result = Utils::Path::NormalizePath(path);\n  if (!normalized_result) {\n    return std::unexpected(\"Failed to normalize path: \" + normalized_result.error());\n  }\n  auto normalized = normalized_result.value();\n\n  auto asset_result =\n      Features::Gallery::Asset::Repository::get_asset_by_path(app_state, normalized.string());\n  if (!asset_result) {\n    return std::unexpected(\"Failed to query asset by path: \" + asset_result.error());\n  }\n\n  if (!asset_result->has_value()) {\n    return false;\n  }\n\n  auto asset = asset_result->value();\n  if (auto thumbnail_result =\n          Features::Gallery::Asset::Thumbnail::delete_thumbnail(app_state, asset);\n      !thumbnail_result) {\n    Logger().debug(\"Delete thumbnail skipped for '{}': {}\", normalized.string(),\n                   thumbnail_result.error());\n  }\n\n  auto delete_result = Features::Gallery::Asset::Repository::delete_asset(app_state, asset.id);\n  if (!delete_result) {\n    return std::unexpected(\"Failed to delete asset: \" + delete_result.error());\n  }\n\n  return true;\n}\n\n// 根据文件路径执行插入或更新，包含读取属性、生成缩略图及提取颜色等全套处理逻辑\n// 成功时返回 1（插入）， 2（更新），或 0（由于文件不支持等原因被跳过）\nauto upsert_asset_by_path(Core::State::AppState& app_state, const std::filesystem::path& root_path,\n                          const Types::ScanOptions& options,\n                          const std::vector<Types::IgnoreRule>& ignore_rules,\n                          const std::unordered_map<std::string, std::int64_t>& folder_mapping,\n                          std::optional<Utils::Image::WICFactory>& wic_factory,\n                          const std::filesystem::path& path) -> std::expected<int, std::string> {\n  auto normalized_result = Utils::Path::NormalizePath(path);\n  if (!normalized_result) {\n    return std::unexpected(\"Failed to normalize path: \" + normalized_result.error());\n  }\n  auto normalized = normalized_result.value();\n\n  std::error_code ec;\n  if (!std::filesystem::exists(normalized, ec) ||\n      !std::filesystem::is_regular_file(normalized, ec) || ec) {\n    auto remove_result = remove_asset_by_path(app_state, normalized);\n    if (!remove_result) {\n      return std::unexpected(remove_result.error());\n    }\n    return 0;\n  }\n\n  const auto supported_extensions =\n      options.supported_extensions.value_or(ScanCommon::default_supported_extensions());\n  if (!ScanCommon::is_supported_file(normalized, supported_extensions)) {\n    return 0;\n  }\n\n  if (Features::Gallery::Ignore::Service::apply_ignore_rules(normalized, root_path, ignore_rules)) {\n    return 0;\n  }\n\n  auto file_size = std::filesystem::file_size(normalized, ec);\n  if (ec) {\n    return std::unexpected(\"Failed to read file size: \" + ec.message());\n  }\n\n  auto last_write_time = std::filesystem::last_write_time(normalized, ec);\n  if (ec) {\n    return std::unexpected(\"Failed to read file modified time: \" + ec.message());\n  }\n\n  auto creation_time_result = Utils::Time::get_file_creation_time_millis(normalized);\n  if (!creation_time_result) {\n    return std::unexpected(\"Failed to read file creation time: \" + creation_time_result.error());\n  }\n\n  auto existing_result =\n      Features::Gallery::Asset::Repository::get_asset_by_path(app_state, normalized.string());\n  if (!existing_result) {\n    return std::unexpected(\"Failed to query existing asset: \" + existing_result.error());\n  }\n\n  auto existing_asset = existing_result.value();\n  auto file_modified_millis = Utils::Time::file_time_to_millis(last_write_time);\n\n  if (existing_asset && existing_asset->size.value_or(0) == static_cast<std::int64_t>(file_size) &&\n      existing_asset->file_modified_at.value_or(0) == file_modified_millis) {\n    return 0;\n  }\n\n  auto hash_result = ScanCommon::calculate_file_hash(normalized);\n  if (!hash_result) {\n    return std::unexpected(hash_result.error());\n  }\n  auto hash = hash_result.value();\n\n  if (existing_asset && existing_asset->hash.has_value() && !existing_asset->hash->empty() &&\n      existing_asset->hash.value() == hash) {\n    return 0;\n  }\n\n  auto asset_type = ScanCommon::detect_asset_type(normalized);\n\n  Types::Asset asset{\n      .id = existing_asset ? existing_asset->id : 0,\n      .name = normalized.filename().string(),\n      .path = normalized.string(),\n      .type = asset_type,\n      .description = existing_asset ? existing_asset->description : std::nullopt,\n      .width = std::nullopt,\n      .height = std::nullopt,\n      .size = static_cast<std::int64_t>(file_size),\n      .extension = std::nullopt,\n      .mime_type = \"application/octet-stream\",\n      .hash = std::optional<std::string>{hash},\n      .folder_id = std::nullopt,\n      .file_created_at = creation_time_result.value(),\n      .file_modified_at = file_modified_millis,\n      .created_at = existing_asset ? existing_asset->created_at : 0,\n      .updated_at = existing_asset ? existing_asset->updated_at : 0,\n  };\n  std::vector<Features::Gallery::Color::Types::ExtractedColor> extracted_colors;\n\n  if (normalized.has_extension()) {\n    asset.extension = Utils::String::ToLowerAscii(normalized.extension().string());\n  }\n\n  auto parent_path = normalized.parent_path().string();\n  if (auto folder_it = folder_mapping.find(parent_path); folder_it != folder_mapping.end()) {\n    asset.folder_id = folder_it->second;\n  }\n\n  if (asset_type == \"photo\") {\n    if (!wic_factory.has_value()) {\n      auto wic_result = Utils::Image::get_thread_wic_factory();\n      if (wic_result) {\n        wic_factory = std::move(wic_result.value());\n      } else {\n        Logger().warn(\"Failed to initialize WIC factory for watcher sync: {}\", wic_result.error());\n      }\n    }\n\n    if (wic_factory.has_value()) {\n      auto image_info_result = Utils::Image::get_image_info(wic_factory->get(), normalized);\n      if (image_info_result) {\n        auto image_info = image_info_result.value();\n        asset.width = static_cast<std::int32_t>(image_info.width);\n        asset.height = static_cast<std::int32_t>(image_info.height);\n        asset.mime_type = std::move(image_info.mime_type);\n      } else {\n        Logger().warn(\"Failed to read image info for '{}': {}\", normalized.string(),\n                      image_info_result.error());\n        asset.width = 0;\n        asset.height = 0;\n      }\n\n      if (options.generate_thumbnails.value_or(true)) {\n        auto thumbnail_result = Features::Gallery::Asset::Thumbnail::generate_thumbnail(\n            app_state, *wic_factory, normalized, hash, options.thumbnail_short_edge.value_or(480));\n        if (!thumbnail_result) {\n          Logger().warn(\"Failed to generate thumbnail for '{}': {}\", normalized.string(),\n                        thumbnail_result.error());\n        }\n      }\n\n      auto color_result =\n          Features::Gallery::Color::Extractor::extract_main_colors(*wic_factory, normalized);\n      if (color_result) {\n        extracted_colors = std::move(color_result.value());\n      } else {\n        Logger().warn(\"Failed to extract main colors for '{}': {}\", normalized.string(),\n                      color_result.error());\n      }\n    } else {\n      asset.width = 0;\n      asset.height = 0;\n      extracted_colors.clear();\n    }\n  } else if (asset_type == \"video\") {\n    // 与扫描一致：仅元数据 + 可选封面；不写颜色索引（末尾 clear）。\n    auto video_result = Utils::Media::VideoAsset::analyze_video_file(\n        normalized, options.generate_thumbnails.value_or(true)\n                        ? std::optional<std::uint32_t>{options.thumbnail_short_edge.value_or(480)}\n                        : std::nullopt);\n    if (video_result) {\n      asset.width = static_cast<std::int32_t>(video_result->width);\n      asset.height = static_cast<std::int32_t>(video_result->height);\n      asset.mime_type = video_result->mime_type;\n\n      if (video_result->thumbnail.has_value()) {\n        auto thumbnail_result = Features::Gallery::Asset::Thumbnail::save_thumbnail_data(\n            app_state, hash, *video_result->thumbnail);\n        if (!thumbnail_result) {\n          Logger().warn(\"Failed to save video thumbnail for '{}': {}\", normalized.string(),\n                        thumbnail_result.error());\n        }\n      }\n    } else {\n      return std::unexpected(\"Failed to analyze video file '\" + normalized.string() +\n                             \"': \" + video_result.error());\n    }\n\n    extracted_colors.clear();\n  } else {\n    asset.width = 0;\n    asset.height = 0;\n    extracted_colors.clear();\n  }\n\n  if (existing_asset) {\n    auto update_result = Features::Gallery::Asset::Repository::update_asset(app_state, asset);\n    if (!update_result) {\n      return std::unexpected(\"Failed to update asset: \" + update_result.error());\n    }\n\n    auto color_replace_result = Features::Gallery::Color::Repository::replace_asset_colors(\n        app_state, asset.id, extracted_colors);\n    if (!color_replace_result) {\n      return std::unexpected(\"Failed to update asset colors: \" + color_replace_result.error());\n    }\n    return 2;\n  }\n\n  auto create_result = Features::Gallery::Asset::Repository::create_asset(app_state, asset);\n  if (!create_result) {\n    return std::unexpected(\"Failed to create asset: \" + create_result.error());\n  }\n\n  auto color_replace_result = Features::Gallery::Color::Repository::replace_asset_colors(\n      app_state, create_result.value(), extracted_colors);\n  if (!color_replace_result) {\n    return std::unexpected(\"Failed to create asset colors: \" + color_replace_result.error());\n  }\n  return 1;\n}\n\n// 将接收到的待处理快照应用到数据库的最终逻辑（增量模式）\nauto apply_incremental_sync(Core::State::AppState& app_state,\n                            const std::shared_ptr<State::FolderWatcherState>& watcher,\n                            const PendingSnapshot& snapshot)\n    -> std::expected<Types::ScanResult, std::string> {\n  Types::ScanResult result{};\n  auto options = get_watcher_scan_options(watcher);\n\n  auto root_folder_result = Features::Gallery::Folder::Repository::get_folder_by_path(\n      app_state, watcher->root_path.string());\n  if (!root_folder_result) {\n    return std::unexpected(\"Failed to query root folder: \" + root_folder_result.error());\n  }\n\n  std::optional<std::int64_t> root_folder_id;\n  if (root_folder_result->has_value()) {\n    root_folder_id = root_folder_result->value().id;\n  }\n\n  auto rules_result =\n      Features::Gallery::Ignore::Service::load_ignore_rules(app_state, root_folder_id);\n  if (!rules_result) {\n    return std::unexpected(\"Failed to load ignore rules: \" + rules_result.error());\n  }\n  auto ignore_rules = std::move(rules_result.value());\n  const auto supported_extensions =\n      options.supported_extensions.value_or(ScanCommon::default_supported_extensions());\n\n  std::vector<std::filesystem::path> upsert_paths;\n  upsert_paths.reserve(snapshot.file_changes.size());\n\n  // 先收集真正可能入库的 UPSERT 路径，避免被忽略或不支持的文件污染 folders 索引。\n  for (const auto& [path, action] : snapshot.file_changes) {\n    if (action != State::PendingFileChangeAction::UPSERT) {\n      continue;\n    }\n\n    auto candidate_path = std::filesystem::path(path);\n    if (!ScanCommon::is_supported_file(candidate_path, supported_extensions)) {\n      continue;\n    }\n\n    if (Features::Gallery::Ignore::Service::apply_ignore_rules(candidate_path, watcher->root_path,\n                                                               ignore_rules)) {\n      continue;\n    }\n\n    upsert_paths.emplace_back(std::move(candidate_path));\n  }\n\n  std::unordered_map<std::string, std::int64_t> folder_mapping;\n  if (!upsert_paths.empty()) {\n    auto folder_paths = Features::Gallery::Folder::Service::extract_unique_folder_paths(\n        upsert_paths, watcher->root_path);\n    auto mapping_result =\n        Features::Gallery::Folder::Service::batch_create_folders_for_paths(app_state, folder_paths);\n    if (mapping_result) {\n      folder_mapping = std::move(mapping_result.value());\n    } else {\n      Logger().warn(\"Failed to pre-create folders for incremental sync '{}': {}\",\n                    watcher->root_path.string(), mapping_result.error());\n    }\n  }\n\n  std::optional<Utils::Image::WICFactory> wic_factory;\n\n  for (const auto& [path, action] : snapshot.file_changes) {\n    if (action != State::PendingFileChangeAction::REMOVE) {\n      continue;\n    }\n\n    auto remove_result = remove_asset_by_path(app_state, std::filesystem::path(path));\n    if (!remove_result) {\n      result.errors.push_back(remove_result.error());\n      continue;\n    }\n\n    if (remove_result.value()) {\n      result.deleted_items++;\n    }\n    // 无论索引里是否仍有该行（例如 RPC 已先行删库），都把 REMOVE 写入 changes：\n    // ScanChange 表示监视根下的路径已从磁盘消失，供扩展做派生同步（如硬链接撤销）。\n    result.changes.push_back(Types::ScanChange{\n        .path = path,\n        .action = Types::ScanChangeAction::REMOVE,\n    });\n  }\n\n  for (const auto& [path, action] : snapshot.file_changes) {\n    if (action != State::PendingFileChangeAction::UPSERT) {\n      continue;\n    }\n\n    auto upsert_result =\n        upsert_asset_by_path(app_state, watcher->root_path, options, ignore_rules, folder_mapping,\n                             wic_factory, std::filesystem::path(path));\n    if (!upsert_result) {\n      result.errors.push_back(upsert_result.error());\n      continue;\n    }\n\n    if (upsert_result.value() == 1) {\n      result.new_items++;\n      // NEW / UPDATED 对派生消费者来说都属于“应确保目标状态存在”的 UPSERT。\n      result.changes.push_back(Types::ScanChange{\n          .path = path,\n          .action = Types::ScanChangeAction::UPSERT,\n      });\n    } else if (upsert_result.value() == 2) {\n      result.updated_items++;\n      result.changes.push_back(Types::ScanChange{\n          .path = path,\n          .action = Types::ScanChangeAction::UPSERT,\n      });\n    }\n  }\n\n  result.total_files = static_cast<int>(snapshot.file_changes.size());\n  result.scan_duration = \"incremental\";\n  return result;\n}\n\n// 执行全量重扫逻辑（直接代理到 Scanner 模块）\nauto apply_full_rescan(Core::State::AppState& app_state,\n                       const std::shared_ptr<State::FolderWatcherState>& watcher)\n    -> std::expected<Types::ScanResult, std::string> {\n  auto options = get_watcher_scan_options(watcher);\n  auto scan_result = Features::Gallery::Scanner::scan_asset_directory(app_state, options);\n  if (!scan_result) {\n    return std::unexpected(scan_result.error());\n  }\n\n  auto thumbnail_repair_result = Features::Gallery::Asset::Thumbnail::repair_missing_thumbnails(\n      app_state, watcher->root_path, options.thumbnail_short_edge.value_or(480));\n  if (!thumbnail_repair_result) {\n    Logger().warn(\"Gallery watcher thumbnail repair failed for '{}': {}\",\n                  watcher->root_path.string(), thumbnail_repair_result.error());\n    return scan_result;\n  }\n\n  const auto& stats = thumbnail_repair_result.value();\n  Logger().info(\n      \"Gallery watcher thumbnail repair finished for '{}'. context=full_rescan, candidates={}, \"\n      \"missing={}, repaired={}, failed={}, skipped_missing_sources={}\",\n      watcher->root_path.string(), stats.candidate_hashes, stats.missing_thumbnails,\n      stats.repaired_thumbnails, stats.failed_repairs, stats.skipped_missing_sources);\n  return scan_result;\n}\n\nauto apply_offline_scan_changes(Core::State::AppState& app_state,\n                                const std::shared_ptr<State::FolderWatcherState>& watcher,\n                                const std::vector<Types::ScanChange>& changes)\n    -> std::expected<Types::ScanResult, std::string> {\n  // 启动恢复并不重新发明一套“离线同步逻辑”，\n  // 而是把 USN 产出的 ScanChange 重新装配成 watcher 已有的增量输入。\n  PendingSnapshot snapshot;\n  for (const auto& change : changes) {\n    snapshot.file_changes[change.path] = change.action == Types::ScanChangeAction::REMOVE\n                                             ? State::PendingFileChangeAction::REMOVE\n                                             : State::PendingFileChangeAction::UPSERT;\n  }\n\n  auto result = apply_incremental_sync(app_state, watcher, snapshot);\n  if (!result) {\n    return std::unexpected(result.error());\n  }\n\n  result->scan_duration = \"startup_usn_recovery\";\n  return result;\n}\n\nauto dispatch_scan_result(Core::State::AppState& app_state,\n                          const std::shared_ptr<State::FolderWatcherState>& watcher,\n                          const Types::ScanResult& result, std::string_view mode,\n                          bool force_gallery_changed = false) -> void {\n  // 统一收口：日志、gallery.changed 通知、post_scan_callback 都在这里发。\n  // 这样启动恢复与运行时增量可以共用同一套“扫描完成后处理”。\n  Logger().info(\n      \"Gallery sync finished for '{}'. mode={}, total={}, new={}, updated={}, deleted={}, \"\n      \"errors={}\",\n      watcher->root_path.string(), mode, result.total_files, result.new_items, result.updated_items,\n      result.deleted_items, result.errors.size());\n\n  if (force_gallery_changed || result.new_items > 0 || result.updated_items > 0 ||\n      result.deleted_items > 0 || !result.changes.empty()) {\n    Core::RPC::NotificationHub::send_notification(app_state, \"gallery.changed\");\n  }\n\n  auto post_scan_callback = get_post_scan_callback(watcher);\n  if (post_scan_callback && (result.new_items > 0 || result.updated_items > 0 ||\n                             result.deleted_items > 0 || !result.changes.empty())) {\n    post_scan_callback(result);\n  }\n}\n\nauto schedule_sync_task(Core::State::AppState& app_state,\n                        const std::shared_ptr<State::FolderWatcherState>& watcher) -> void;\n\n// 标记监听器为全量重扫状态并触发下次同步任务\nauto request_full_rescan(Core::State::AppState& app_state,\n                         const std::shared_ptr<State::FolderWatcherState>& watcher) -> void {\n  mark_full_rescan(watcher);\n  schedule_sync_task(app_state, watcher);\n}\n\nauto run_startup_full_rescan(Core::State::AppState& app_state,\n                             const std::shared_ptr<State::FolderWatcherState>& watcher)\n    -> std::expected<void, std::string> {\n  // 启动阶段的 full scan 需要“当场跑完”，\n  // 这样所有 root 的原图/DB 一致性收敛后，才能安全进入后面的全局缩略图缓存对账。\n  bool expected = false;\n  if (!watcher->scan_in_progress.compare_exchange_strong(expected, true,\n                                                         std::memory_order_acq_rel)) {\n    return std::unexpected(\"Startup full rescan skipped: scan task is already in progress\");\n  }\n\n  mark_full_rescan(watcher);\n  watcher->pending_rescan.store(false, std::memory_order_release);\n  [[maybe_unused]] auto startup_snapshot = take_pending_snapshot(watcher);\n\n  auto sync_result = apply_full_rescan(app_state, watcher);\n  if (sync_result) {\n    dispatch_scan_result(app_state, watcher, sync_result.value(), \"startup_full\", true);\n  }\n\n  watcher->scan_in_progress.store(false, std::memory_order_release);\n  if (!watcher->stop_requested.load(std::memory_order_acquire) &&\n      (watcher->pending_rescan.load(std::memory_order_acquire) || has_pending_changes(watcher))) {\n    schedule_sync_task(app_state, watcher);\n  }\n\n  if (!sync_result) {\n    return std::unexpected(sync_result.error());\n  }\n\n  return {};\n}\n\n// 核心后台处理循环，具有防抖动合并（Debounce）功能，负责将积攒的变更应用到数据库\nauto process_pending_sync(Core::State::AppState& app_state,\n                          const std::shared_ptr<State::FolderWatcherState>& watcher) -> void {\n  // 每 500ms 合并一次改动，再做一次同步。\n  while (!watcher->stop_requested.load(std::memory_order_acquire)) {\n    std::this_thread::sleep_for(kDebounceDelay);\n\n    watcher->pending_rescan.store(false, std::memory_order_release);\n    // 先尝试把“已经安静下来”的候选文件提升到最终增量队列。\n    promote_stable_file_changes(watcher);\n    auto snapshot = take_pending_snapshot(watcher);\n    bool has_executable_changes = snapshot.require_full_rescan || !snapshot.file_changes.empty();\n\n    if (!has_executable_changes) {\n      // 这里可能仍有“未稳定”的候选文件，所以不能直接退出 worker。\n      if (watcher->pending_rescan.load(std::memory_order_acquire) || has_pending_changes(watcher)) {\n        continue;\n      }\n      break;\n    }\n\n    auto sync_result = snapshot.require_full_rescan\n                           ? apply_full_rescan(app_state, watcher)\n                           : apply_incremental_sync(app_state, watcher, snapshot);\n\n    if (!sync_result) {\n      Logger().error(\"Gallery sync failed for '{}': {}\", watcher->root_path.string(),\n                     sync_result.error());\n    } else {\n      dispatch_scan_result(app_state, watcher, sync_result.value(),\n                           snapshot.require_full_rescan ? \"full\" : \"incremental\",\n                           snapshot.require_full_rescan);\n    }\n\n    if (watcher->pending_rescan.load(std::memory_order_acquire) || has_pending_changes(watcher)) {\n      continue;\n    }\n\n    break;\n  }\n\n  watcher->scan_in_progress.store(false, std::memory_order_release);\n  if (!watcher->stop_requested.load(std::memory_order_acquire) &&\n      (watcher->pending_rescan.load(std::memory_order_acquire) || has_pending_changes(watcher))) {\n    schedule_sync_task(app_state, watcher);\n  }\n}\n\n// 提交异步同步任务到线程池（使用 CAS 保证同一目录同时仅有一个任务在执行）\nauto schedule_sync_task(Core::State::AppState& app_state,\n                        const std::shared_ptr<State::FolderWatcherState>& watcher) -> void {\n  watcher->pending_rescan.store(true, std::memory_order_release);\n\n  bool expected = false;\n  // 同一个根目录同一时刻只跑一个同步任务。\n  if (!watcher->scan_in_progress.compare_exchange_strong(expected, true,\n                                                         std::memory_order_acq_rel)) {\n    return;\n  }\n\n  bool submitted = Core::WorkerPool::submit_task(*app_state.worker_pool, [&app_state, watcher]() {\n    process_pending_sync(app_state, watcher);\n  });\n  if (!submitted) {\n    watcher->scan_in_progress.store(false, std::memory_order_release);\n    Logger().warn(\"Failed to submit gallery sync task for '{}'\", watcher->root_path.string());\n  }\n}\n\n// 将 Windows 系统回调的原始变更通知分类转换为内部的 INSERT/UPDATE/DELETE 动作\nauto process_watch_notifications(Core::State::AppState& app_state,\n                                 const std::shared_ptr<State::FolderWatcherState>& watcher,\n                                 const std::vector<ParsedNotification>& notifications) -> void {\n  bool require_full_rescan = false;\n  std::vector<ParsedNotification> effective_notifications;\n  effective_notifications.reserve(notifications.size());\n\n  for (const auto& notification : notifications) {\n    if (is_path_in_manual_move_ignore(app_state, notification.path)) {\n      Logger().debug(\"Watcher ignored notification for manual move path '{}', action={}\",\n                     notification.path.string(), notification.action);\n      continue;\n    }\n    effective_notifications.push_back(notification);\n  }\n\n  if (effective_notifications.empty()) {\n    return;\n  }\n\n  for (const auto& notification : effective_notifications) {\n    if (!notification.is_directory) {\n      continue;\n    }\n\n    switch (notification.action) {\n      case FILE_ACTION_ADDED:\n      case FILE_ACTION_REMOVED:\n      case FILE_ACTION_RENAMED_OLD_NAME:\n      case FILE_ACTION_RENAMED_NEW_NAME: {\n        // 目录事件和文件事件不一样：\n        // 文件可以直接按单个路径做增量，目录则要先判断会不会波及已有资产。\n        auto require_full_scan_result =\n            directory_notification_requires_full_rescan(app_state, notification);\n        if (!require_full_scan_result) {\n          Logger().warn(\n              \"Failed to inspect directory change impact for '{}': {}. Scheduling full rescan.\",\n              notification.path.string(), require_full_scan_result.error());\n          require_full_rescan = true;\n          break;\n        }\n\n        if (require_full_scan_result.value()) {\n          Logger().debug(\n              \"Directory structural change detected for '{}', action={}, indexed assets affected, \"\n              \"scheduling full rescan\",\n              notification.path.string(), notification.action);\n          require_full_rescan = true;\n        } else {\n          Logger().debug(\n              \"Directory structural change detected for '{}', action={}, no indexed assets \"\n              \"affected, keeping incremental path\",\n              notification.path.string(), notification.action);\n        }\n      } break;\n      case FILE_ACTION_MODIFIED:\n        Logger().debug(\n            \"Directory metadata change detected for '{}', action={}, keeping incremental path\",\n            notification.path.string(), notification.action);\n        break;\n      default:\n        break;\n    }\n\n    if (require_full_rescan) {\n      break;\n    }\n  }\n\n  if (require_full_rescan) {\n    request_full_rescan(app_state, watcher);\n    return;\n  }\n\n  for (const auto& notification : effective_notifications) {\n    auto normalized_path = notification.path.string();\n\n    switch (notification.action) {\n      case FILE_ACTION_ADDED:\n      case FILE_ACTION_MODIFIED:\n      case FILE_ACTION_RENAMED_NEW_NAME:\n        // 文件刚出现或仍在写入时，先观察稳定性，再决定是否真正入库。\n        stage_file_change_for_stability(watcher, normalized_path);\n        break;\n      case FILE_ACTION_REMOVED:\n      case FILE_ACTION_RENAMED_OLD_NAME:\n        queue_file_change(watcher, normalized_path, State::PendingFileChangeAction::REMOVE);\n        break;\n      default:\n        break;\n    }\n  }\n\n  schedule_sync_task(app_state, watcher);\n}\n\n// 目录监听主循环（运行在独立线程中），阻塞调用系统 API 读取文件变更\nauto run_watch_loop(Core::State::AppState& app_state,\n                    const std::shared_ptr<State::FolderWatcherState>& watcher,\n                    std::stop_token stop_token) -> void {\n  auto directory_handle = CreateFileW(watcher->root_path.c_str(), FILE_LIST_DIRECTORY,\n                                      FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,\n                                      nullptr, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS, nullptr);\n  if (directory_handle == INVALID_HANDLE_VALUE) {\n    Logger().error(\"Failed to open watcher directory '{}', error={}\", watcher->root_path.string(),\n                   GetLastError());\n    return;\n  }\n\n  watcher->directory_handle.store(directory_handle, std::memory_order_release);\n  Logger().info(\"Gallery watcher started: {}\", watcher->root_path.string());\n\n  std::vector<std::byte> buffer(kWatchBufferSize);\n  DWORD filter = FILE_NOTIFY_CHANGE_FILE_NAME | FILE_NOTIFY_CHANGE_DIR_NAME |\n                 FILE_NOTIFY_CHANGE_LAST_WRITE | FILE_NOTIFY_CHANGE_CREATION |\n                 FILE_NOTIFY_CHANGE_SIZE;\n\n  while (!stop_token.stop_requested() && !watcher->stop_requested.load(std::memory_order_acquire)) {\n    DWORD bytes_returned = 0;\n    // 这里会阻塞等事件；关闭时用 CancelIoEx 打断。\n    BOOL ok = ReadDirectoryChangesExW(\n        directory_handle, buffer.data(), static_cast<DWORD>(buffer.size()), TRUE, filter,\n        &bytes_returned, nullptr, nullptr, ReadDirectoryNotifyExtendedInformation);\n\n    if (!ok) {\n      auto error = GetLastError();\n      if (stop_token.stop_requested() || error == ERROR_OPERATION_ABORTED) {\n        break;\n      }\n\n      if (error == ERROR_NOTIFY_ENUM_DIR) {\n        Logger().warn(\"Watcher overflow for '{}', scheduling full rescan\",\n                      watcher->root_path.string());\n        request_full_rescan(app_state, watcher);\n        continue;\n      }\n\n      Logger().warn(\"ReadDirectoryChangesExW failed for '{}', error={}\",\n                    watcher->root_path.string(), error);\n      std::this_thread::sleep_for(std::chrono::milliseconds(200));\n      continue;\n    }\n\n    if (bytes_returned == 0) {\n      request_full_rescan(app_state, watcher);\n      continue;\n    }\n\n    auto notifications =\n        parse_notification_buffer(watcher->root_path, buffer.data(), bytes_returned);\n    if (!notifications.empty()) {\n      process_watch_notifications(app_state, watcher, notifications);\n    }\n  }\n\n  watcher->directory_handle.store(nullptr, std::memory_order_release);\n  CloseHandle(directory_handle);\n  Logger().info(\"Gallery watcher stopped: {}\", watcher->root_path.string());\n}\n\n// 停止并清理指定的目录监听器，取消阻塞的系统调用并等待线程退出\nauto stop_watcher(const std::shared_ptr<State::FolderWatcherState>& watcher) -> void {\n  if (!watcher) {\n    return;\n  }\n\n  watcher->stop_requested.store(true, std::memory_order_release);\n\n  if (watcher->watch_thread.joinable()) {\n    watcher->watch_thread.request_stop();\n\n    auto raw_handle = watcher->directory_handle.load(std::memory_order_acquire);\n    auto* directory_handle = static_cast<HANDLE>(raw_handle);\n    if (directory_handle && directory_handle != INVALID_HANDLE_VALUE) {\n      CancelIoEx(directory_handle, nullptr);\n    }\n\n    watcher->watch_thread.join();\n  }\n}\n\n// 启动指定目录的监听器线程，并处理初始化时的全量重扫请求\nauto start_watcher_if_needed(Core::State::AppState& app_state,\n                             const std::shared_ptr<State::FolderWatcherState>& watcher,\n                             bool bootstrap_full_scan) -> std::expected<bool, std::string> {\n  if (!watcher) {\n    return false;\n  }\n\n  if (watcher->watch_thread.joinable()) {\n    return false;\n  }\n\n  watcher->stop_requested.store(false, std::memory_order_release);\n\n  try {\n    watcher->watch_thread = std::jthread([&app_state, watcher](std::stop_token stop_token) {\n      run_watch_loop(app_state, watcher, stop_token);\n    });\n  } catch (const std::exception& e) {\n    return std::unexpected(\"Failed to start watcher thread: \" + std::string(e.what()));\n  }\n\n  if (bootstrap_full_scan) {\n    request_full_rescan(app_state, watcher);\n  }\n\n  return true;\n}\n\n// 规范化需要监听的根目录路径，检查是否存在且是目录\nauto normalize_root_directory(const std::filesystem::path& root_directory)\n    -> std::expected<std::filesystem::path, std::string> {\n  auto normalized_result = Utils::Path::NormalizePath(root_directory);\n  if (!normalized_result) {\n    return std::unexpected(\"Failed to normalize root directory: \" + normalized_result.error());\n  }\n\n  auto normalized = normalized_result.value();\n  if (!std::filesystem::exists(normalized)) {\n    return std::unexpected(\"Root directory does not exist: \" + normalized.string());\n  }\n  if (!std::filesystem::is_directory(normalized)) {\n    return std::unexpected(\"Root path is not a directory: \" + normalized.string());\n  }\n\n  return normalized;\n}\n\n// 注册一个根目录 watcher；若已存在则更新扫描参数。\nauto register_watcher_for_directory(Core::State::AppState& app_state,\n                                    const std::filesystem::path& root_directory,\n                                    const std::optional<Types::ScanOptions>& scan_options)\n    -> std::expected<void, std::string> {\n  auto normalized_result = normalize_root_directory(root_directory);\n  if (!normalized_result) {\n    return std::unexpected(normalized_result.error());\n  }\n  auto normalized_root = normalized_result.value();\n  auto key = normalized_root.string();\n\n  std::shared_ptr<State::FolderWatcherState> watcher;\n  bool watcher_created = false;\n  {\n    std::lock_guard<std::mutex> lock(app_state.gallery->folder_watchers_mutex);\n    if (auto it = app_state.gallery->folder_watchers.find(key);\n        it != app_state.gallery->folder_watchers.end()) {\n      watcher = it->second;\n    } else {\n      watcher = std::make_shared<State::FolderWatcherState>();\n      watcher->root_path = normalized_root;\n      app_state.gallery->folder_watchers.emplace(key, watcher);\n      watcher_created = true;\n    }\n  }\n\n  if (watcher_created || scan_options.has_value()) {\n    update_watcher_scan_options(watcher, scan_options);\n  }\n\n  return {};\n}\n\n// 为已注册的根目录 watcher 设置扫描完成回调。\nauto set_post_scan_callback_for_directory(\n    Core::State::AppState& app_state, const std::filesystem::path& root_directory,\n    std::function<void(const Types::ScanResult&)> post_scan_callback)\n    -> std::expected<void, std::string> {\n  auto normalized_result = normalize_root_directory(root_directory);\n  if (!normalized_result) {\n    return std::unexpected(normalized_result.error());\n  }\n\n  std::shared_ptr<State::FolderWatcherState> watcher;\n  {\n    std::lock_guard<std::mutex> lock(app_state.gallery->folder_watchers_mutex);\n    auto it = app_state.gallery->folder_watchers.find(normalized_result->string());\n    if (it == app_state.gallery->folder_watchers.end()) {\n      return std::unexpected(\"Watcher is not registered for root directory: \" +\n                             normalized_result->string());\n    }\n    watcher = it->second;\n  }\n\n  update_post_scan_callback(watcher, std::move(post_scan_callback));\n  return {};\n}\n\n// 启动一个已注册的根目录 watcher。\nauto start_watcher_for_directory(Core::State::AppState& app_state,\n                                 const std::filesystem::path& root_directory,\n                                 bool bootstrap_full_scan) -> std::expected<void, std::string> {\n  auto normalized_result = normalize_root_directory(root_directory);\n  if (!normalized_result) {\n    return std::unexpected(normalized_result.error());\n  }\n\n  std::shared_ptr<State::FolderWatcherState> watcher;\n  {\n    std::lock_guard<std::mutex> lock(app_state.gallery->folder_watchers_mutex);\n    auto it = app_state.gallery->folder_watchers.find(normalized_result->string());\n    if (it == app_state.gallery->folder_watchers.end()) {\n      return std::unexpected(\"Watcher is not registered for root directory: \" +\n                             normalized_result->string());\n    }\n    watcher = it->second;\n  }\n\n  auto start_result = start_watcher_if_needed(app_state, watcher, bootstrap_full_scan);\n  if (!start_result) {\n    return std::unexpected(start_result.error());\n  }\n\n  return {};\n}\n\n// 从程序缓存中移除对应目录的监听器并停止后台线程\nauto remove_watcher_for_directory(Core::State::AppState& app_state,\n                                  const std::filesystem::path& root_directory)\n    -> std::expected<bool, std::string> {\n  auto normalized_result = Utils::Path::NormalizePath(root_directory);\n  if (!normalized_result) {\n    return std::unexpected(\"Failed to normalize root directory: \" + normalized_result.error());\n  }\n\n  auto key = normalized_result->string();\n  std::shared_ptr<State::FolderWatcherState> watcher;\n  {\n    std::lock_guard<std::mutex> lock(app_state.gallery->folder_watchers_mutex);\n    if (auto it = app_state.gallery->folder_watchers.find(key);\n        it != app_state.gallery->folder_watchers.end()) {\n      watcher = it->second;\n      app_state.gallery->folder_watchers.erase(it);\n    }\n  }\n\n  if (!watcher) {\n    return false;\n  }\n\n  stop_watcher(watcher);\n  Logger().info(\"Gallery watcher removed: {}\", key);\n  return true;\n}\n\n// 在应用启动时，从数据库的文件夹列表中恢复所有的监听器配置\nauto restore_watchers_from_db(Core::State::AppState& app_state)\n    -> std::expected<void, std::string> {\n  auto folders_result = Features::Gallery::Folder::Repository::list_all_folders(app_state);\n  if (!folders_result) {\n    return std::unexpected(\"Failed to query folders for watcher restore: \" +\n                           folders_result.error());\n  }\n\n  size_t restored_count = 0;\n  for (const auto& folder : folders_result.value()) {\n    // 只恢复根目录；子目录已经会被递归监听到。\n    if (folder.parent_id.has_value()) {\n      continue;\n    }\n\n    auto register_result =\n        register_watcher_for_directory(app_state, std::filesystem::path(folder.path));\n    if (!register_result) {\n      Logger().warn(\"Skip watcher restore for '{}': {}\", folder.path, register_result.error());\n      continue;\n    }\n\n    restored_count++;\n  }\n\n  Logger().info(\"Gallery watcher registrations restored: {}\", restored_count);\n  return {};\n}\n\n// 启动所有已经纳入管理的（注册过的）监听器任务\nauto start_registered_watchers(Core::State::AppState& app_state)\n    -> std::expected<void, std::string> {\n  if (is_shutdown_requested(app_state)) {\n    Logger().info(\"Skip gallery watcher startup recovery: shutdown has been requested\");\n    return {};\n  }\n\n  std::vector<std::shared_ptr<State::FolderWatcherState>> watchers;\n  {\n    std::lock_guard<std::mutex> lock(app_state.gallery->folder_watchers_mutex);\n    for (auto& [_, watcher] : app_state.gallery->folder_watchers) {\n      watchers.push_back(watcher);\n    }\n  }\n\n  size_t started_count = 0;\n  std::optional<std::string> first_error;\n  std::uint32_t startup_thumbnail_short_edge = 480;\n\n  // 公共 helper：启动 watcher 线程并统一处理计数和错误记录。\n  auto try_start_watcher = [&](const std::shared_ptr<State::FolderWatcherState>& w) -> bool {\n    if (is_shutdown_requested(app_state)) {\n      Logger().info(\"Skip watcher start for '{}': shutdown has been requested\",\n                    w->root_path.string());\n      return false;\n    }\n\n    auto result = start_watcher_if_needed(app_state, w, false);\n    if (!result) {\n      Logger().warn(\"Skip watcher start for '{}': {}\", w->root_path.string(), result.error());\n      if (!first_error.has_value()) {\n        first_error = result.error();\n      }\n      return false;\n    }\n    if (result.value()) {\n      started_count++;\n    }\n    return true;\n  };\n\n  for (const auto& watcher : watchers) {\n    if (is_shutdown_requested(app_state)) {\n      Logger().info(\n          \"Stop gallery watcher startup recovery before '{}': shutdown has been requested\",\n          watcher->root_path.string());\n      break;\n    }\n\n    startup_thumbnail_short_edge =\n        std::max(startup_thumbnail_short_edge,\n                 get_watcher_scan_options(watcher).thumbnail_short_edge.value_or(480));\n\n    // 每个 root 的启动流程：\n    // 1. 查询恢复决策（USN 增量 or 全量）\n    // 2. USN 模式先补齐离线变更\n    // 3. 补完后持久化新检查点\n    // 4. 启动实时监听线程\n    auto recovery_plan_result = Features::Gallery::Recovery::Service::prepare_startup_recovery(\n        app_state, watcher->root_path, get_watcher_scan_options(watcher));\n    if (!recovery_plan_result) {\n      Logger().warn(\"Startup recovery decision failed for '{}': {}. Falling back to full scan.\",\n                    watcher->root_path.string(), recovery_plan_result.error());\n      if (!try_start_watcher(watcher)) continue;\n      auto startup_full_scan_result = run_startup_full_rescan(app_state, watcher);\n      if (!startup_full_scan_result) {\n        Logger().warn(\"Startup full scan failed for '{}': {}\", watcher->root_path.string(),\n                      startup_full_scan_result.error());\n      }\n      continue;\n    }\n\n    const auto& plan = recovery_plan_result.value();\n\n    if (plan.mode == Features::Gallery::Recovery::Types::StartupRecoveryMode::UsnJournal) {\n      Logger().info(\"Gallery startup recovery for '{}': mode=usn, reason={}, changes={}\",\n                    watcher->root_path.string(), plan.reason, plan.changes.size());\n\n      if (!plan.changes.empty()) {\n        auto recovery_apply_result = apply_offline_scan_changes(app_state, watcher, plan.changes);\n        if (!recovery_apply_result) {\n          Logger().warn(\"USN recovery apply failed for '{}': {}. Falling back to full scan.\",\n                        watcher->root_path.string(), recovery_apply_result.error());\n          // apply 失败时仍必须启动 watcher 线程，否则此 root 将无人监听。\n          if (!try_start_watcher(watcher)) continue;\n          auto startup_full_scan_result = run_startup_full_rescan(app_state, watcher);\n          if (!startup_full_scan_result) {\n            Logger().warn(\"Startup full scan failed for '{}': {}\", watcher->root_path.string(),\n                          startup_full_scan_result.error());\n          }\n          continue;\n        }\n        dispatch_scan_result(app_state, watcher, recovery_apply_result.value(), \"startup_usn\");\n      }\n\n      // plan 已携带完整的卷快照信息，直接持久化检查点，无需重新查询 journal。\n      Features::Gallery::Recovery::Types::WatchRootRecoveryState recovery_state{\n          .root_path = plan.root_path,\n          .volume_identity = plan.volume_identity,\n          .journal_id = plan.journal_id,\n          .checkpoint_usn = plan.checkpoint_usn,\n          .rule_fingerprint = plan.rule_fingerprint,\n      };\n      auto persist_result =\n          Features::Gallery::Recovery::Service::persist_recovery_state(app_state, recovery_state);\n      if (!persist_result) {\n        Logger().warn(\"Failed to persist startup recovery checkpoint for '{}': {}\",\n                      watcher->root_path.string(), persist_result.error());\n      }\n\n      if (!try_start_watcher(watcher)) continue;\n      continue;\n    }\n\n    Logger().info(\"Gallery startup recovery for '{}': mode=full_scan, reason={}\",\n                  watcher->root_path.string(), plan.reason);\n\n    if (!try_start_watcher(watcher)) continue;\n    auto startup_full_scan_result = run_startup_full_rescan(app_state, watcher);\n    if (!startup_full_scan_result) {\n      Logger().warn(\"Startup full scan failed for '{}': {}\", watcher->root_path.string(),\n                    startup_full_scan_result.error());\n    }\n  }\n\n  // 所有 root 的启动恢复都完成后，统一做一次全局缩略图缓存对账：补 missing、删 orphan。\n  auto thumbnail_reconcile_result = Features::Gallery::Asset::Thumbnail::reconcile_thumbnail_cache(\n      app_state, startup_thumbnail_short_edge);\n  if (!thumbnail_reconcile_result) {\n    Logger().warn(\"Gallery startup thumbnail cache reconcile failed: {}\",\n                  thumbnail_reconcile_result.error());\n  } else {\n    const auto& stats = thumbnail_reconcile_result.value();\n    Logger().info(\n        \"Gallery startup thumbnail cache reconcile finished. expected={}, existing={}, \"\n        \"missing={}, repaired={}, orphaned={}, deleted_orphans={}, failed_repairs={}, \"\n        \"failed_orphan_deletions={}, skipped_missing_sources={}\",\n        stats.expected_hashes, stats.existing_thumbnails, stats.missing_thumbnails,\n        stats.repaired_thumbnails, stats.orphaned_thumbnails, stats.deleted_orphaned_thumbnails,\n        stats.failed_repairs, stats.failed_orphan_deletions, stats.skipped_missing_sources);\n  }\n\n  Logger().info(\"Gallery watchers started: {} / {}\", started_count, watchers.size());\n  if (first_error.has_value()) {\n    return std::unexpected(first_error.value());\n  }\n  return {};\n}\n\nauto schedule_start_registered_watchers(Core::State::AppState& app_state) -> void {\n  if (!app_state.gallery || !app_state.worker_pool) {\n    Logger().warn(\n        \"Skip scheduling gallery watcher startup recovery: gallery state or worker pool is not \"\n        \"ready\");\n    return;\n  }\n\n  if (app_state.gallery->startup_watchers_future.has_value()) {\n    Logger().debug(\"Skip scheduling gallery watcher startup recovery: task is already running\");\n    return;\n  }\n\n  auto promise = std::make_shared<std::promise<void>>();\n  app_state.gallery->startup_watchers_future = promise->get_future();\n\n  bool submitted = Core::WorkerPool::submit_task(\n      *app_state.worker_pool, [&app_state, promise = std::move(promise)]() mutable {\n        auto result = start_registered_watchers(app_state);\n        if (!result && !is_shutdown_requested(app_state)) {\n          Logger().warn(\"Gallery watcher startup recovery failed: {}\", result.error());\n        }\n        promise->set_value();\n      });\n\n  if (!submitted) {\n    app_state.gallery->startup_watchers_future.reset();\n    Logger().warn(\"Skip scheduling gallery watcher startup recovery: worker pool is unavailable\");\n  }\n}\n\nauto wait_for_start_registered_watchers(Core::State::AppState& app_state) -> void {\n  if (!app_state.gallery || !app_state.gallery->startup_watchers_future.has_value()) {\n    return;\n  }\n  app_state.gallery->startup_watchers_future->wait();\n  app_state.gallery->startup_watchers_future.reset();\n}\n\nauto begin_manual_move_ignore(Core::State::AppState& app_state,\n                              const std::filesystem::path& source_path,\n                              const std::filesystem::path& destination_path)\n    -> std::expected<void, std::string> {\n  auto source_key_result = build_ignore_key(source_path);\n  if (!source_key_result) {\n    return std::unexpected(source_key_result.error());\n  }\n  auto destination_key_result = build_ignore_key(destination_path);\n  if (!destination_key_result) {\n    return std::unexpected(destination_key_result.error());\n  }\n\n  std::lock_guard<std::mutex> lock(app_state.gallery->manual_move_ignore_mutex);\n  cleanup_expired_manual_move_ignores(app_state);\n  const auto now = std::chrono::steady_clock::now();\n\n  auto touch = [&](const std::wstring& key) {\n    // in_flight_count 允许多个并发 move 命中同一路径，避免互相提前解除忽略。\n    auto& entry = app_state.gallery->manual_move_ignore_paths[key];\n    entry.in_flight_count += 1;\n    if (entry.ignore_until < now + kManualMoveIgnoreGracePeriod) {\n      entry.ignore_until = now + kManualMoveIgnoreGracePeriod;\n    }\n  };\n\n  touch(source_key_result.value());\n  touch(destination_key_result.value());\n  return {};\n}\n\nauto complete_manual_move_ignore(Core::State::AppState& app_state,\n                                 const std::filesystem::path& source_path,\n                                 const std::filesystem::path& destination_path)\n    -> std::expected<void, std::string> {\n  auto source_key_result = build_ignore_key(source_path);\n  if (!source_key_result) {\n    return std::unexpected(source_key_result.error());\n  }\n  auto destination_key_result = build_ignore_key(destination_path);\n  if (!destination_key_result) {\n    return std::unexpected(destination_key_result.error());\n  }\n\n  std::lock_guard<std::mutex> lock(app_state.gallery->manual_move_ignore_mutex);\n  cleanup_expired_manual_move_ignores(app_state);\n  const auto now = std::chrono::steady_clock::now();\n\n  auto touch = [&](const std::wstring& key) {\n    auto it = app_state.gallery->manual_move_ignore_paths.find(key);\n    if (it == app_state.gallery->manual_move_ignore_paths.end()) {\n      return;\n    }\n\n    // 操作完成后并不立刻解除，保留短暂 grace 窗口来过滤延迟事件。\n    it->second.in_flight_count = std::max(0, it->second.in_flight_count - 1);\n    it->second.ignore_until = now + kManualMoveIgnoreGracePeriod;\n  };\n\n  touch(source_key_result.value());\n  touch(destination_key_result.value());\n  cleanup_expired_manual_move_ignores(app_state);\n  return {};\n}\n\n// 在应用关闭时，优雅关闭所有的监听器线程和句柄资源\nauto shutdown_watchers(Core::State::AppState& app_state) -> void {\n  std::vector<std::shared_ptr<State::FolderWatcherState>> watchers;\n  {\n    std::lock_guard<std::mutex> lock(app_state.gallery->folder_watchers_mutex);\n    for (auto& [_, watcher] : app_state.gallery->folder_watchers) {\n      watchers.push_back(watcher);\n    }\n    app_state.gallery->folder_watchers.clear();\n  }\n\n  for (auto& watcher : watchers) {\n    // 先停监听线程，避免退出阶段还在提交同步任务。\n    stop_watcher(watcher);\n  }\n\n  Logger().info(\"Gallery watchers stopped: {}\", watchers.size());\n}\n\n}  // namespace Features::Gallery::Watcher\n"
  },
  {
    "path": "src/features/gallery/watcher.ixx",
    "content": "module;\n\nexport module Features.Gallery.Watcher;\n\nimport std;\nimport Core.State;\nimport Features.Gallery.Types;\n\nnamespace Features::Gallery::Watcher {\n\n// 从数据库恢复根目录 watcher 注册信息，但不立即启动监听线程。\nexport auto restore_watchers_from_db(Core::State::AppState& app_state)\n    -> std::expected<void, std::string>;\n\n// 注册一个根目录 watcher（重复调用会更新扫描参数，但不会立即启动线程）。\nexport auto register_watcher_for_directory(\n    Core::State::AppState& app_state, const std::filesystem::path& root_directory,\n    const std::optional<Types::ScanOptions>& scan_options = std::nullopt)\n    -> std::expected<void, std::string>;\n\n// 为已注册的 watcher 设置扫描完成回调。\nexport auto set_post_scan_callback_for_directory(\n    Core::State::AppState& app_state, const std::filesystem::path& root_directory,\n    std::function<void(const Types::ScanResult&)> post_scan_callback)\n    -> std::expected<void, std::string>;\n\n// 启动一个已注册的 watcher，可选是否在启动后立即全量扫描一次。\nexport auto start_watcher_for_directory(Core::State::AppState& app_state,\n                                        const std::filesystem::path& root_directory,\n                                        bool bootstrap_full_scan = true)\n    -> std::expected<void, std::string>;\n\n// 启动所有已注册的 watcher，并在启动后补做一次全量扫描。\nexport auto start_registered_watchers(Core::State::AppState& app_state)\n    -> std::expected<void, std::string>;\n\n// 启动后在后台线程中恢复并启动所有已注册 watcher。\nexport auto schedule_start_registered_watchers(Core::State::AppState& app_state) -> void;\n\n// 等待后台 watcher 启动恢复任务结束。\nexport auto wait_for_start_registered_watchers(Core::State::AppState& app_state) -> void;\n\n// 停止并移除某个目录 watcher。返回 true 表示实际移除了 watcher。\nexport auto remove_watcher_for_directory(Core::State::AppState& app_state,\n                                         const std::filesystem::path& root_directory)\n    -> std::expected<bool, std::string>;\n\n// 退出时停掉所有 watcher 线程。\nexport auto shutdown_watchers(Core::State::AppState& app_state) -> void;\n\n// 标记某条手动 move 的源/目标路径进入 watcher 忽略集合（in-flight 阶段）。\nexport auto begin_manual_move_ignore(Core::State::AppState& app_state,\n                                     const std::filesystem::path& source_path,\n                                     const std::filesystem::path& destination_path)\n    -> std::expected<void, std::string>;\n\n// 手动 move 完成后结束 in-flight，并保留一段短缓冲，吸收延迟到达的目录通知。\nexport auto complete_manual_move_ignore(Core::State::AppState& app_state,\n                                        const std::filesystem::path& source_path,\n                                        const std::filesystem::path& destination_path)\n    -> std::expected<void, std::string>;\n\n}  // namespace Features::Gallery::Watcher\n"
  },
  {
    "path": "src/features/letterbox/letterbox.cpp",
    "content": "module;\n\nmodule Features.Letterbox;\n\nimport std;\nimport Core.State;\nimport Features.Letterbox.State;\nimport Utils.Logger;\nimport <dwmapi.h>;\nimport <windows.h>;\n\nnamespace Features::Letterbox {\n\n// 全局状态指针，用于钩子回调\nCore::State::AppState* g_app_state = nullptr;\n\n// 检查是否需要显示letterbox\nauto needs_letterbox(HWND target_window) -> bool {\n  if (!target_window || !IsWindow(target_window)) {\n    return false;\n  }\n\n  RECT rect;\n  GetClientRect(target_window, &rect);\n  int window_width = rect.right - rect.left;\n  int window_height = rect.bottom - rect.top;\n\n  int screen_width = GetSystemMetrics(SM_CXSCREEN);\n  int screen_height = GetSystemMetrics(SM_CYSCREEN);\n\n  return ((window_width >= screen_width && window_height < screen_height) ||\n          (window_height >= screen_height && window_width < screen_width));\n}\n\n// 更新位置\nauto update_position(Core::State::AppState& state, HWND target_window)\n    -> std::expected<void, std::string> {\n  auto& letterbox = *state.letterbox;\n\n  if (!letterbox.is_initialized) {\n    return std::unexpected{\"Letterbox not initialized\"};\n  }\n\n  if (!letterbox.target_window) {\n    return std::unexpected{\"No target window specified\"};\n  }\n\n  // 检查目标窗口是否有效\n  if (!IsWindow(letterbox.target_window)) {\n    auto hide_result = hide(state);\n    return std::unexpected{\"Target window is no longer valid\"};\n  }\n\n  // 获取屏幕尺寸\n  int screen_width = GetSystemMetrics(SM_CXSCREEN);\n  int screen_height = GetSystemMetrics(SM_CYSCREEN);\n\n  // 设置letterbox窗口为全屏\n  SetWindowPos(letterbox.window_handle, letterbox.target_window, 0, 0, screen_width, screen_height,\n               SWP_NOACTIVATE);\n\n  // 设置计时器处理任务栏置底\n  SetTimer(letterbox.window_handle, State::TIMER_TASKBAR_ZORDER, 10, nullptr);\n\n  return {};\n}\n\n// 静态回调函数实现\nLRESULT CALLBACK letterbox_wnd_proc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam) {\n  Core::State::AppState* state = nullptr;\n\n  // 添加WM_NCCREATE处理逻辑\n  if (message == WM_NCCREATE) {\n    const auto* cs = reinterpret_cast<CREATESTRUCT*>(lParam);\n    state = reinterpret_cast<Core::State::AppState*>(cs->lpCreateParams);\n    SetWindowLongPtr(hwnd, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(state));\n  } else {\n    state = reinterpret_cast<Core::State::AppState*>(GetWindowLongPtr(hwnd, GWLP_USERDATA));\n  }\n\n  // 添加空状态检查\n  if (!state) {\n    return DefWindowProc(hwnd, message, wParam, lParam);\n  }\n\n  auto& letterbox = *state->letterbox;\n\n  switch (message) {\n    case WM_TIMER:\n      if (wParam == State::TIMER_TASKBAR_ZORDER) {\n        if (HWND taskbar = FindWindow(TEXT(\"Shell_TrayWnd\"), NULL)) {\n          SetWindowPos(taskbar, HWND_BOTTOM, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE);\n        }\n        KillTimer(hwnd, State::TIMER_TASKBAR_ZORDER);\n      }\n      break;\n\n    case WM_LBUTTONDOWN:\n    case WM_RBUTTONDOWN:\n    case WM_MBUTTONDOWN:\n      if (letterbox.target_window && IsWindow(letterbox.target_window)) {\n        SetForegroundWindow(letterbox.target_window);\n        SetWindowPos(letterbox.window_handle, letterbox.target_window, 0, 0, 0, 0,\n                     SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE);\n        SetTimer(letterbox.window_handle, State::TIMER_TASKBAR_ZORDER, 10, nullptr);\n      }\n      break;\n  }\n\n  return DefWindowProc(hwnd, message, wParam, lParam);\n}\n\nauto register_window_class(HINSTANCE instance) -> std::expected<void, std::string> {\n  WNDCLASSEX wcex = {0};\n  wcex.cbSize = sizeof(WNDCLASSEX);\n  wcex.style = CS_HREDRAW | CS_VREDRAW;\n  wcex.lpfnWndProc = letterbox_wnd_proc;\n  wcex.hInstance = instance;\n  wcex.hCursor = LoadCursor(nullptr, IDC_ARROW);\n  wcex.hbrBackground = CreateSolidBrush(RGB(0, 0, 0));\n  wcex.lpszClassName = L\"LetterboxWindowClass\";\n\n  if (!RegisterClassEx(&wcex)) {\n    return std::unexpected{\n        std::format(\"Failed to register letterbox window class, error: {}\", GetLastError())};\n  }\n\n  return {};\n}\n\nauto create_letterbox_window(State::LetterboxState& letterbox, Core::State::AppState* state)\n    -> std::expected<void, std::string> {\n  letterbox.window_handle = CreateWindowExW(WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW | WS_EX_LAYERED,\n                                            L\"LetterboxWindowClass\", L\"Letterbox\", WS_POPUP, 0, 0,\n                                            0, 0, nullptr, nullptr, letterbox.instance, state);\n\n  if (!letterbox.window_handle) {\n    return std::unexpected{\n        std::format(\"Failed to create letterbox window, error: {}\", GetLastError())};\n  }\n\n  SetLayeredWindowAttributes(letterbox.window_handle, 0, 255, LWA_ALPHA);\n\n  return {};\n}\n\n// 初始化\nauto initialize(Core::State::AppState& state, HINSTANCE instance)\n    -> std::expected<void, std::string> {\n  auto& letterbox = *state.letterbox;\n\n  if (letterbox.is_initialized) {\n    return std::unexpected{\"Letterbox already initialized\"};\n  }\n\n  // 设置全局状态指针\n  g_app_state = &state;\n\n  letterbox.instance = instance;\n\n  // 注册窗口类\n  if (auto result = register_window_class(instance); !result) {\n    return result;\n  }\n\n  // 创建letterbox窗口\n  if (auto result = create_letterbox_window(letterbox, &state); !result) {\n    return result;\n  }\n\n  letterbox.is_initialized = true;\n  return {};\n}\n\nLRESULT CALLBACK message_wnd_proc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam) {\n  Core::State::AppState* state = nullptr;\n\n  // 添加WM_NCCREATE处理逻辑\n  if (message == WM_NCCREATE) {\n    const auto* cs = reinterpret_cast<CREATESTRUCT*>(lParam);\n    state = reinterpret_cast<Core::State::AppState*>(cs->lpCreateParams);\n    SetWindowLongPtr(hwnd, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(state));\n  } else {\n    state = reinterpret_cast<Core::State::AppState*>(GetWindowLongPtr(hwnd, GWLP_USERDATA));\n  }\n\n  // 添加空状态检查\n  if (!state) {\n    return DefWindowProc(hwnd, message, wParam, lParam);\n  }\n\n  switch (message) {\n    case State::WM_TARGET_WINDOW_FOREGROUND: {\n      if (!IsWindowVisible(hwnd)) {\n        [[maybe_unused]] auto result = show(*state);\n      } else {\n        [[maybe_unused]] auto result = update_position(*state, state->letterbox->target_window);\n      }\n      break;\n    }\n\n    case State::WM_HIDE_LETTERBOX: {\n      [[maybe_unused]] auto result = hide(*state);\n      break;\n    }\n\n    case State::WM_SHOW_LETTERBOX: {\n      [[maybe_unused]] auto result = show(*state);\n      break;\n    }\n  }\n\n  return DefWindowProc(hwnd, message, wParam, lParam);\n}\n\nvoid CALLBACK win_event_proc(HWINEVENTHOOK hook, DWORD event, HWND hwnd, LONG idObject,\n                             LONG idChild, DWORD idEventThread, DWORD dwmsEventTime) {\n  // 使用全局状态指针替代窗口属性\n  auto* state = g_app_state;\n\n  if (!state || !state->letterbox->event_thread.joinable() || !state->letterbox->message_window) {\n    return;\n  }\n\n  auto& letterbox = *state->letterbox;\n\n  // 只处理与目标窗口相关的事件\n  if (hwnd == letterbox.target_window) {\n    switch (event) {\n      case EVENT_SYSTEM_FOREGROUND:\n        PostMessage(letterbox.message_window, State::WM_TARGET_WINDOW_FOREGROUND, 0, 0);\n        break;\n\n      case EVENT_SYSTEM_MINIMIZESTART:\n        PostMessage(letterbox.message_window, State::WM_HIDE_LETTERBOX, 0, 0);\n        break;\n\n      case EVENT_OBJECT_DESTROY:\n        PostMessage(letterbox.message_window, State::WM_HIDE_LETTERBOX, 0, 0);\n        break;\n    }\n  }\n}\n\nauto event_thread_proc(Core::State::AppState& state, std::stop_token stoken,\n                       const State::LetterboxConfig& config) -> void {\n  auto& letterbox = *state.letterbox;\n\n  // 保存线程ID\n  letterbox.event_thread_id = GetCurrentThreadId();\n\n  // 注册消息窗口类\n  WNDCLASSEX wcMessage = {0};\n  wcMessage.cbSize = sizeof(WNDCLASSEX);\n  wcMessage.lpfnWndProc = message_wnd_proc;\n  wcMessage.hInstance = letterbox.instance;\n  wcMessage.lpszClassName = L\"SpinningMomoLetterboxMessageClass\";\n\n  if (!RegisterClassEx(&wcMessage)) {\n    return;\n  }\n\n  // 创建消息窗口\n  letterbox.message_window =\n      CreateWindowExW(0, L\"SpinningMomoLetterboxMessageClass\", L\"LetterboxMessage\", WS_OVERLAPPED,\n                      0, 0, 0, 0, HWND_MESSAGE, nullptr, letterbox.instance, &state);\n\n  if (!letterbox.message_window) {\n    UnregisterClass(L\"SpinningMomoLetterboxMessageClass\", letterbox.instance);\n    return;\n  }\n\n  // 关联消息窗口与状态\n  SetWindowLongPtr(letterbox.message_window, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(&state));\n\n  // 获取目标窗口进程ID\n  if (letterbox.target_window) {\n    GetWindowThreadProcessId(letterbox.target_window, &letterbox.target_process_id);\n\n    // 设置窗口事件钩子\n    letterbox.event_hook = SetWinEventHook(EVENT_SYSTEM_FOREGROUND, EVENT_OBJECT_DESTROY, NULL,\n                                           win_event_proc, letterbox.target_process_id, 0,\n                                           WINEVENT_OUTOFCONTEXT | WINEVENT_SKIPOWNPROCESS);\n  }\n\n  // 消息循环\n  MSG msg;\n  while (!stoken.stop_requested() && GetMessage(&msg, NULL, 0, 0)) {\n    TranslateMessage(&msg);\n    DispatchMessage(&msg);\n  }\n\n  // 清理资源\n  if (letterbox.event_hook) {\n    UnhookWinEvent(letterbox.event_hook);\n    letterbox.event_hook = nullptr;\n  }\n\n  if (letterbox.message_window) {\n    DestroyWindow(letterbox.message_window);\n    letterbox.message_window = nullptr;\n  }\n\n  UnregisterClass(L\"SpinningMomoLetterboxMessageClass\", letterbox.instance);\n}\n\n// 启动事件监听\nauto start_event_monitoring(Core::State::AppState& state, const State::LetterboxConfig& config)\n    -> std::expected<void, std::string> {\n  auto& letterbox = *state.letterbox;\n\n  if (letterbox.event_thread.joinable()) {\n    return {};  // 已经在运行\n  }\n\n  try {\n    letterbox.event_thread = std::jthread(\n        [&state, config](std::stop_token stoken) { event_thread_proc(state, stoken, config); });\n    return {};\n  } catch (const std::exception& e) {\n    return std::unexpected{std::format(\"Failed to start event thread: {}\", e.what())};\n  }\n}\n\n// 显示\nauto show(Core::State::AppState& state, HWND target_window) -> std::expected<void, std::string> {\n  auto& letterbox = *state.letterbox;\n\n  // 检查Letterbox是否已初始化，如果未初始化则进行初始化\n  if (!letterbox.is_initialized) {\n    HINSTANCE instance = GetModuleHandle(nullptr);\n    if (auto result = initialize(state, instance); !result) {\n      return std::unexpected{\"Failed to initialize letterbox: \" + result.error()};\n    }\n  }\n\n  if (target_window) {\n    letterbox.target_window = target_window;\n  }\n\n  if (!letterbox.target_window) {\n    return std::unexpected{\"No target window specified\"};\n  }\n\n  // 检查目标窗口是否可见\n  if (!IsWindowVisible(letterbox.target_window)) {\n    return std::unexpected{\"Target window is not visible\"};\n  }\n\n  // 检查是否真正需要显示letterbox\n  if (!needs_letterbox(letterbox.target_window)) {\n    [[maybe_unused]] auto result = hide(state);\n    return {};\n  }\n\n  // 确保事件监听线程已启动\n  if (!state.letterbox->event_thread.joinable()) {\n    if (auto result = start_event_monitoring(state, State::LetterboxConfig{}); !result) {\n      return std::unexpected{\"Failed to start event monitoring: \" + result.error()};\n    }\n  }\n\n  ShowWindow(letterbox.window_handle, SW_SHOWNA);\n\n  if (auto result = update_position(state, letterbox.target_window); !result) {\n    return result;\n  }\n\n  return {};\n}\n\n// 隐藏\nauto hide(Core::State::AppState& state) -> std::expected<void, std::string> {\n  auto& letterbox = *state.letterbox;\n\n  if (!letterbox.is_initialized) {\n    return std::unexpected{\"Letterbox not initialized\"};\n  }\n\n  if (letterbox.window_handle) {\n    ShowWindow(letterbox.window_handle, SW_HIDE);\n  }\n\n  return {};\n}\n\n// 停止事件监听\nauto stop_event_monitoring(Core::State::AppState& state) -> std::expected<void, std::string> {\n  auto& letterbox = *state.letterbox;\n\n  if (letterbox.event_thread.joinable()) {\n    letterbox.event_thread.request_stop();\n    if (letterbox.event_thread_id != 0) {\n      PostThreadMessage(letterbox.event_thread_id, WM_QUIT, 0, 0);\n    }\n    letterbox.event_thread.join();\n  }\n\n  if (letterbox.event_hook) {\n    UnhookWinEvent(letterbox.event_hook);\n    letterbox.event_hook = nullptr;\n  }\n\n  return {};\n}\n\n// 关闭\nauto shutdown(Core::State::AppState& state) -> std::expected<void, std::string> {\n  auto& letterbox = *state.letterbox;\n\n  if (!letterbox.is_initialized) {\n    return {};\n  }\n\n  // 隐藏窗口\n  [[maybe_unused]] auto hide_result = hide(state);\n  // 记录错误但继续清理\n\n  // 停止事件监听\n  [[maybe_unused]] auto stop_result = stop_event_monitoring(state);\n  // 记录错误但继续清理\n\n  // 销毁窗口\n  if (letterbox.window_handle) {\n    DestroyWindow(letterbox.window_handle);\n    letterbox.window_handle = nullptr;\n  }\n\n  // 注销窗口类\n  UnregisterClass(L\"LetterboxWindowClass\", letterbox.instance);\n\n  // 清除全局状态指针\n  g_app_state = nullptr;\n\n  letterbox.is_initialized = false;\n\n  Logger().debug(\"Letterbox shutdown successfully\");\n\n  return {};\n}\n\n}  // namespace Features::Letterbox"
  },
  {
    "path": "src/features/letterbox/letterbox.ixx",
    "content": "module;\n\nexport module Features.Letterbox;\n\nimport std;\nimport Core.State;\nimport <windows.h>;\n\nnamespace Features::Letterbox {\n\n// 初始化和清理\nexport auto initialize(Core::State::AppState& state, HINSTANCE instance)\n    -> std::expected<void, std::string>;\n\nexport auto shutdown(Core::State::AppState& state) -> std::expected<void, std::string>;\n\n// 窗口操作\nexport auto show(Core::State::AppState& state, HWND target_window = nullptr)\n    -> std::expected<void, std::string>;\n\nexport auto hide(Core::State::AppState& state) -> std::expected<void, std::string>;\n\n}  // namespace Features::Letterbox"
  },
  {
    "path": "src/features/letterbox/state.ixx",
    "content": "module;\n\nexport module Features.Letterbox.State;\n\nimport std;\nimport <windows.h>;\n\nexport namespace Features::Letterbox::State {\n\nstruct LetterboxState {\n  // 状态标志\n  bool enabled{false};  // 用户是否启用黑边模式\n\n  HWND window_handle{nullptr};\n  HWND target_window{nullptr};\n  HWND message_window{nullptr};\n  HINSTANCE instance{nullptr};\n  bool is_initialized{false};\n\n  // 线程和事件相关\n  std::jthread event_thread;\n  DWORD event_thread_id{0};\n  HWINEVENTHOOK event_hook{nullptr};\n  DWORD target_process_id{0};\n};\n\nstruct LetterboxConfig {\n  bool auto_hide_on_minimize{true};\n  bool auto_show_on_foreground{true};\n  bool manage_taskbar_order{true};\n  std::chrono::milliseconds taskbar_delay{10};\n};\n\n// 自定义消息定义\nconstexpr UINT WM_TARGET_WINDOW_FOREGROUND = WM_USER + 100;\nconstexpr UINT WM_HIDE_LETTERBOX = WM_USER + 101;\nconstexpr UINT WM_SHOW_LETTERBOX = WM_USER + 102;\nconstexpr UINT WM_UPDATE_TASKBAR_ZORDER = WM_USER + 103;\n\n// 计时器ID定义\nconstexpr UINT TIMER_TASKBAR_ZORDER = 1001;\n\n}  // namespace Features::Letterbox::State"
  },
  {
    "path": "src/features/letterbox/usecase.cpp",
    "content": "module;\n\nmodule Features.Letterbox.UseCase;\n\nimport std;\nimport Core.State;\nimport Core.I18n.State;\nimport Features.Letterbox;\nimport Features.Letterbox.State;\nimport Features.Overlay;\nimport Features.Overlay.State;\nimport Features.Settings;\nimport Features.Settings.State;\nimport Features.WindowControl;\nimport Features.Notifications;\nimport Utils.Logger;\nimport Utils.String;\n\nnamespace Features::Letterbox::UseCase {\n\n// 切换黑边模式\nauto toggle_letterbox(Core::State::AppState& state) -> void {\n  bool is_enabled = state.letterbox->enabled;\n  auto old_settings = state.settings->raw;\n\n  // 切换启用状态\n  state.letterbox->enabled = !is_enabled;\n  Features::Overlay::set_letterbox_mode(state, !is_enabled);\n\n  // 同步到 settings 并持久化\n  state.settings->raw.features.letterbox.enabled = !is_enabled;\n\n  // 保存设置到文件\n  bool did_persist_settings = false;\n  auto settings_path = Features::Settings::get_settings_path();\n  if (settings_path) {\n    auto save_result =\n        Features::Settings::save_settings_to_file(settings_path.value(), state.settings->raw);\n    if (!save_result) {\n      Logger().error(\"Failed to save settings: {}\", save_result.error());\n    } else {\n      did_persist_settings = true;\n    }\n  } else {\n    Logger().error(\"Failed to get settings path: {}\", settings_path.error());\n  }\n\n  if (did_persist_settings) {\n    Features::Settings::notify_settings_changed(state, old_settings,\n                                                \"Settings updated via letterbox toggle\");\n  }\n\n  std::wstring window_title = Utils::String::FromUtf8(state.settings->raw.window.target_title);\n  auto target_window = Features::WindowControl::find_target_window(window_title);\n\n  // 根据叠加层是否运行采取不同的处理方式\n  if (state.overlay->running.load(std::memory_order_acquire)) {\n    // 叠加层正在运行时，黑边模式由叠加层模块处理\n    // 只需重启叠加层以应用新的黑边模式设置\n    if (target_window) {\n      Features::Overlay::stop_overlay(state);\n      auto start_result = Features::Overlay::start_overlay(state, target_window.value());\n\n      if (!start_result) {\n        Logger().error(\"Failed to restart overlay after letterbox mode change: {}\",\n                       start_result.error());\n        // 回滚启用状态\n        state.letterbox->enabled = is_enabled;\n        state.settings->raw.features.letterbox.enabled = is_enabled;\n        std::string error_message =\n            state.i18n->texts[\"message.overlay_start_failed\"] + start_result.error();\n        Features::Notifications::show_notification(state, state.i18n->texts[\"label.app_name\"],\n                                                   error_message);\n      }\n    }\n  } else {\n    // 叠加层未运行时，黑边模式由letterbox模块处理\n    if (!is_enabled) {\n      // 启用黑边模式\n      if (target_window) {\n        if (auto result = Features::Letterbox::show(state, target_window.value()); !result) {\n          Logger().error(\"Failed to show letterbox: {}\", result.error());\n          // 回滚启用状态\n          state.letterbox->enabled = false;\n          state.settings->raw.features.letterbox.enabled = false;\n          std::string error_message =\n              state.i18n->texts[\"message.overlay_start_failed\"] + result.error();\n          Features::Notifications::show_notification(state, state.i18n->texts[\"label.app_name\"],\n                                                     error_message);\n        }\n      }\n    } else {\n      // 禁用黑边模式\n      if (auto result = Features::Letterbox::shutdown(state); !result) {\n        Logger().error(\"Failed to shutdown letterbox: {}\", result.error());\n        std::string error_message =\n            state.i18n->texts[\"message.overlay_start_failed\"] + result.error();\n        Features::Notifications::show_notification(state, state.i18n->texts[\"label.app_name\"],\n                                                   error_message);\n      }\n    }\n  }\n}\n\n}  // namespace Features::Letterbox::UseCase\n"
  },
  {
    "path": "src/features/letterbox/usecase.ixx",
    "content": "module;\n\nexport module Features.Letterbox.UseCase;\n\nimport Core.State;\nimport UI.FloatingWindow.Events;\n\nnamespace Features::Letterbox::UseCase {\n\n// 切换黑边模式\nexport auto toggle_letterbox(Core::State::AppState& state) -> void;\n\n}  // namespace Features::Letterbox::UseCase\n"
  },
  {
    "path": "src/features/notifications/constants.ixx",
    "content": "module;\r\n\r\n#include <windows.h>\r\n#include <chrono>\r\n\r\nexport module Features.Notifications.Constants;\r\n\r\nexport namespace Features::Notifications::Constants {\r\n\r\n// 最大可见通知数量\r\nconstexpr int MAX_VISIBLE_NOTIFICATIONS = 5;\r\n\r\n// 基础尺寸 (96 DPI)\r\nconstexpr int BASE_WINDOW_WIDTH = 300;\r\nconstexpr int BASE_MIN_HEIGHT = 80;\r\nconstexpr int BASE_MAX_HEIGHT = 200;\r\nconstexpr int BASE_PADDING = 12;\r\nconstexpr int BASE_TITLE_HEIGHT = 24;\r\nconstexpr int BASE_FONT_SIZE = 14;\r\nconstexpr int BASE_TITLE_FONT_SIZE = 14;\r\nconstexpr int BASE_CLOSE_SIZE = 12;\r\nconstexpr int BASE_CLOSE_PADDING = 12;\r\nconstexpr int BASE_CONTENT_PADDING = 16;\r\nconstexpr int BASE_SPACING = 10;\r\n\r\n// 动画计时\r\nconstexpr auto SLIDE_DURATION = std::chrono::milliseconds(200);\r\nconstexpr auto FADE_DURATION = std::chrono::milliseconds(200);\r\nconstexpr auto DISPLAY_DURATION = std::chrono::milliseconds(3000);\r\n\r\n// 动画定时器\r\nconstexpr UINT_PTR ANIMATION_TIMER_ID = 1001;\r\nconstexpr UINT ANIMATION_FRAME_INTERVAL = 16;  // ~60fps\r\n\r\n// 颜色\r\nconst COLORREF BG_COLOR = RGB(255, 255, 255);\r\nconst COLORREF TEXT_COLOR = RGB(96, 96, 96);\r\nconst COLORREF TITLE_COLOR = RGB(38, 38, 38);\r\nconst COLORREF CLOSE_NORMAL_COLOR = RGB(128, 128, 128);\r\nconst COLORREF CLOSE_HOVER_COLOR = RGB(51, 51, 51);\r\n\r\n// 窗口类名\r\nconst std::wstring NOTIFICATION_WINDOW_CLASS = L\"SpinningMomoNotificationClass\";\r\n\r\n}  // namespace Features::Notifications::Constants"
  },
  {
    "path": "src/features/notifications/notifications.cpp",
    "content": "module;\n\nmodule Features.Notifications;\n\nimport std;\nimport Core.State;\nimport UI.FloatingWindow.State;\nimport Features.Notifications.State;\nimport Features.Notifications.Constants;\nimport Features.Settings.State;\nimport Utils.Logger;\nimport Utils.String;\nimport <dwmapi.h>;\nimport <windows.h>;\nimport <windowsx.h>;\n\nnamespace Features::Notifications {\n\nauto ease_out_cubic(float t) -> float {\n  float ft = 1.0f - t;\n  return 1.0f - ft * ft * ft;\n}\n\nstruct NotificationThemeColors {\n  COLORREF background = Constants::BG_COLOR;\n  COLORREF text = Constants::TEXT_COLOR;\n  COLORREF title = Constants::TITLE_COLOR;\n  COLORREF close_normal = Constants::CLOSE_NORMAL_COLOR;\n  COLORREF close_hover = Constants::CLOSE_HOVER_COLOR;\n};\n\nauto hex_char_to_int(char c) -> int {\n  if (c >= '0' && c <= '9') return c - '0';\n  if (c >= 'a' && c <= 'f') return c - 'a' + 10;\n  if (c >= 'A' && c <= 'F') return c - 'A' + 10;\n  return -1;\n}\n\nauto parse_hex_color_to_colorref(std::string_view hex_color, COLORREF fallback) -> COLORREF {\n  if (hex_color.empty()) return fallback;\n\n  if (hex_color.starts_with('#')) {\n    hex_color.remove_prefix(1);\n  }\n\n  // 只使用前 6 位 RRGGBB，忽略可选 AA。\n  if (hex_color.size() < 6) return fallback;\n\n  const int r_hi = hex_char_to_int(hex_color[0]);\n  const int r_lo = hex_char_to_int(hex_color[1]);\n  const int g_hi = hex_char_to_int(hex_color[2]);\n  const int g_lo = hex_char_to_int(hex_color[3]);\n  const int b_hi = hex_char_to_int(hex_color[4]);\n  const int b_lo = hex_char_to_int(hex_color[5]);\n\n  if (r_hi < 0 || r_lo < 0 || g_hi < 0 || g_lo < 0 || b_hi < 0 || b_lo < 0) {\n    return fallback;\n  }\n\n  const int r = (r_hi << 4) | r_lo;\n  const int g = (g_hi << 4) | g_lo;\n  const int b = (b_hi << 4) | b_lo;\n\n  return RGB(r, g, b);\n}\n\nauto resolve_notification_theme_colors(const Core::State::AppState& state)\n    -> NotificationThemeColors {\n  NotificationThemeColors colors;\n  if (!state.settings) {\n    return colors;\n  }\n\n  const auto& settings_colors = state.settings->raw.ui.floating_window_colors;\n  colors.background = parse_hex_color_to_colorref(settings_colors.background, colors.background);\n  colors.text = parse_hex_color_to_colorref(settings_colors.text, colors.text);\n  colors.title = parse_hex_color_to_colorref(settings_colors.text, colors.title);\n  colors.close_normal = parse_hex_color_to_colorref(settings_colors.text, colors.close_normal);\n  colors.close_hover = parse_hex_color_to_colorref(settings_colors.text, colors.close_hover);\n  return colors;\n}\n\nauto calculate_window_height(const std::wstring& message, int dpi) -> int {\n  HDC hdc = GetDC(NULL);\n  if (!hdc) return Constants::BASE_MIN_HEIGHT;\n\n  // 缩放字体大小\n  int fontSize = MulDiv(Constants::BASE_FONT_SIZE, dpi, 96);\n  int titleHeight = MulDiv(Constants::BASE_TITLE_HEIGHT, dpi, 96);\n  int padding = MulDiv(Constants::BASE_PADDING, dpi, 96);\n  int contentPadding = MulDiv(Constants::BASE_CONTENT_PADDING, dpi, 96);\n  int windowWidth = MulDiv(Constants::BASE_WINDOW_WIDTH, dpi, 96);\n\n  HFONT messageFont = CreateFont(-fontSize, 0, 0, 0, FW_NORMAL, FALSE, FALSE, FALSE,\n                                 DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS,\n                                 CLEARTYPE_QUALITY, DEFAULT_PITCH | FF_DONTCARE, L\"微软雅黑\");\n\n  HFONT oldFont = (HFONT)SelectObject(hdc, messageFont);\n\n  int textWidth = windowWidth - (contentPadding * 2);\n\n  RECT textRect = {0, 0, textWidth, 0};\n  UINT format = DT_CALCRECT | DT_WORDBREAK | DT_EDITCONTROL | DT_EXPANDTABS;\n  DrawText(hdc, message.c_str(), -1, &textRect, format);\n  int contentHeight = textRect.bottom - textRect.top;\n\n  SelectObject(hdc, oldFont);\n  DeleteObject(messageFont);\n  ReleaseDC(NULL, hdc);\n\n  int totalHeight = titleHeight + contentHeight + (padding * 2);\n\n  int minHeight = MulDiv(Constants::BASE_MIN_HEIGHT, dpi, 96);\n  int maxHeight = MulDiv(Constants::BASE_MAX_HEIGHT, dpi, 96);\n\n  return std::clamp(totalHeight, minHeight, maxHeight);\n}\n\nvoid draw_close_button(HDC hdc, const RECT& rect, bool is_hovered, int dpi, COLORREF normal_color,\n                       COLORREF hover_color) {\n  int close_size = MulDiv(Constants::BASE_CLOSE_SIZE, dpi, 96);\n  int close_padding = MulDiv(Constants::BASE_CLOSE_PADDING, dpi, 96);\n  int padding = MulDiv(Constants::BASE_PADDING, dpi, 96);\n\n  int x = rect.right - close_size - close_padding;\n  int y = padding;\n\n  int penWidth = std::max(2, dpi / 48);\n\n  COLORREF closeColor = is_hovered ? hover_color : normal_color;\n  LOGBRUSH lb = {BS_SOLID, closeColor, 0};\n  HPEN hClosePen = ExtCreatePen(PS_GEOMETRIC | PS_SOLID | PS_ENDCAP_ROUND | PS_JOIN_ROUND, penWidth,\n                                &lb, 0, nullptr);\n\n  HGDIOBJ oldPen = SelectObject(hdc, hClosePen);\n\n  int cross_padding = penWidth + 1;\n  int effectiveSize = close_size - (cross_padding * 2);\n\n  MoveToEx(hdc, x + cross_padding, y + cross_padding, NULL);\n  LineTo(hdc, x + cross_padding + effectiveSize, y + cross_padding + effectiveSize);\n  MoveToEx(hdc, x + cross_padding + effectiveSize, y + cross_padding, NULL);\n  LineTo(hdc, x + cross_padding, y + cross_padding + effectiveSize);\n\n  SelectObject(hdc, oldPen);\n  DeleteObject(hClosePen);\n}\n\nvoid present_notification_frame(HDC reference_hdc, Features::Notifications::State::Notification& n,\n                                int dpi) {\n  RECT rect;\n  GetClientRect(n.hwnd, &rect);\n\n  HDC memDC = CreateCompatibleDC(reference_hdc);\n  HBITMAP memBitmap = CreateCompatibleBitmap(reference_hdc, rect.right, rect.bottom);\n  HBITMAP oldBitmap = (HBITMAP)SelectObject(memDC, memBitmap);\n\n  SetBkMode(memDC, TRANSPARENT);\n\n  int title_font_size = MulDiv(Constants::BASE_TITLE_FONT_SIZE, dpi, 96);\n  int font_size = MulDiv(Constants::BASE_FONT_SIZE, dpi, 96);\n  int padding = MulDiv(Constants::BASE_PADDING, dpi, 96);\n  int title_height = MulDiv(Constants::BASE_TITLE_HEIGHT, dpi, 96);\n  int content_padding = MulDiv(Constants::BASE_CONTENT_PADDING, dpi, 96);\n  int close_padding_h = MulDiv(Constants::BASE_CLOSE_PADDING, dpi, 96);\n\n  HFONT titleFont = CreateFont(-title_font_size, 0, 0, 0, FW_NORMAL, FALSE, FALSE, FALSE,\n                               DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS,\n                               CLEARTYPE_QUALITY, DEFAULT_PITCH | FF_DONTCARE, L\"微软雅黑\");\n  HFONT messageFont = CreateFont(-font_size, 0, 0, 0, FW_NORMAL, FALSE, FALSE, FALSE,\n                                 DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS,\n                                 CLEARTYPE_QUALITY, DEFAULT_PITCH | FF_DONTCARE, L\"微软雅黑\");\n\n  HBRUSH hBackBrush = CreateSolidBrush(static_cast<COLORREF>(n.bg_color));\n  FillRect(memDC, &rect, hBackBrush);\n  DeleteObject(hBackBrush);\n\n  int textLeft = content_padding;\n  int textRight = rect.right - close_padding_h;\n\n  SelectObject(memDC, titleFont);\n  SetTextColor(memDC, static_cast<COLORREF>(n.title_color));\n  RECT titleRect = {textLeft, padding, textRight, padding + title_height};\n  DrawText(memDC, n.title.c_str(), -1, &titleRect,\n           DT_SINGLELINE | DT_LEFT | DT_VCENTER | DT_END_ELLIPSIS);\n\n  SelectObject(memDC, messageFont);\n  SetTextColor(memDC, static_cast<COLORREF>(n.text_color));\n  RECT messageRect = {textLeft, padding + title_height, textRight, rect.bottom - padding};\n  DrawText(memDC, n.message.c_str(), -1, &messageRect,\n           DT_WORDBREAK | DT_EDITCONTROL | DT_EXPANDTABS);\n\n  draw_close_button(memDC, rect, n.is_close_hovered, dpi,\n                    static_cast<COLORREF>(n.close_normal_color),\n                    static_cast<COLORREF>(n.close_hover_color));\n\n  BLENDFUNCTION blend = {AC_SRC_OVER, 0, static_cast<BYTE>(n.opacity * 255), 0};\n  POINT ptSrc = {0, 0};\n  SIZE sizeWnd = {rect.right - rect.left, rect.bottom - rect.top};\n  UpdateLayeredWindow(n.hwnd, reference_hdc, NULL, &sizeWnd, memDC, &ptSrc, 0, &blend, ULW_ALPHA);\n\n  SelectObject(memDC, oldBitmap);\n  DeleteObject(titleFont);\n  DeleteObject(messageFont);\n  DeleteObject(memBitmap);\n  DeleteDC(memDC);\n}\n\n// 窗口消息处理\nLRESULT CALLBACK NotificationWindowProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) {\n  Features::Notifications::State::Notification* notification = nullptr;\n  if (msg == WM_NCCREATE) {\n    CREATESTRUCT* cs = reinterpret_cast<CREATESTRUCT*>(lParam);\n    notification =\n        reinterpret_cast<Features::Notifications::State::Notification*>(cs->lpCreateParams);\n    SetWindowLongPtr(hwnd, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(notification));\n    notification->hwnd = hwnd;\n  } else {\n    notification = reinterpret_cast<Features::Notifications::State::Notification*>(\n        GetWindowLongPtr(hwnd, GWLP_USERDATA));\n  }\n\n  if (!notification) {\n    return DefWindowProc(hwnd, msg, wParam, lParam);\n  }\n\n  switch (msg) {\n    case WM_PAINT: {\n      PAINTSTRUCT ps;\n      HDC hdc = BeginPaint(hwnd, &ps);\n      present_notification_frame(hdc, *notification, GetDpiForWindow(hwnd));\n      EndPaint(hwnd, &ps);\n      return 0;\n    }\n\n    case WM_MOUSEMOVE: {\n      int x = GET_X_LPARAM(lParam);\n      int y = GET_Y_LPARAM(lParam);\n\n      int dpi = GetDpiForWindow(hwnd);\n      int close_size = MulDiv(Constants::BASE_CLOSE_SIZE, dpi, 96);\n      int close_padding_h = MulDiv(Constants::BASE_CLOSE_PADDING, dpi, 96);\n      int padding_v = MulDiv(Constants::BASE_PADDING, dpi, 96);\n\n      RECT client_rect;\n      GetClientRect(hwnd, &client_rect);\n      RECT close_rect = {client_rect.right - close_size - close_padding_h, padding_v,\n                         client_rect.right - close_padding_h, padding_v + close_size};\n\n      bool current_close_hover = PtInRect(&close_rect, {x, y});\n      if (notification->is_close_hovered != current_close_hover) {\n        notification->is_close_hovered = current_close_hover;\n        InvalidateRect(hwnd, NULL, TRUE);\n      }\n\n      if (!notification->is_hovered) {\n        notification->is_hovered = true;\n        if (notification->state ==\n            Features::Notifications::State::NotificationAnimState::Displaying) {\n          notification->pause_start_time = std::chrono::steady_clock::now();\n        }\n\n        TRACKMOUSEEVENT tme = {sizeof(TRACKMOUSEEVENT), TME_LEAVE, hwnd, 0};\n        TrackMouseEvent(&tme);\n      }\n      return 0;\n    }\n\n    case WM_MOUSELEAVE: {\n      if (notification->is_close_hovered) {\n        notification->is_close_hovered = false;\n        InvalidateRect(hwnd, NULL, TRUE);\n      }\n      notification->is_hovered = false;\n      if (notification->state ==\n              Features::Notifications::State::NotificationAnimState::Displaying &&\n          notification->pause_start_time.time_since_epoch().count() > 0) {\n        auto paused_duration = std::chrono::steady_clock::now() - notification->pause_start_time;\n        notification->total_paused_duration +=\n            std::chrono::duration_cast<std::chrono::milliseconds>(paused_duration);\n        notification->pause_start_time = {};  // Reset\n      }\n      InvalidateRect(hwnd, NULL, TRUE);\n      return 0;\n    }\n\n    case WM_LBUTTONUP: {\n      notification->state = Features::Notifications::State::NotificationAnimState::FadingOut;\n      notification->last_state_change_time = std::chrono::steady_clock::now();\n      return 0;\n    }\n\n    case WM_DESTROY: {\n      return 0;\n    }\n  }\n  return DefWindowProc(hwnd, msg, wParam, lParam);\n}\n\nauto register_window_class(HINSTANCE hInstance) -> void {\n  static bool registered = false;\n  if (registered) return;\n\n  WNDCLASSEX wc = {0};\n  wc.cbSize = sizeof(WNDCLASSEX);\n  wc.lpfnWndProc = NotificationWindowProc;\n  wc.hInstance = hInstance;\n  wc.lpszClassName = Constants::NOTIFICATION_WINDOW_CLASS.c_str();\n  wc.hbrBackground = NULL;\n  wc.style = CS_HREDRAW | CS_VREDRAW;\n  wc.hCursor = LoadCursor(NULL, IDC_ARROW);\n  RegisterClassEx(&wc);\n  registered = true;\n}\n\nauto create_notification_window(Core::State::AppState& state,\n                                Features::Notifications::State::Notification& notification)\n    -> bool {\n  register_window_class(state.floating_window->window.instance);\n\n  int dpi = state.floating_window->window.dpi;\n  int width = MulDiv(Constants::BASE_WINDOW_WIDTH, dpi, 96);\n\n  HWND hwnd = CreateWindowExW(WS_EX_LAYERED | WS_EX_TOOLWINDOW | WS_EX_TOPMOST,\n                              Constants::NOTIFICATION_WINDOW_CLASS.c_str(), L\"Notification\",\n                              WS_POPUP | WS_CLIPCHILDREN, notification.current_pos.x,\n                              notification.current_pos.y, width, notification.height, NULL, NULL,\n                              state.floating_window->window.instance, &notification);\n\n  if (!hwnd) return false;\n\n  // 应用现代窗口样式\n  MARGINS margins = {1, 1, 1, 1};\n  DwmExtendFrameIntoClientArea(hwnd, &margins);\n  DWM_WINDOW_CORNER_PREFERENCE corner = DWMWCP_ROUNDSMALL;\n  DwmSetWindowAttribute(hwnd, DWMWA_WINDOW_CORNER_PREFERENCE, &corner, sizeof(corner));\n\n  // HWND 存储在 notification 结构体中，通过 WM_NCCREATE 传递\n  return true;\n}\n\nauto update_window_positions(Core::State::AppState& state) -> void {\n  RECT workArea;\n  SystemParametersInfo(SPI_GETWORKAREA, 0, &workArea, 0);\n\n  int dpi = state.floating_window->window.dpi;\n  int window_width = MulDiv(Constants::BASE_WINDOW_WIDTH, dpi, 96);\n  int padding = MulDiv(Constants::BASE_PADDING, dpi, 96);\n  int spacing = MulDiv(Constants::BASE_SPACING, dpi, 96);\n\n  int current_y = workArea.bottom - padding;\n  int base_x = workArea.right - window_width - padding;\n\n  // 从下往上迭代，定位窗口\n  for (auto it = state.notifications->active_notifications.rbegin();\n       it != state.notifications->active_notifications.rend(); ++it) {\n    auto& n = *it;\n    if (n.state == Features::Notifications::State::NotificationAnimState::Done) continue;\n\n    current_y -= n.height;\n\n    POINT new_target_pos = {base_x, current_y};\n\n    if (n.target_pos.y != new_target_pos.y &&\n        n.state == Features::Notifications::State::NotificationAnimState::Displaying) {\n      n.state = Features::Notifications::State::NotificationAnimState::MovingUp;\n      n.last_state_change_time = std::chrono::steady_clock::now();\n    }\n\n    n.target_pos = new_target_pos;\n\n    if (n.state == Features::Notifications::State::NotificationAnimState::Spawning) {\n      // 新通知从底部滑入，起始位置不同\n      n.current_pos = {base_x, workArea.bottom};\n    } else if (n.state != Features::Notifications::State::NotificationAnimState::MovingUp) {\n      // 确保 current_pos 在其他状态时更新，除非正在动画\n      n.current_pos = n.target_pos;\n    }\n\n    current_y -= spacing;\n  }\n}\n\n// 公共接口\nauto show_notification(Core::State::AppState& state, const std::wstring& title,\n                       const std::wstring& message) -> void {\n  // 1. 如果活动通知数量达到上限，将最旧的标记为淡出\n  if (state.notifications->active_notifications.size() >= Constants::MAX_VISIBLE_NOTIFICATIONS) {\n    // 找到最旧的 \"Displaying\" 通知，并将其状态设置为 FadingOut\n    for (auto& n : state.notifications->active_notifications) {\n      if (n.state == Features::Notifications::State::NotificationAnimState::Displaying) {\n        n.state = Features::Notifications::State::NotificationAnimState::FadingOut;\n        n.last_state_change_time = std::chrono::steady_clock::now();\n        break;  // 只淡出一个\n      }\n    }\n  }\n\n  // 2. 创建新的通知对象\n  const auto colors = resolve_notification_theme_colors(state);\n  state.notifications->active_notifications.emplace_back(\n      Features::Notifications::State::Notification{\n          .id = state.notifications->next_id++,\n          .title = title,\n          .message = message,\n          .bg_color = static_cast<std::uint32_t>(colors.background),\n          .text_color = static_cast<std::uint32_t>(colors.text),\n          .title_color = static_cast<std::uint32_t>(colors.title),\n          .close_normal_color = static_cast<std::uint32_t>(colors.close_normal),\n          .close_hover_color = static_cast<std::uint32_t>(colors.close_hover),\n          .state = Features::Notifications::State::NotificationAnimState::Spawning,\n          .last_state_change_time = std::chrono::steady_clock::now()});\n\n  // 在更新位置前计算高度\n  auto& new_notification = state.notifications->active_notifications.back();\n  new_notification.height =\n      calculate_window_height(new_notification.message, state.floating_window->window.dpi);\n\n  // 3. 更新所有通知的位置\n  update_window_positions(state);\n\n  // 4. 启动动画定时器（如果尚未运行）\n  if (!state.notifications->animation_timer_active) {\n    if (state.floating_window && state.floating_window->window.hwnd) {\n      ::SetTimer(state.floating_window->window.hwnd, Constants::ANIMATION_TIMER_ID,\n                 Constants::ANIMATION_FRAME_INTERVAL, nullptr);\n      state.notifications->animation_timer_active = true;\n    }\n  }\n}\n\n// std::string 重载版本\nauto show_notification(Core::State::AppState& state, const std::string& title,\n                       const std::string& message) -> void {\n  // 转换为 wstring 并调用主实现\n  std::wstring wideTitle = Utils::String::FromUtf8(title);\n  std::wstring wideMessage = Utils::String::FromUtf8(message);\n  show_notification(state, wideTitle, wideMessage);\n}\n\nauto update_notifications(Core::State::AppState& state) -> void {\n  auto now = std::chrono::steady_clock::now();\n\n  // 遍历所有通知，根据其状态更新状态\n  for (auto it = state.notifications->active_notifications.begin();\n       it != state.notifications->active_notifications.end();\n       /* no increment */) {\n    auto& notification = *it;\n    bool should_erase = false;\n\n    auto elapsed_time = now - notification.last_state_change_time;\n    if (notification.state == Features::Notifications::State::NotificationAnimState::Displaying &&\n        notification.is_hovered) {\n      // 悬停时不计时间\n    } else if (notification.state ==\n               Features::Notifications::State::NotificationAnimState::Displaying) {\n      elapsed_time -= notification.total_paused_duration;\n    }\n\n    switch (notification.state) {\n      case Features::Notifications::State::NotificationAnimState::Spawning:\n        if (create_notification_window(state, notification)) {\n          notification.state = Features::Notifications::State::NotificationAnimState::SlidingIn;\n          notification.last_state_change_time = now;\n          notification.total_paused_duration = std::chrono::milliseconds(0);\n\n          if (HDC screen_dc = GetDC(NULL); screen_dc) {\n            present_notification_frame(screen_dc, notification, GetDpiForWindow(notification.hwnd));\n            ReleaseDC(NULL, screen_dc);\n          }\n\n          ShowWindow(notification.hwnd, SW_SHOWNA);\n          UpdateWindow(notification.hwnd);\n        } else {\n          // 创建失败，删除它\n          should_erase = true;\n        }\n        break;\n      case Features::Notifications::State::NotificationAnimState::SlidingIn: {\n        float progress = std::min(\n            1.0f, static_cast<float>(\n                      std::chrono::duration_cast<std::chrono::milliseconds>(elapsed_time).count()) /\n                      Constants::SLIDE_DURATION.count());\n        float eased_progress = ease_out_cubic(progress);\n\n        int new_y = notification.current_pos.y +\n                    static_cast<int>((notification.target_pos.y - notification.current_pos.y) *\n                                     eased_progress);\n\n        SetWindowPos(notification.hwnd, HWND_TOPMOST, notification.target_pos.x, new_y, 0, 0,\n                     SWP_NOSIZE | SWP_NOACTIVATE);\n        notification.opacity = eased_progress;\n\n        if (progress >= 1.0f) {\n          notification.state = Features::Notifications::State::NotificationAnimState::Displaying;\n          notification.last_state_change_time = now;\n          notification.current_pos = notification.target_pos;\n          notification.total_paused_duration = std::chrono::milliseconds(0);\n        }\n        InvalidateRect(notification.hwnd, NULL, TRUE);\n        break;\n      }\n      case Features::Notifications::State::NotificationAnimState::Displaying:\n        if (!notification.is_hovered && elapsed_time >= Constants::DISPLAY_DURATION) {\n          notification.state = Features::Notifications::State::NotificationAnimState::FadingOut;\n          notification.last_state_change_time = now;\n        }\n        break;\n      case Features::Notifications::State::NotificationAnimState::MovingUp: {\n        float progress = std::min(\n            1.0f, static_cast<float>(\n                      std::chrono::duration_cast<std::chrono::milliseconds>(elapsed_time).count()) /\n                      Constants::SLIDE_DURATION.count());\n        float eased_progress = ease_out_cubic(progress);\n\n        int new_y = notification.current_pos.y +\n                    static_cast<int>((notification.target_pos.y - notification.current_pos.y) *\n                                     eased_progress);\n\n        SetWindowPos(notification.hwnd, HWND_TOPMOST, notification.target_pos.x, new_y, 0, 0,\n                     SWP_NOSIZE | SWP_NOACTIVATE);\n\n        if (progress >= 1.0f) {\n          notification.state = Features::Notifications::State::NotificationAnimState::Displaying;\n          notification.last_state_change_time = now;\n          notification.current_pos = notification.target_pos;\n        }\n        break;\n      }\n      case Features::Notifications::State::NotificationAnimState::FadingOut: {\n        float progress = std::min(\n            1.0f, static_cast<float>(\n                      std::chrono::duration_cast<std::chrono::milliseconds>(elapsed_time).count()) /\n                      Constants::FADE_DURATION.count());\n        notification.opacity = 1.0f - ease_out_cubic(progress);\n\n        if (progress >= 1.0f) {\n          notification.state = Features::Notifications::State::NotificationAnimState::Done;\n        }\n        InvalidateRect(notification.hwnd, NULL, TRUE);\n        break;\n      }\n      case Features::Notifications::State::NotificationAnimState::Done:\n        // 标记为待删除\n        should_erase = true;\n        break;\n    }\n\n    if (should_erase) {\n      if (notification.hwnd) {\n        DestroyWindow(notification.hwnd);\n      }\n      it = state.notifications->active_notifications.erase(it);\n    } else {\n      ++it;\n    }\n  }\n\n  // 如果没有活动的通知，停止动画定时器\n  if (state.notifications->active_notifications.empty()) {\n    if (state.notifications->animation_timer_active) {\n      if (state.floating_window && state.floating_window->window.hwnd) {\n        ::KillTimer(state.floating_window->window.hwnd, Constants::ANIMATION_TIMER_ID);\n      }\n      state.notifications->animation_timer_active = false;\n    }\n  }\n}\n\n}  // namespace Features::Notifications\n"
  },
  {
    "path": "src/features/notifications/notifications.ixx",
    "content": "module;\r\n\r\nexport module Features.Notifications;\r\n\r\nimport Core.State;\r\nimport std;\r\n\r\nnamespace Features::Notifications {\r\n\r\n// 对外暴露的接口，用于触发一个新通知\r\nexport auto show_notification(Core::State::AppState& state, const std::wstring& title,\r\n                              const std::wstring& message) -> void;\r\n\r\n// std::string 重载版本，用于方便调用\r\nexport auto show_notification(Core::State::AppState& state, const std::string& title,\r\n                              const std::string& message) -> void;\r\n\r\n// 在主循环中每帧调用的核心更新函数\r\nexport auto update_notifications(Core::State::AppState& state) -> void;\r\n\r\n}  // namespace Features::Notifications"
  },
  {
    "path": "src/features/notifications/state.ixx",
    "content": "module;\r\n\r\nexport module Features.Notifications.State;\r\n\r\nimport std;\r\nimport Vendor.Windows;\r\n\r\nnamespace Features::Notifications::State {\r\n\r\n// 通知当前的动画/生命周期阶段\r\nexport enum class NotificationAnimState {\r\n  Spawning,    // 正在生成，尚未创建窗口\r\n  SlidingIn,   // 滑入动画\r\n  Displaying,  // 正常显示\r\n  MovingUp,    // 为新通知腾出空间而上移\r\n  FadingOut,   // 淡出动画\r\n  Done         // 处理完毕，待销毁\r\n};\r\n\r\n// 单个通知的所有数据\r\nexport struct Notification {\r\n  size_t id;  // 唯一ID\r\n  std::wstring title;\r\n  std::wstring message;\r\n  // 未来可以扩展: NotificationType type;\r\n\r\n  // 主题颜色快照（创建通知时读取设置，确保通知生命周期内外观稳定）\r\n  std::uint32_t bg_color = 0;\r\n  std::uint32_t text_color = 0;\r\n  std::uint32_t title_color = 0;\r\n  std::uint32_t close_normal_color = 0;\r\n  std::uint32_t close_hover_color = 0;\r\n\r\n  NotificationAnimState state = NotificationAnimState::Spawning;\r\n  Vendor::Windows::HWND hwnd = nullptr;\r\n\r\n  // 动画和时间\r\n  std::chrono::steady_clock::time_point last_state_change_time;\r\n  float opacity = 0.0f;\r\n  Vendor::Windows::POINT current_pos;\r\n  Vendor::Windows::POINT target_pos;\r\n  int height = 0;\r\n\r\n  // 鼠标悬停状态\r\n  bool is_hovered = false;\r\n  bool is_close_hovered = false;\r\n  std::chrono::milliseconds total_paused_duration{0};\r\n  std::chrono::steady_clock::time_point pause_start_time;\r\n};\r\n\r\n// 整个通知系统的状态\r\nexport struct NotificationSystemState {\r\n  // 使用 std::list 能保证元素指针和引用的稳定性，\r\n  // 这对于将指针传递给 WNDPROC 非常重要。\r\n  std::list<Notification> active_notifications;\r\n  size_t next_id = 0;\r\n\r\n  // 动画定时器状态\r\n  bool animation_timer_active = false;\r\n};\r\n\r\n}  // namespace Features::Notifications::State\r\n"
  },
  {
    "path": "src/features/overlay/capture.cpp",
    "content": "module;\n\n#include <wil/com.h>\n\nmodule Features.Overlay.Capture;\n\nimport std;\nimport Core.State;\nimport Core.State.RuntimeInfo;\nimport Features.Overlay;\nimport Features.Overlay.State;\nimport Features.Overlay.Rendering;\nimport Features.Overlay.Geometry;\nimport Features.Overlay.Interaction;\nimport Features.Overlay.Window;\nimport Utils.Logger;\nimport Utils.Graphics.Capture;\nimport <d3d11.h>;\nimport <windows.h>;\n\nnamespace Features::Overlay::Capture {\n\nauto on_frame_arrived(Core::State::AppState& state,\n                      Utils::Graphics::Capture::Direct3D11CaptureFrame frame) -> void {\n  if (!state.overlay->running.load(std::memory_order_acquire) || !frame) {\n    return;\n  }\n\n  // 检查帧大小是否发生变化\n  auto content_size = frame.ContentSize();\n  auto& last_width = state.overlay->capture_state.last_frame_width;\n  auto& last_height = state.overlay->capture_state.last_frame_height;\n  bool is_transforming = state.overlay->is_transforming.load(std::memory_order_acquire);\n  bool overlay_window_shown = state.overlay->window.overlay_window_shown;\n\n  // last_frame_* 表示“已经被 overlay 消费并对齐过”的尺寸，\n  // 不是“最近观察到”的尺寸；否则变换收尾时会丢失真正需要应用的 resize。\n  bool size_changed = (content_size.Width != last_width) || (content_size.Height != last_height);\n\n  if (size_changed) {\n    // 变换流程中，已经显示过的 overlay 不应提前消费新尺寸。\n    // 否则变换收尾前会把 last_frame_* 污染成新值，解冻后就丢失真正的 resize。\n    if (is_transforming && overlay_window_shown) {\n      return;\n    }\n\n    // 变换前临时启动 overlay 时，首帧必须继续推进到 render_frame，\n    // 否则 freeze_after_first_frame 永远不会生效，窗口变换协程也无法正常收口。\n    // 因此这里只更新 last_frame_*，把真正的显示与冻结交给下面的渲染路径。\n    if (is_transforming && !overlay_window_shown) {\n      last_width = content_size.Width;\n      last_height = content_size.Height;\n    } else {\n      // 检查是否需要退出（非变换场景，如用户手动调整窗口）\n      auto [screen_width, screen_height] = Geometry::get_screen_dimensions();\n      if (!Geometry::should_use_overlay(content_size.Width, content_size.Height, screen_width,\n                                        screen_height)) {\n        stop_overlay(state);\n        return;\n      }\n\n      // 更新记录的尺寸\n      last_width = content_size.Width;\n      last_height = content_size.Height;\n\n      // 重建帧池\n      Utils::Graphics::Capture::recreate_frame_pool(state.overlay->capture_state.session,\n                                                    content_size.Width, content_size.Height);\n\n      state.overlay->rendering.create_new_srv = true;\n\n      Window::set_overlay_window_size(state, content_size.Width, content_size.Height);\n\n      // 延迟防止闪烁\n      if (state.overlay->window.overlay_window_shown) {\n        std::this_thread::sleep_for(std::chrono::milliseconds(400));\n        Logger().debug(\"Capture size changed, sleeping for 400ms\");\n      }\n\n      return;\n    }\n  }\n\n  // 冻结状态下不处理渲染，但仍要允许上面的尺寸变化检测继续工作。\n  if (state.overlay->freeze_rendering.load(std::memory_order_acquire)) {\n    return;\n  }\n\n  auto surface = frame.Surface();\n  if (!surface) {\n    return;\n  }\n\n  auto texture = Utils::Graphics::Capture::get_dxgi_interface_from_object<ID3D11Texture2D>(surface);\n  if (!texture) {\n    return;\n  }\n\n  // 触发渲染\n  Rendering::render_frame(state, texture);\n\n  // 首次渲染时显示叠加层窗口\n  if (!state.overlay->window.overlay_window_shown) {\n    auto result = Window::show_overlay_window_first_time(state);\n    if (!result) {\n      return;\n    }\n\n    state.overlay->window.overlay_window_shown = true;\n\n    // direct-start 不一定会再收到一次前台切换事件；\n    // 首次显示后主动同步一次焦点状态，确保任务栏压制与窗口层级立即进入正确状态。\n    Features::Overlay::Interaction::refresh_focus_state(state);\n    if (state.overlay->interaction.is_game_focused) {\n      Features::Overlay::Interaction::suppress_taskbar_redraw(state);\n    }\n\n    // 首帧后自动冻结（用于窗口变换场景）\n    if (state.overlay->freeze_after_first_frame.load(std::memory_order_acquire)) {\n      state.overlay->freeze_rendering.store(true, std::memory_order_release);\n      Logger().debug(\"First frame rendered, overlay frozen for transform\");\n    }\n  }\n}\n\nauto initialize_capture(Core::State::AppState& state, HWND target_window, int width, int height)\n    -> std::expected<void, std::string> {\n  if (!target_window || !IsWindow(target_window)) {\n    return std::unexpected(\"Invalid target window\");\n  }\n\n  // 检查是否支持捕获\n  if (!state.runtime_info->is_capture_supported) {\n    return std::unexpected(\"Capture not supported on this system\");\n  }\n\n  // 确保渲染系统已初始化\n  auto& overlay_state = *state.overlay;\n  if (!overlay_state.rendering.d3d_initialized) {\n    return std::unexpected(\"D3D not initialized\");\n  }\n\n  // 创建WinRT设备\n  auto winrt_device_result = Utils::Graphics::Capture::create_winrt_device(\n      overlay_state.rendering.d3d_context.device.get());\n  if (!winrt_device_result) {\n    Logger().error(\"Failed to create WinRT device for capture\");\n    return std::unexpected(\"Failed to create WinRT device\");\n  }\n\n  // 创建帧回调\n  auto frame_callback = [&state](Utils::Graphics::Capture::Direct3D11CaptureFrame frame) {\n    on_frame_arrived(state, frame);\n  };\n\n  // 创建捕获会话\n  auto session_result = Utils::Graphics::Capture::create_capture_session(\n      target_window, winrt_device_result.value(), width, height, frame_callback);\n\n  if (!session_result) {\n    Logger().error(\"Failed to create capture session\");\n    return std::unexpected(\"Failed to create capture session\");\n  }\n\n  overlay_state.capture_state.session = std::move(session_result.value());\n\n  Logger().info(\"Capture system initialized successfully\");\n  return {};\n}\n\nauto start_capture(Core::State::AppState& state) -> std::expected<void, std::string> {\n  auto& session = state.overlay->capture_state.session;\n\n  auto start_result = Utils::Graphics::Capture::start_capture(session);\n  if (!start_result) {\n    Logger().error(\"Failed to start capture\");\n    return std::unexpected(\"Failed to start capture\");\n  }\n\n  Logger().debug(\"Capture started successfully\");\n  return {};\n}\n\nauto stop_capture(Core::State::AppState& state) -> void {\n  auto& session = state.overlay->capture_state.session;\n\n  Utils::Graphics::Capture::stop_capture(session);\n  Logger().debug(\"Capture stopped\");\n}\n\nauto cleanup_capture(Core::State::AppState& state) -> void {\n  auto& session = state.overlay->capture_state.session;\n\n  Utils::Graphics::Capture::cleanup_capture_session(session);\n  Logger().info(\"Capture resources cleaned up\");\n}\n\n}  // namespace Features::Overlay::Capture\n"
  },
  {
    "path": "src/features/overlay/capture.ixx",
    "content": "module;\n\nexport module Features.Overlay.Capture;\n\nimport std;\nimport Core.State;\nimport Vendor.Windows;\n\nnamespace Features::Overlay::Capture {\n\n// 初始化捕获系统\nexport auto initialize_capture(Core::State::AppState& state, Vendor::Windows::HWND target_window,\n                               int width, int height) -> std::expected<void, std::string>;\n\n// 开始捕获\nexport auto start_capture(Core::State::AppState& state) -> std::expected<void, std::string>;\n\n// 停止捕获\nexport auto stop_capture(Core::State::AppState& state) -> void;\n\n// 清理捕获资源\nexport auto cleanup_capture(Core::State::AppState& state) -> void;\n\n}  // namespace Features::Overlay::Capture\n"
  },
  {
    "path": "src/features/overlay/geometry.cpp",
    "content": "module;\n\nmodule Features.Overlay.Geometry;\n\nimport std;\nimport Core.State;\nimport Features.Overlay.State;\nimport Features.Overlay.Types;\nimport <dwmapi.h>;\nimport <windows.h>;\n\nnamespace Features::Overlay::Geometry {\n\nauto get_screen_dimensions() -> std::pair<int, int> {\n  return {GetSystemMetrics(SM_CXSCREEN), GetSystemMetrics(SM_CYSCREEN)};\n}\n\nauto calculate_overlay_dimensions(int game_width, int game_height, int screen_width,\n                                  int screen_height) -> std::pair<int, int> {\n  double aspect_ratio = static_cast<double>(game_width) / game_height;\n\n  int window_width, window_height;\n  if (game_width * screen_height <= screen_width * game_height) {\n    // 基于高度计算宽度\n    window_height = screen_height;\n    window_width = static_cast<int>(screen_height * aspect_ratio);\n  } else {\n    // 基于宽度计算高度\n    window_width = screen_width;\n    window_height = static_cast<int>(screen_width / aspect_ratio);\n  }\n\n  return {window_width, window_height};\n}\n\nauto should_use_overlay(int game_width, int game_height, int screen_width, int screen_height)\n    -> bool {\n  return game_width > screen_width || game_height > screen_height;\n}\n\nauto get_window_dimensions(HWND hwnd) -> std::expected<std::pair<int, int>, std::string> {\n  RECT rect;\n  if (!GetClientRect(hwnd, &rect)) {\n    DWORD error = GetLastError();\n    return std::unexpected(std::format(\"Failed to get client rect. Error: {}\", error));\n  }\n\n  return std::make_pair(rect.right - rect.left, rect.bottom - rect.top);\n}\n\nauto calculate_letterbox_area(int screen_width, int screen_height, int game_width, int game_height)\n    -> std::tuple<int, int, int, int> {\n  // 计算游戏内容的宽高比\n  float game_aspect = static_cast<float>(game_width) / game_height;\n\n  // 计算屏幕的宽高比\n  float screen_aspect = static_cast<float>(screen_width) / screen_height;\n\n  // 计算实际的游戏显示区域\n  int content_left = 0, content_top = 0, content_width = 0, content_height = 0;\n\n  if (game_aspect > screen_aspect) {\n    // 游戏比例更宽 - 上下黑边 (letterbox)\n    content_width = screen_width;\n    content_height = static_cast<int>(screen_width / game_aspect);\n    content_left = 0;\n    content_top = (screen_height - content_height) / 2;\n  } else if (game_aspect < screen_aspect) {\n    // 游戏比例更窄 - 左右黑边 (pillarbox)\n    content_width = static_cast<int>(screen_height * game_aspect);\n    content_height = screen_height;\n    content_left = (screen_width - content_width) / 2;\n    content_top = 0;\n  } else {\n    // 完美匹配，无黑边\n    content_width = screen_width;\n    content_height = screen_height;\n    content_left = 0;\n    content_top = 0;\n  }\n\n  return std::make_tuple(content_left, content_top, content_width, content_height);\n}\n\n}  // namespace Features::Overlay::Geometry\n"
  },
  {
    "path": "src/features/overlay/geometry.ixx",
    "content": "module;\n\nexport module Features.Overlay.Geometry;\n\nimport std;\nimport <windows.h>;\n\nnamespace Features::Overlay::Geometry {\n\n// 获取屏幕尺寸\nexport auto get_screen_dimensions() -> std::pair<int, int>;\n\n// 计算窗口尺寸\nexport auto calculate_overlay_dimensions(int game_width, int game_height, int screen_width,\n                                         int screen_height) -> std::pair<int, int>;\n\n// 检查游戏窗口是否需要叠加层\nexport auto should_use_overlay(int game_width, int game_height, int screen_width, int screen_height)\n    -> bool;\n\n// 获取游戏窗口尺寸\nexport auto get_window_dimensions(HWND hwnd) -> std::expected<std::pair<int, int>, std::string>;\n\n// 计算黑边区域的位置和尺寸\nexport auto calculate_letterbox_area(int screen_width, int screen_height, int game_width,\n                                     int game_height) -> std::tuple<int, int, int, int>;\n\n}  // namespace Features::Overlay::Geometry\n"
  },
  {
    "path": "src/features/overlay/interaction.cpp",
    "content": "module;\n\nmodule Features.Overlay.Interaction;\n\nimport std;\nimport Core.State;\nimport Features.Overlay;\nimport Features.Overlay.Capture;\nimport Features.Overlay.Rendering;\nimport Features.Overlay.State;\nimport Features.Overlay.Types;\nimport Features.Overlay.Geometry;\nimport Utils.Logger;\nimport <dwmapi.h>;\nimport <windows.h>;\n\nnamespace Features::Overlay::Interaction {\n\n// 全局状态指针，用于钩子回调\nCore::State::AppState* g_app_state = nullptr;\n\n// 窗口事件钩子过程。\n// 这里故意不直接操作 overlay 状态，而是把事件转成窗口消息。\n// 原因是 WinEventHook 回调运行在系统回调上下文里，逻辑越轻越安全；\n// 真正的状态切换统一交给 overlay 自己的消息处理函数完成。\nvoid CALLBACK win_event_proc(HWINEVENTHOOK hook, DWORD event, HWND hwnd, LONG idObject,\n                             LONG idChild, DWORD idEventThread, DWORD dwmsEventTime) {\n  if (!g_app_state) {\n    return;\n  }\n\n  auto& overlay_state = *g_app_state->overlay;\n\n  // 前台切换事件仍然沿用旧路径：先投递到 timer_window，\n  // 再由 window manager 线程更新焦点状态和窗口层级。\n  if (event == EVENT_SYSTEM_FOREGROUND) {\n    if (overlay_state.window.timer_window) {\n      PostMessage(overlay_state.window.timer_window, Types::WM_WINDOW_EVENT,\n                  static_cast<WPARAM>(event), reinterpret_cast<LPARAM>(hwnd));\n    }\n    return;\n  }\n\n  // 目标窗口被销毁时，不在 hook 回调里直接 stop_overlay()。\n  // 而是通知 overlay 窗口自己处理，避免在回调线程里做复杂停机。\n  if (event == EVENT_OBJECT_DESTROY && hwnd == overlay_state.window.target_window &&\n      idObject == OBJID_WINDOW && idChild == CHILDID_SELF && overlay_state.window.overlay_hwnd) {\n    PostMessage(overlay_state.window.overlay_hwnd, Types::WM_TARGET_WINDOW_DESTROYED, 0, 0);\n  }\n}\n\n// 统一维护 overlay 的“当前是否由游戏/overlay 持有焦点”状态。\n// 启动时和前台切换时都走这里，避免 direct-start 与 transform-start 行为不一致。\nauto update_focus_state(Core::State::AppState& state, HWND hwnd) -> void {\n  auto& overlay_state = *state.overlay;\n  bool is_game_or_overlay =\n      (hwnd == overlay_state.window.target_window || hwnd == overlay_state.window.overlay_hwnd);\n\n  overlay_state.interaction.is_game_focused = is_game_or_overlay;\n\n  if (is_game_or_overlay) {\n    if (hwnd == overlay_state.window.target_window && overlay_state.window.overlay_hwnd) {\n      PostMessage(overlay_state.window.overlay_hwnd, Types::WM_GAME_WINDOW_FOREGROUND, 0, 0);\n    }\n  } else {\n    restore_taskbar_redraw(state);\n  }\n}\n\nauto initialize_interaction(Core::State::AppState& state) -> std::expected<void, std::string> {\n  g_app_state = &state;\n\n  // 保存目标窗口所属进程 ID。\n  // 后面注册“目标窗口销毁”hook 时会把监听范围限制到这个进程，\n  // 避免收到无关进程的销毁噪声。\n  if (state.overlay->window.target_window) {\n    DWORD process_id;\n    GetWindowThreadProcessId(state.overlay->window.target_window, &process_id);\n    state.overlay->interaction.game_process_id = process_id;\n  }\n\n  // 钩子安装时前台窗口可能早已稳定，不会自动补发 EVENT_SYSTEM_FOREGROUND；\n  // 启动阶段需要主动读取一次当前前台窗口来初始化焦点状态。\n  refresh_focus_state(state);\n\n  return {};\n}\n\nauto install_window_event_hook(Core::State::AppState& state) -> std::expected<void, std::string> {\n  auto& overlay_state = *state.overlay;\n\n  // 这个 hook 监听全局前台切换。\n  // overlay 需要知道“现在前台还是不是游戏/overlay”，\n  // 这样才能决定是否压制任务栏重绘，以及是否调整窗口层级。\n  if (!overlay_state.interaction.foreground_event_hook) {\n    overlay_state.interaction.foreground_event_hook =\n        SetWinEventHook(EVENT_SYSTEM_FOREGROUND, EVENT_SYSTEM_FOREGROUND, nullptr, win_event_proc,\n                        0, 0, WINEVENT_OUTOFCONTEXT);\n\n    if (!overlay_state.interaction.foreground_event_hook) {\n      DWORD error = GetLastError();\n      auto error_msg = std::format(\"Failed to install foreground event hook. Error: {}\", error);\n      Logger().error(error_msg);\n      return std::unexpected(error_msg);\n    }\n  }\n\n  // 这个 hook 只监听目标进程里的“窗口对象销毁”。\n  // 我们真正关心的是目标游戏窗口消失，此时 overlay 应该跟着退出。\n  if (!overlay_state.interaction.target_window_event_hook) {\n    overlay_state.interaction.target_window_event_hook =\n        SetWinEventHook(EVENT_OBJECT_DESTROY, EVENT_OBJECT_DESTROY, nullptr, win_event_proc,\n                        overlay_state.interaction.game_process_id, 0,\n                        WINEVENT_OUTOFCONTEXT | WINEVENT_SKIPOWNPROCESS);\n\n    if (!overlay_state.interaction.target_window_event_hook) {\n      if (overlay_state.interaction.foreground_event_hook) {\n        UnhookWinEvent(overlay_state.interaction.foreground_event_hook);\n        overlay_state.interaction.foreground_event_hook = nullptr;\n      }\n\n      DWORD error = GetLastError();\n      auto error_msg = std::format(\"Failed to install target window event hook. Error: {}\", error);\n      Logger().error(error_msg);\n      return std::unexpected(error_msg);\n    }\n  }\n\n  Logger().info(\"Window event hooks installed successfully\");\n  return {};\n}\n\nauto uninstall_hooks(Core::State::AppState& state) -> void {\n  auto& overlay_state = *state.overlay;\n\n  if (overlay_state.interaction.foreground_event_hook) {\n    UnhookWinEvent(overlay_state.interaction.foreground_event_hook);\n    overlay_state.interaction.foreground_event_hook = nullptr;\n  }\n\n  if (overlay_state.interaction.target_window_event_hook) {\n    UnhookWinEvent(overlay_state.interaction.target_window_event_hook);\n    overlay_state.interaction.target_window_event_hook = nullptr;\n  }\n}\n\nauto suppress_taskbar_redraw(Core::State::AppState& state) -> void {\n  if (state.overlay->interaction.taskbar_redraw_suppressed) {\n    return;  // 已经禁止了，无需重复操作\n  }\n\n  HWND taskbar = FindWindow(L\"Shell_TrayWnd\", nullptr);\n  if (taskbar) {\n    SendMessage(taskbar, WM_SETREDRAW, FALSE, 0);\n    state.overlay->interaction.taskbar_redraw_suppressed = true;\n    Logger().debug(\"[Overlay] Taskbar redraw suppressed\");\n  }\n}\n\nauto restore_taskbar_redraw(Core::State::AppState& state) -> void {\n  HWND taskbar = FindWindow(L\"Shell_TrayWnd\", nullptr);\n  if (taskbar) {\n    SendMessage(taskbar, WM_SETREDRAW, TRUE, 0);\n    RedrawWindow(taskbar, nullptr, nullptr, RDW_INVALIDATE | RDW_UPDATENOW);\n    state.overlay->interaction.taskbar_redraw_suppressed = false;\n  }\n}\n\nauto update_game_window_position(Core::State::AppState& state) -> void {\n  auto& overlay_state = *state.overlay;\n\n  if (!overlay_state.window.target_window) return;\n\n  POINT current_pos{};\n  if (!GetCursorPos(&current_pos)) {\n    return;\n  }\n\n  // 计算叠加层窗口的位置\n  int overlay_left = (overlay_state.window.screen_width - overlay_state.window.window_width) / 2;\n  int overlay_top = (overlay_state.window.screen_height - overlay_state.window.window_height) / 2;\n\n  // 只有鼠标位于 overlay 显示区域内时，才根据鼠标位置“拖动”游戏窗口。\n  // 这样用户把鼠标移到别处时，不会继续改游戏窗口位置。\n  if (current_pos.x >= overlay_left &&\n      current_pos.x <= (overlay_left + overlay_state.window.window_width) &&\n      current_pos.y >= overlay_top &&\n      current_pos.y <= (overlay_top + overlay_state.window.window_height)) {\n    // 在黑边模式下，计算实际的游戏显示区域\n    if (overlay_state.window.use_letterbox_mode) {\n      // 使用工具函数计算黑边区域，与渲染部分保持一致\n      auto [content_left, content_top, content_width, content_height] =\n          Geometry::calculate_letterbox_area(\n              overlay_state.window.screen_width, overlay_state.window.screen_height,\n              overlay_state.window.cached_game_width, overlay_state.window.cached_game_height);\n\n      // 检查鼠标是否在游戏显示区域内\n      if (current_pos.x >= content_left && current_pos.x < (content_left + content_width) &&\n          current_pos.y >= content_top && current_pos.y < (content_top + content_height)) {\n        // 计算鼠标在游戏显示区域中的相对位置（0.0 到 1.0）\n        double relative_x = (current_pos.x - content_left) / static_cast<double>(content_width);\n        double relative_y = (current_pos.y - content_top) / static_cast<double>(content_height);\n\n        // 使用缓存的游戏窗口尺寸计算新位置\n        int new_game_x =\n            static_cast<int>(-relative_x * overlay_state.window.cached_game_width + current_pos.x);\n        int new_game_y =\n            static_cast<int>(-relative_y * overlay_state.window.cached_game_height + current_pos.y);\n\n        // 根据焦点状态禁用任务栏重绘\n        if (overlay_state.interaction.is_game_focused) {\n          suppress_taskbar_redraw(state);\n        }\n\n        POINT new_game_pos = {new_game_x, new_game_y};\n        if (auto& last_pos = overlay_state.interaction.last_game_window_pos;\n            last_pos && last_pos->x == new_game_pos.x && last_pos->y == new_game_pos.y) {\n          return;\n        }\n\n        if (SetWindowPos(\n                overlay_state.window.target_window, nullptr, new_game_x, new_game_y, 0, 0,\n                SWP_NOSIZE | SWP_NOZORDER | SWP_NOREDRAW | SWP_NOCOPYBITS | SWP_NOSENDCHANGING)) {\n          overlay_state.interaction.last_game_window_pos = new_game_pos;\n        }\n      }\n    } else {\n      // 非黑边模式：整个 overlay 都是有效显示区，直接按整个 overlay 计算相对位置。\n      double relative_x =\n          (current_pos.x - overlay_left) / static_cast<double>(overlay_state.window.window_width);\n      double relative_y =\n          (current_pos.y - overlay_top) / static_cast<double>(overlay_state.window.window_height);\n\n      // 使用缓存的游戏窗口尺寸计算新位置\n      int new_game_x =\n          static_cast<int>(-relative_x * overlay_state.window.cached_game_width + current_pos.x);\n      int new_game_y =\n          static_cast<int>(-relative_y * overlay_state.window.cached_game_height + current_pos.y);\n\n      // 根据焦点状态禁用任务栏重绘\n      if (overlay_state.interaction.is_game_focused) {\n        suppress_taskbar_redraw(state);\n      }\n\n      POINT new_game_pos = {new_game_x, new_game_y};\n      if (auto& last_pos = overlay_state.interaction.last_game_window_pos;\n          last_pos && last_pos->x == new_game_pos.x && last_pos->y == new_game_pos.y) {\n        return;\n      }\n\n      if (SetWindowPos(\n              overlay_state.window.target_window, nullptr, new_game_x, new_game_y, 0, 0,\n              SWP_NOSIZE | SWP_NOZORDER | SWP_NOREDRAW | SWP_NOCOPYBITS | SWP_NOSENDCHANGING)) {\n        overlay_state.interaction.last_game_window_pos = new_game_pos;\n      }\n    }\n  }\n}\n\nauto handle_window_event(Core::State::AppState& state, DWORD event, HWND hwnd) -> void {\n  if (event == EVENT_SYSTEM_FOREGROUND) {\n    update_focus_state(state, hwnd);\n  }\n}\n\nauto refresh_focus_state(Core::State::AppState& state) -> void {\n  // 使用系统当前前台窗口做一次同步，供 direct-start 或其它无前台切换的路径复用。\n  update_focus_state(state, GetForegroundWindow());\n}\n\nauto cleanup_interaction(Core::State::AppState& state) -> void {\n  uninstall_hooks(state);\n  state.overlay->interaction.last_game_window_pos.reset();\n  restore_taskbar_redraw(state);  // 确保任务栏重绘被恢复\n  g_app_state = nullptr;\n}\n\nauto handle_overlay_message(Core::State::AppState& state, HWND hwnd, UINT message, WPARAM wParam,\n                            LPARAM lParam) -> std::pair<bool, LRESULT> {\n  auto& overlay_state = *state.overlay;\n\n  // overlay 的“控制面板”。\n  // 外部线程和系统回调尽量只投递消息，真正修改 overlay 状态统一在这里做。\n  switch (message) {\n    case Types::WM_GAME_WINDOW_FOREGROUND: {\n      // 处理游戏窗口前台事件\n      if (overlay_state.window.target_window) {\n        SetWindowPos(hwnd, HWND_TOPMOST, 0, 0, 0, 0,\n                     SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_ASYNCWINDOWPOS);\n        SetWindowPos(hwnd, HWND_NOTOPMOST, 0, 0, 0, 0,\n                     SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_ASYNCWINDOWPOS);\n        SetWindowPos(overlay_state.window.target_window, hwnd, 0, 0, 0, 0,\n                     SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_NOREDRAW | SWP_NOCOPYBITS |\n                         SWP_ASYNCWINDOWPOS);\n      }\n      return {true, 1};\n    }\n\n    case Types::WM_SCHEDULE_OVERLAY_CLEANUP: {\n      KillTimer(hwnd, Types::OVERLAY_CLEANUP_TIMER_ID);\n      if (SetTimer(hwnd, Types::OVERLAY_CLEANUP_TIMER_ID, 3000, nullptr) == 0) {\n        Logger().error(\"Failed to schedule delayed overlay cleanup\");\n        return {true, 0};\n      }\n\n      return {true, 1};\n    }\n\n    case Types::WM_CANCEL_OVERLAY_CLEANUP: {\n      KillTimer(hwnd, Types::OVERLAY_CLEANUP_TIMER_ID);\n      return {true, 1};\n    }\n\n    case Types::WM_IMMEDIATE_OVERLAY_CLEANUP: {\n      KillTimer(hwnd, Types::OVERLAY_CLEANUP_TIMER_ID);\n      Features::Overlay::Capture::cleanup_capture(state);\n      Features::Overlay::Rendering::cleanup_rendering(state);\n      Logger().info(\"Overlay cleaned up\");\n      return {true, 1};\n    }\n\n    case Types::WM_TARGET_WINDOW_DESTROYED: {\n      Logger().info(\"Target window destroyed, stopping overlay\");\n\n      // 目标窗口已不存在，所以这里走“不恢复目标窗口”的 stop 变体。\n      Features::Overlay::stop_overlay(state, false);\n\n      return {true, 1};\n    }\n\n    case WM_SIZE: {\n      // 处理窗口大小变化\n      if (!overlay_state.rendering.d3d_initialized) {\n        return {true, 0};\n      }\n\n      // 更新窗口尺寸\n      overlay_state.window.window_width = LOWORD(lParam);\n      overlay_state.window.window_height = HIWORD(lParam);\n\n      // 调整渲染系统大小\n      if (auto result = Rendering::resize_rendering(state); !result) {\n        Logger().error(\"Failed to resize overlay rendering: {}\", result.error());\n      }\n\n      return {true, 0};\n    }\n\n    case WM_TIMER: {\n      if (wParam != Types::OVERLAY_CLEANUP_TIMER_ID) {\n        break;\n      }\n\n      KillTimer(hwnd, Types::OVERLAY_CLEANUP_TIMER_ID);\n      Features::Overlay::Capture::cleanup_capture(state);\n      Features::Overlay::Rendering::cleanup_rendering(state);\n      Logger().info(\"Overlay cleaned up\");\n      return {true, 1};\n    }\n\n    case WM_DESTROY:\n      KillTimer(hwnd, Types::OVERLAY_CLEANUP_TIMER_ID);\n      return {true, 0};\n  }\n\n  return {false, 0};\n}\n\n}  // namespace Features::Overlay::Interaction\n"
  },
  {
    "path": "src/features/overlay/interaction.ixx",
    "content": "module;\r\n\r\nexport module Features.Overlay.Interaction;\r\n\r\nimport std;\r\nimport Core.State;\r\nimport <windows.h>;\r\n\r\nnamespace Features::Overlay::Interaction {\r\n\r\n// 初始化交互系统（钩子等）\r\nexport auto initialize_interaction(Core::State::AppState& state)\r\n    -> std::expected<void, std::string>;\r\n\r\n// 处理叠加层窗口消息\r\nexport auto handle_overlay_message(Core::State::AppState& state, HWND hwnd, UINT message,\r\n                                   WPARAM wParam, LPARAM lParam) -> std::pair<bool, LRESULT>;\r\n\r\n// 安装窗口事件钩子\r\nexport auto install_window_event_hook(Core::State::AppState& state)\r\n    -> std::expected<void, std::string>;\r\n\r\n// 卸载所有钩子\r\nexport auto uninstall_hooks(Core::State::AppState& state) -> void;\r\n\r\n// 更新游戏窗口位置\r\nexport auto update_game_window_position(Core::State::AppState& state) -> void;\r\n\r\n// 处理窗口事件\r\nexport auto handle_window_event(Core::State::AppState& state, DWORD event, HWND hwnd) -> void;\r\n\r\n// 同步当前前台窗口对应的焦点状态\r\nexport auto refresh_focus_state(Core::State::AppState& state) -> void;\r\n\r\n// 清理交互资源\r\nexport auto cleanup_interaction(Core::State::AppState& state) -> void;\r\n\r\n// 禁止任务栏重绘\r\nexport auto suppress_taskbar_redraw(Core::State::AppState& state) -> void;\r\n\r\n// 恢复任务栏重绘\r\nexport auto restore_taskbar_redraw(Core::State::AppState& state) -> void;\r\n\r\n}  // namespace Features::Overlay::Interaction\r\n"
  },
  {
    "path": "src/features/overlay/overlay.cpp",
    "content": "module;\n\nmodule Features.Overlay;\n\nimport std;\nimport Core.State;\nimport Core.State.RuntimeInfo;\nimport Features.Overlay.State;\nimport Features.Overlay.Types;\nimport Features.Overlay.Window;\nimport Features.Overlay.Rendering;\nimport Features.Overlay.Capture;\nimport Features.Overlay.Interaction;\nimport Features.Overlay.Threads;\nimport Features.Overlay.Geometry;\nimport Utils.Logger;\nimport <dwmapi.h>;\nimport <windows.h>;\n\nnamespace Features::Overlay {\nauto send_overlay_control_message(HWND overlay_hwnd, UINT message) -> bool {\n  if (!overlay_hwnd || !IsWindow(overlay_hwnd)) {\n    return false;\n  }\n\n  return SendMessageW(overlay_hwnd, message, 0, 0) != 0;\n}\n\nauto cleanup_overlay(Core::State::AppState& state) -> void {\n  if (send_overlay_control_message(state.overlay->window.overlay_hwnd,\n                                   Types::WM_IMMEDIATE_OVERLAY_CLEANUP)) {\n    return;\n  }\n\n  Capture::cleanup_capture(state);\n  Rendering::cleanup_rendering(state);\n\n  Logger().info(\"Overlay cleaned up\");\n}\n\nauto schedule_overlay_cleanup(Core::State::AppState& state) -> void {\n  if (!send_overlay_control_message(state.overlay->window.overlay_hwnd,\n                                    Types::WM_SCHEDULE_OVERLAY_CLEANUP)) {\n    cleanup_overlay(state);\n  }\n}\n\nauto stop_overlay_runtime(Core::State::AppState& state, bool restore_target_window) -> void {\n  auto& overlay_state = *state.overlay;\n\n  overlay_state.running.store(false, std::memory_order_release);\n  overlay_state.freeze_rendering.store(false, std::memory_order_release);\n  overlay_state.freeze_after_first_frame.store(false, std::memory_order_release);\n  overlay_state.rendering.create_new_srv = true;\n\n  Threads::stop_threads(state);\n  Capture::stop_capture(state);\n\n  if (restore_target_window) {\n    Window::restore_game_window(state);\n  }\n\n  Window::hide_overlay_window(state);\n  overlay_state.window.target_window = nullptr;\n\n  Threads::wait_for_threads(state);\n  Interaction::cleanup_interaction(state);\n}\n\nauto start_overlay(Core::State::AppState& state, HWND target_window, bool freeze_after_first_frame)\n    -> std::expected<void, std::string> {\n  auto& overlay_state = *state.overlay;\n\n  if (state.overlay->running.load(std::memory_order_acquire)) {\n    Logger().debug(\"Overlay already running, skipping\");\n    return {};\n  }\n\n  // 检查是否支持捕捉\n  if (!state.runtime_info->is_capture_supported) {\n    return std::unexpected(\"Capture not supported on this system\");\n  }\n\n  // 检查窗口是否已初始化，如果未初始化则进行初始化\n  if (!overlay_state.window.overlay_hwnd) {\n    HINSTANCE instance = GetModuleHandle(nullptr);\n\n    if (auto result = Window::initialize_overlay_window(state, instance); !result) {\n      return std::unexpected(result.error());\n    }\n  }\n\n  if (!target_window || !IsWindow(target_window)) {\n    return std::unexpected(\"Invalid target window\");\n  }\n\n  // 检查窗口是否最小化\n  if (IsIconic(target_window)) {\n    return std::unexpected(\"Target window is minimized\");\n  }\n\n  // 获取窗口尺寸\n  auto dimensions_result = Geometry::get_window_dimensions(target_window);\n  if (!dimensions_result) {\n    return std::unexpected(dimensions_result.error());\n  }\n\n  auto [width, height] = dimensions_result.value();\n  auto [screen_width, screen_height] = Geometry::get_screen_dimensions();\n\n  // 在非变换场景下，检查是否需要 overlay\n  if (!freeze_after_first_frame &&\n      !Geometry::should_use_overlay(width, height, screen_width, screen_height)) {\n    Window::restore_game_window(state);\n    // 不返回错误，因为游戏窗口在屏幕内，不需要叠加层\n    return {};\n  }\n\n  // 设置首帧后自动冻结标志\n  overlay_state.freeze_after_first_frame.store(freeze_after_first_frame, std::memory_order_release);\n\n  if (!send_overlay_control_message(overlay_state.window.overlay_hwnd,\n                                    Types::WM_CANCEL_OVERLAY_CLEANUP)) {\n    Logger().warn(\"Failed to cancel pending overlay cleanup\");\n  }\n\n  overlay_state.interaction.last_game_window_pos.reset();\n\n  // 设置目标窗口\n  overlay_state.window.target_window = target_window;\n\n  // 更新窗口尺寸\n  Window::set_overlay_window_size(state, width, height);\n\n  // 初始化渲染系统（仅在未初始化时）\n  if (!overlay_state.rendering.d3d_initialized) {\n    if (auto result = Rendering::initialize_rendering(state); !result) {\n      return std::unexpected(result.error());\n    }\n  }\n\n  overlay_state.running.store(true, std::memory_order_release);  // 设置运行状态为true\n\n  // 初始化捕获\n  if (auto result = Capture::initialize_capture(state, target_window, width, height); !result) {\n    overlay_state.running.store(false, std::memory_order_release);\n    return std::unexpected(result.error());\n  }\n\n  // 启动捕获\n  if (auto result = Capture::start_capture(state); !result) {\n    overlay_state.running.store(false, std::memory_order_release);\n    return std::unexpected(result.error());\n  }\n\n  // 启动线程（只启动钩子和窗口管理线程）\n  if (auto result = Threads::start_threads(state); !result) {\n    overlay_state.running.store(\n        false, std::memory_order_release);  // 如果线程启动失败，设置运行状态为false\n    return std::unexpected(result.error());\n  }\n\n  return {};\n}\n\nauto stop_overlay(Core::State::AppState& state, bool restore_target_window) -> void {\n  Logger().debug(\"Stopping overlay\");\n\n  stop_overlay_runtime(state, restore_target_window);\n  schedule_overlay_cleanup(state);\n\n  Logger().debug(\"Overlay stopped\");\n}\n\nauto freeze_overlay(Core::State::AppState& state) -> void {\n  state.overlay->freeze_rendering.store(true, std::memory_order_release);\n  Logger().debug(\"Overlay frozen\");\n}\n\nauto unfreeze_overlay(Core::State::AppState& state) -> void {\n  state.overlay->freeze_rendering.store(false, std::memory_order_release);\n  state.overlay->freeze_after_first_frame.store(false, std::memory_order_release);\n  Logger().debug(\"Overlay unfrozen\");\n}\n\nauto set_letterbox_mode(Core::State::AppState& state, bool enabled) -> void {\n  state.overlay->window.use_letterbox_mode = enabled;\n}\n\n}  // namespace Features::Overlay\n"
  },
  {
    "path": "src/features/overlay/overlay.ixx",
    "content": "module;\n\nexport module Features.Overlay;\n\nimport std;\nimport Core.State;\nimport <windows.h>;\n\nnamespace Features::Overlay {\n\n// 开始叠加层捕捉\n// freeze_after_first_frame: 首帧渲染后自动冻结（用于窗口变换场景）\nexport auto start_overlay(Core::State::AppState& state, HWND target_window,\n                          bool freeze_after_first_frame = false)\n    -> std::expected<void, std::string>;\n\n// 停止叠加层。\n// restore_target_window 为 true 时，按“正常关闭”处理；\n// 为 false 时，按“目标窗口已失效”处理，不再尝试恢复目标窗口。\nexport auto stop_overlay(Core::State::AppState& state, bool restore_target_window = true) -> void;\n\n// 冻结叠加层（保持当前帧，停止处理新帧）\nexport auto freeze_overlay(Core::State::AppState& state) -> void;\n\n// 解冻叠加层（恢复处理新帧）\nexport auto unfreeze_overlay(Core::State::AppState& state) -> void;\n\n// 设置黑边模式\nexport auto set_letterbox_mode(Core::State::AppState& state, bool enabled) -> void;\n\n// 清理资源\nexport auto cleanup_overlay(Core::State::AppState& state) -> void;\n\n}  // namespace Features::Overlay\n"
  },
  {
    "path": "src/features/overlay/rendering.cpp",
    "content": "module;\n\n#include <d3d11.h>\n#include <wil/com.h>\n#include <windows.h>\n\n#include <iostream>\n\nmodule Features.Overlay.Rendering;\n\nimport std;\nimport Core.State;\nimport Features.Overlay.State;\nimport Features.Overlay.Types;\nimport Features.Overlay.Geometry;\nimport Features.Overlay.Shaders;\nimport Utils.Graphics.D3D;\nimport Utils.Logger;\n\nnamespace Features::Overlay::Rendering {\n\nauto create_shader_resources(Core::State::AppState& state) -> std::expected<void, std::string> {\n  auto& overlay_state = *state.overlay;\n\n  auto result = Utils::Graphics::D3D::create_basic_shader_resources(\n      overlay_state.rendering.d3d_context.device.get(),\n      Features::Overlay::Shaders::BASIC_VERTEX_SHADER,\n      Features::Overlay::Shaders::BASIC_PIXEL_SHADER);\n\n  if (!result) {\n    return std::unexpected(result.error());\n  }\n\n  overlay_state.rendering.shader_resources = std::move(result.value());\n\n  // 创建顶点缓冲区，初始使用全屏顶点\n  Types::Vertex vertices[] = {\n      {-1.0f, -1.0f, 0.0f, 1.0f},  // 左下\n      {-1.0f, 1.0f, 0.0f, 0.0f},   // 左上\n      {1.0f, -1.0f, 1.0f, 1.0f},   // 右下\n      {1.0f, 1.0f, 1.0f, 0.0f}     // 右上\n  };\n\n  auto vertex_buffer_result = Utils::Graphics::D3D::create_vertex_buffer(\n      overlay_state.rendering.d3d_context.device.get(), vertices, 4, sizeof(Types::Vertex));\n\n  if (!vertex_buffer_result) {\n    return std::unexpected(vertex_buffer_result.error());\n  }\n\n  overlay_state.rendering.shader_resources.vertex_buffer = vertex_buffer_result.value();\n\n  return {};\n}\n\nauto initialize_rendering(Core::State::AppState& state) -> std::expected<void, std::string> {\n  auto& overlay_state = *state.overlay;\n\n  // 创建D3D上下文\n  auto d3d_result = Utils::Graphics::D3D::create_d3d_context(overlay_state.window.overlay_hwnd,\n                                                             overlay_state.window.window_width,\n                                                             overlay_state.window.window_height);\n\n  if (!d3d_result) {\n    auto error_msg = std::format(\"Failed to initialize D3D rendering: {}\", d3d_result.error());\n    Logger().error(error_msg);\n    return std::unexpected(d3d_result.error());\n  }\n\n  overlay_state.rendering.d3d_context = std::move(d3d_result.value());\n  Logger().info(\"D3D rendering initialized successfully\");\n\n  // 创建着色器资源\n  if (auto result = create_shader_resources(state); !result) {\n    auto error_msg = std::format(\"Failed to create shader resources: {}\", result.error());\n    Logger().error(error_msg);\n\n    // 清理已分配的D3D资源\n    Utils::Graphics::D3D::cleanup_d3d_context(overlay_state.rendering.d3d_context);\n    overlay_state.rendering.d3d_initialized = false;\n\n    return std::unexpected(result.error());\n  }\n\n  overlay_state.rendering.d3d_initialized = true;\n  Logger().info(\"Render states initialized successfully\");\n\n  return {};\n}\n\nauto resize_rendering(Core::State::AppState& state) -> std::expected<void, std::string> {\n  if (!state.overlay->rendering.d3d_initialized) {\n    return std::unexpected(\"D3D not initialized\");\n  }\n\n  auto& overlay_state = *state.overlay;\n  auto& rendering_state = overlay_state.rendering;\n\n  rendering_state.resources_busy.store(true, std::memory_order_release);\n\n  // 调整交换链大小\n  auto result = Utils::Graphics::D3D::resize_swap_chain(rendering_state.d3d_context,\n                                                        overlay_state.window.window_width,\n                                                        overlay_state.window.window_height);\n\n  rendering_state.resources_busy.store(false, std::memory_order_release);\n\n  if (!result) {\n    Logger().error(\"Failed to resize swap chain for overlay: {}\", result.error());\n    return std::unexpected(\"Failed to resize swap chain\");\n  }\n\n  Logger().debug(\"Overlay rendering resized to {}x{}\", overlay_state.window.window_width,\n                 overlay_state.window.window_height);\n\n  return {};\n}\n\nauto update_capture_srv(Core::State::AppState& state, wil::com_ptr<ID3D11Texture2D> texture)\n    -> std::expected<void, std::string> {\n  auto& overlay_state = *state.overlay;\n\n  if (!overlay_state.rendering.d3d_initialized || !texture) {\n    return std::unexpected(\"Invalid rendering resources or texture\");\n  }\n\n  // 获取纹理描述\n  D3D11_TEXTURE2D_DESC desc;\n  texture->GetDesc(&desc);\n\n  // 创建着色器资源视图描述\n  D3D11_SHADER_RESOURCE_VIEW_DESC srvDesc = {};\n  srvDesc.Format = desc.Format;\n  srvDesc.ViewDimension = D3D11_SRV_DIMENSION_TEXTURE2D;\n  srvDesc.Texture2D.MostDetailedMip = 0;\n  srvDesc.Texture2D.MipLevels = 1;\n\n  // 创建新的SRV\n  HRESULT hr = overlay_state.rendering.d3d_context.device->CreateShaderResourceView(\n      texture.get(), &srvDesc, overlay_state.rendering.capture_srv.put());\n  if (FAILED(hr)) {\n    auto error_msg = std::format(\"Failed to create shader resource view, HRESULT: 0x{:08X}\",\n                                 static_cast<unsigned int>(hr));\n    Logger().error(error_msg);\n    return std::unexpected(error_msg);\n  }\n\n  return {};\n}\n\nauto update_vertex_buffer_for_letterbox(Core::State::AppState& state) -> void {\n  auto& overlay_state = *state.overlay;\n  auto& d3d_context = overlay_state.rendering.d3d_context;\n  auto& shader_resources = overlay_state.rendering.shader_resources;\n\n  // 计算顶点坐标\n  float left = -1.0f, right = 1.0f, top = 1.0f, bottom = -1.0f;\n\n  if (overlay_state.window.use_letterbox_mode) {\n    // 使用工具函数计算黑边区域\n    auto [content_left, content_top, content_width, content_height] =\n        Geometry::calculate_letterbox_area(\n            overlay_state.window.screen_width, overlay_state.window.screen_height,\n            overlay_state.window.cached_game_width, overlay_state.window.cached_game_height);\n\n    // 将像素坐标转换为标准化设备坐标(-1到1)\n    // 注意：Direct3D的Y轴是向上的，而窗口坐标Y轴是向下的\n    left = (content_left * 2.0f / overlay_state.window.screen_width) - 1.0f;\n    right = ((content_left + content_width) * 2.0f / overlay_state.window.screen_width) - 1.0f;\n    top = 1.0f - (content_top * 2.0f / overlay_state.window.screen_height);\n    bottom = 1.0f - ((content_top + content_height) * 2.0f / overlay_state.window.screen_height);\n  }\n\n  // 更新顶点数据\n  Types::Vertex vertices[] = {\n      {left, bottom, 0.0f, 1.0f},   // 左下\n      {left, top, 0.0f, 0.0f},      // 左上\n      {right, bottom, 1.0f, 1.0f},  // 右下\n      {right, top, 1.0f, 0.0f}      // 右上\n  };\n\n  // 更新顶点缓冲区\n  d3d_context.context->UpdateSubresource(shader_resources.vertex_buffer.get(), 0, nullptr, vertices,\n                                         0, 0);\n}\n\nauto render_frame(Core::State::AppState& state, wil::com_ptr<ID3D11Texture2D> frame_texture)\n    -> void {\n  auto& overlay_state = *state.overlay;\n  auto& d3d_context = overlay_state.rendering.d3d_context;\n  auto& shader_resources = overlay_state.rendering.shader_resources;\n  auto& rendering_state = overlay_state.rendering;\n\n  if (!rendering_state.d3d_initialized) {\n    return;\n  }\n\n  // 检查渲染资源是否正忙，如果是则跳过渲染\n  if (rendering_state.resources_busy.load(std::memory_order_acquire)) {\n    return;\n  }\n\n  // 等待帧延迟对象\n  if (rendering_state.frame_latency_object) {\n    DWORD result = WaitForSingleObjectEx(rendering_state.frame_latency_object, 1000, TRUE);\n    if (result != WAIT_OBJECT_0) {\n      // 超时或失败，继续渲染\n    }\n  }\n\n  // 更新纹理资源\n  if (rendering_state.create_new_srv) {\n    if (auto result = update_capture_srv(state, frame_texture); result) {\n      rendering_state.create_new_srv = false;\n    }\n  }\n\n  // 清理渲染目标为黑色（作为黑边的背景色）\n  float clear_color[4] = {0.0f, 0.0f, 0.0f, 0.0f};\n  d3d_context.context->ClearRenderTargetView(d3d_context.render_target.get(), clear_color);\n  ID3D11RenderTargetView* render_target_view = d3d_context.render_target.get();\n  d3d_context.context->OMSetRenderTargets(1, &render_target_view, nullptr);\n\n  // 根据letterbox模式更新顶点缓冲区\n  update_vertex_buffer_for_letterbox(state);\n\n  // 设置视口和渲染参数\n  D3D11_VIEWPORT viewport = {};\n  viewport.Width = static_cast<float>(overlay_state.window.window_width);\n  viewport.Height = static_cast<float>(overlay_state.window.window_height);\n  viewport.MinDepth = 0.0f;\n  viewport.MaxDepth = 1.0f;\n  d3d_context.context->RSSetViewports(1, &viewport);\n\n  // 设置着色器和资源\n  d3d_context.context->VSSetShader(shader_resources.vertex_shader.get(), nullptr, 0);\n  d3d_context.context->PSSetShader(shader_resources.pixel_shader.get(), nullptr, 0);\n  d3d_context.context->IASetInputLayout(shader_resources.input_layout.get());\n\n  // 设置顶点缓冲区\n  UINT stride = sizeof(Types::Vertex);\n  UINT offset = 0;\n  ID3D11Buffer* vertex_buffer = shader_resources.vertex_buffer.get();\n  d3d_context.context->IASetVertexBuffers(0, 1, &vertex_buffer, &stride, &offset);\n  d3d_context.context->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP);\n\n  // 设置纹理和采样器\n  if (rendering_state.capture_srv) {\n    ID3D11ShaderResourceView* srv = rendering_state.capture_srv.get();\n    d3d_context.context->PSSetShaderResources(0, 1, &srv);\n  }\n  if (shader_resources.sampler) {\n    ID3D11SamplerState* sampler = shader_resources.sampler.get();\n    d3d_context.context->PSSetSamplers(0, 1, &sampler);\n  }\n\n  // 设置混合状态\n  if (shader_resources.blend_state) {\n    float blend_factor[4] = {0.0f, 0.0f, 0.0f, 0.0f};\n    d3d_context.context->OMSetBlendState(shader_resources.blend_state.get(), blend_factor,\n                                         0xffffffff);\n  }\n\n  // 绘制\n  d3d_context.context->Draw(4, 0);\n\n  // 呈现\n  if (auto swap_chain = d3d_context.swap_chain.get()) {\n    swap_chain->Present(0, 0);\n  }\n}\n\nauto cleanup_rendering(Core::State::AppState& state) -> void {\n  auto& overlay_state = *state.overlay;\n\n  overlay_state.rendering.d3d_initialized = false;\n  overlay_state.rendering.resources_busy.store(true, std::memory_order_release);\n\n  Utils::Graphics::D3D::cleanup_shader_resources(overlay_state.rendering.shader_resources);\n  Utils::Graphics::D3D::cleanup_d3d_context(overlay_state.rendering.d3d_context);\n\n  overlay_state.rendering.frame_texture.reset();\n  overlay_state.rendering.capture_srv.reset();\n\n  if (overlay_state.rendering.frame_latency_object) {\n    CloseHandle(overlay_state.rendering.frame_latency_object);\n    overlay_state.rendering.frame_latency_object = nullptr;\n  }\n\n  overlay_state.rendering.resources_busy.store(false, std::memory_order_release);\n}\n\n}  // namespace Features::Overlay::Rendering\n"
  },
  {
    "path": "src/features/overlay/rendering.ixx",
    "content": "module;\n\nexport module Features.Overlay.Rendering;\n\nimport std;\nimport Core.State;\nimport Features.Overlay.State;\nimport <d3d11.h>;\nimport <wil/com.h>;\n\nnamespace Features::Overlay::Rendering {\n\n// 初始化渲染系统\nexport auto initialize_rendering(Core::State::AppState& state) -> std::expected<void, std::string>;\n\n// 调整交换链大小\nexport auto resize_rendering(Core::State::AppState& state) -> std::expected<void, std::string>;\n\n// 渲染帧\nexport auto render_frame(Core::State::AppState& state, wil::com_ptr<ID3D11Texture2D> frame_texture)\n    -> void;\n\n// 清理渲染资源\nexport auto cleanup_rendering(Core::State::AppState& state) -> void;\n\n}  // namespace Features::Overlay::Rendering\n"
  },
  {
    "path": "src/features/overlay/shaders.ixx",
    "content": "module;\n\nexport module Features.Overlay.Shaders;\n\nimport std;\n\nexport namespace Features::Overlay::Shaders {\n\n// 基本渲染着色器（用于显示捕获的游戏画面）\nconst std::string BASIC_VERTEX_SHADER = R\"(\nstruct VS_INPUT {\n    float2 pos : POSITION;\n    float2 tex : TEXCOORD;\n};\nstruct PS_INPUT {\n    float4 pos : SV_POSITION;\n    float2 tex : TEXCOORD;\n};\nPS_INPUT main(VS_INPUT input) {\n    PS_INPUT output;\n    output.pos = float4(input.pos, 0.0f, 1.0f);\n    output.tex = input.tex;\n    return output;\n}\n)\";\n\nconst std::string BASIC_PIXEL_SHADER = R\"(\nTexture2D tex : register(t0);\nSamplerState samp : register(s0);\nfloat4 main(float4 pos : SV_POSITION, float2 texCoord : TEXCOORD) : SV_Target {\n    return tex.Sample(samp, texCoord);\n}\n)\";\n\n}  // namespace Features::Overlay::Shaders"
  },
  {
    "path": "src/features/overlay/state.ixx",
    "content": "module;\n\nexport module Features.Overlay.State;\n\nimport std;\nimport Features.Overlay.Types;\n\nexport namespace Features::Overlay::State {\n\n// 叠加层完整状态\nstruct OverlayState {\n  Types::WindowState window;\n  Types::RenderingState rendering;\n  Types::CaptureState capture_state;\n  Types::InteractionState interaction;\n  Types::ThreadState threads;\n\n  std::condition_variable frame_available;\n\n  // 状态标志\n  bool enabled = false;                                // 用户是否启用叠加层模式\n  std::atomic<bool> running = false;                   // 叠加层是否实际在运行\n  std::atomic<bool> is_transforming = false;           // 窗口变换流程进行中\n  std::atomic<bool> freeze_rendering = false;          // 冻结渲染（保持最后一帧）\n  std::atomic<bool> freeze_after_first_frame = false;  // 首帧渲染后自动冻结\n};\n\n}  // namespace Features::Overlay::State\n"
  },
  {
    "path": "src/features/overlay/threads.cpp",
    "content": "module;\n\nmodule Features.Overlay.Threads;\n\nimport std;\nimport Core.State;\nimport Features.Overlay.State;\nimport Features.Overlay.Types;\nimport Features.Overlay.Interaction;\nimport Features.Overlay.Window;\nimport Utils.Logger;\nimport <windows.h>;\n\nnamespace Features::Overlay::Threads {\n\nauto start_threads(Core::State::AppState& state) -> std::expected<void, std::string> {\n  auto& overlay_state = *state.overlay;\n  try {\n    // 启动钩子线程\n    overlay_state.threads.hook_thread = std::jthread([&state](std::stop_token token) {\n      state.overlay->threads.hook_thread_id = GetCurrentThreadId();\n      hook_thread_proc(state, token);\n    });\n    // 启动窗口管理线程\n    overlay_state.threads.window_manager_thread = std::jthread([&state](std::stop_token token) {\n      state.overlay->threads.window_manager_thread_id = GetCurrentThreadId();\n      window_manager_thread_proc(state, token);\n    });\n    return {};\n  } catch (const std::exception& e) {\n    return std::unexpected(std::format(\"Failed to start threads: {}\", e.what()));\n  }\n}\n\nauto stop_threads(Core::State::AppState& state) -> void {\n  auto& overlay_state = *state.overlay;\n  // 请求停止线程并发送 WM_QUIT 消息\n  if (overlay_state.threads.hook_thread.joinable() && overlay_state.threads.hook_thread_id != 0) {\n    overlay_state.threads.hook_thread.request_stop();\n    PostThreadMessage(overlay_state.threads.hook_thread_id, WM_QUIT, 0, 0);\n  }\n  if (overlay_state.threads.window_manager_thread.joinable() &&\n      overlay_state.threads.window_manager_thread_id != 0) {\n    overlay_state.threads.window_manager_thread.request_stop();\n    PostThreadMessage(overlay_state.threads.window_manager_thread_id, WM_QUIT, 0, 0);\n  }\n}\n\nauto wait_for_threads(Core::State::AppState& state) -> void {\n  auto& overlay_state = *state.overlay;\n  if (overlay_state.threads.hook_thread.joinable()) {\n    Logger().debug(\"Waiting for hook thread to join\");\n    overlay_state.threads.hook_thread.join();\n  }\n  if (overlay_state.threads.window_manager_thread.joinable()) {\n    Logger().debug(\"Waiting for window manager thread to join\");\n    overlay_state.threads.window_manager_thread.join();\n  }\n}\n\nauto hook_thread_proc(Core::State::AppState& state, std::stop_token token) -> void {\n  // 初始化交互系统\n  if (auto result = Interaction::initialize_interaction(state); !result) {\n    return;\n  }\n\n  if (auto result = Interaction::install_window_event_hook(state); !result) {\n    Interaction::uninstall_hooks(state);\n    return;\n  }\n\n  // 消息循环\n  MSG msg;\n  while (!token.stop_requested()) {\n    DWORD result = GetMessage(&msg, nullptr, 0, 0);\n    if (result == -1 || result == 0) {\n      break;\n    }\n\n    TranslateMessage(&msg);\n    DispatchMessage(&msg);\n  }\n\n  // 清理钩子\n  Interaction::uninstall_hooks(state);\n}\n\nauto window_manager_thread_proc(Core::State::AppState& state, std::stop_token token) -> void {\n  auto& overlay_state = *state.overlay;\n\n  // 创建定时器窗口\n  WNDCLASSEXW wc = {};\n  wc.cbSize = sizeof(WNDCLASSEXW);\n  wc.lpfnWndProc = DefWindowProc;\n  wc.hInstance = GetModuleHandle(nullptr);\n  wc.lpszClassName = L\"WindowManagerClass\";\n\n  if (!RegisterClassExW(&wc)) {\n    return;\n  }\n\n  HWND timer_window = CreateWindowExW(0, L\"WindowManagerClass\", L\"Timer Window\", 0, 0, 0, 0, 0,\n                                      HWND_MESSAGE, nullptr, GetModuleHandle(nullptr), nullptr);\n\n  if (!timer_window) {\n    UnregisterClassW(L\"WindowManagerClass\", GetModuleHandle(nullptr));\n    return;\n  }\n\n  overlay_state.window.timer_window = timer_window;\n\n  // 设置定时器\n  SetTimer(timer_window, 1, 16, nullptr);  // ~60 FPS\n\n  // 消息循环\n  MSG msg;\n  while (!token.stop_requested()) {\n    BOOL result = GetMessage(&msg, nullptr, 0, 0);\n    if (result == -1 || result == 0) {\n      break;\n    }\n\n    switch (msg.message) {\n      case WM_TIMER:\n        // 更新游戏窗口位置\n        Interaction::update_game_window_position(state);\n        break;\n\n      case Types::WM_GAME_WINDOW_FOREGROUND:\n        // 确保叠加层窗口在游戏窗口上方\n        if (overlay_state.window.overlay_hwnd && overlay_state.window.target_window) {\n          SetWindowPos(overlay_state.window.overlay_hwnd, HWND_TOPMOST, 0, 0, 0, 0,\n                       SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_ASYNCWINDOWPOS);\n          SetWindowPos(overlay_state.window.overlay_hwnd, HWND_NOTOPMOST, 0, 0, 0, 0,\n                       SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_ASYNCWINDOWPOS);\n          SetWindowPos(overlay_state.window.target_window, overlay_state.window.overlay_hwnd, 0, 0,\n                       0, 0,\n                       SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_NOREDRAW | SWP_NOCOPYBITS |\n                           SWP_ASYNCWINDOWPOS);\n        }\n        break;\n\n      case Types::WM_WINDOW_EVENT: {\n        // 处理窗口事件\n        DWORD event = static_cast<DWORD>(msg.wParam);\n        HWND hwnd = reinterpret_cast<HWND>(msg.lParam);\n        Interaction::handle_window_event(state, event, hwnd);\n        break;\n      }\n    }\n\n    TranslateMessage(&msg);\n    DispatchMessage(&msg);\n  }\n\n  // 清理资源\n  KillTimer(timer_window, 1);\n  DestroyWindow(timer_window);\n  UnregisterClassW(L\"WindowManagerClass\", GetModuleHandle(nullptr));\n  overlay_state.window.timer_window = nullptr;\n}\n\n}  // namespace Features::Overlay::Threads\n"
  },
  {
    "path": "src/features/overlay/threads.ixx",
    "content": "module;\r\n\r\nexport module Features.Overlay.Threads;\r\n\r\nimport std;\r\nimport Core.State;\r\n\r\nnamespace Features::Overlay::Threads {\r\n\r\n// 启动所有工作线程\r\nexport auto start_threads(Core::State::AppState& state) -> std::expected<void, std::string>;\r\n\r\n// 停止所有线程\r\nexport auto stop_threads(Core::State::AppState& state) -> void;\r\n\r\n// 等待所有线程结束\r\nexport auto wait_for_threads(Core::State::AppState& state) -> void;\r\n\r\n// 钩子线程处理函数\r\nexport auto hook_thread_proc(Core::State::AppState& state, std::stop_token token) -> void;\r\n\r\n// 窗口管理线程处理函数\r\nexport auto window_manager_thread_proc(Core::State::AppState& state, std::stop_token token) -> void;\r\n\r\n}  // namespace Features::Overlay::Threads\r\n"
  },
  {
    "path": "src/features/overlay/types.ixx",
    "content": "module;\n\nexport module Features.Overlay.Types;\n\nimport std;\nimport Utils.Graphics.Capture;\nimport Utils.Graphics.D3D;\nimport <d3d11.h>;\nimport <wil/com.h>;\nimport <windows.h>;\n\nexport namespace Features::Overlay::Types {\n\n// 消息常量\nconstexpr UINT WM_GAME_WINDOW_FOREGROUND = WM_USER + 1;\nconstexpr UINT WM_WINDOW_EVENT = WM_USER + 2;\nconstexpr UINT WM_SCHEDULE_OVERLAY_CLEANUP = WM_USER + 3;\nconstexpr UINT WM_CANCEL_OVERLAY_CLEANUP = WM_USER + 4;\nconstexpr UINT WM_IMMEDIATE_OVERLAY_CLEANUP = WM_USER + 5;\nconstexpr UINT WM_TARGET_WINDOW_DESTROYED = WM_USER + 6;\n\nconstexpr UINT_PTR OVERLAY_CLEANUP_TIMER_ID = 1;\n\n// 顶点结构体\nstruct Vertex {\n  float x, y;\n  float u, v;\n};\n\n// 窗口状态\nstruct WindowState {\n  HWND overlay_hwnd = nullptr;\n  HWND target_window = nullptr;\n  HWND timer_window = nullptr;\n\n  int screen_width = 0;\n  int screen_height = 0;\n  int window_width = 0;\n  int window_height = 0;\n  int cached_game_width = 0;\n  int cached_game_height = 0;\n  RECT game_window_rect{};\n\n  bool use_letterbox_mode = false;\n\n  bool overlay_window_shown = false;\n};\n\n// 渲染状态\nstruct RenderingState {\n  Utils::Graphics::D3D::D3DContext d3d_context;\n  Utils::Graphics::D3D::ShaderResources shader_resources;\n  wil::com_ptr<ID3D11Texture2D> frame_texture;\n  wil::com_ptr<ID3D11ShaderResourceView> capture_srv;\n  HANDLE frame_latency_object = nullptr;\n  std::atomic<bool> resources_busy = false;  // 标记渲染资源是否正忙（如尺寸调整等）\n\n  bool d3d_initialized = false;\n  bool create_new_srv = true;\n};\n\n// 捕获状态\nstruct CaptureState {\n  Utils::Graphics::Capture::CaptureSession session;\n  int last_frame_width = 0;\n  int last_frame_height = 0;\n};\n\n// 交互状态\nstruct InteractionState {\n  HWINEVENTHOOK foreground_event_hook = nullptr;\n  HWINEVENTHOOK target_window_event_hook = nullptr;\n  std::optional<POINT> last_game_window_pos;\n  DWORD game_process_id = 0;\n  bool is_game_focused = false;            // 前台窗口是否是游戏/overlay\n  bool taskbar_redraw_suppressed = false;  // 任务栏重绘是否已禁用\n};\n\n// 线程状态\nstruct ThreadState {\n  std::jthread hook_thread;\n  std::jthread window_manager_thread;\n\n  DWORD hook_thread_id = 0;\n  DWORD window_manager_thread_id = 0;\n};\n\n}  // namespace Features::Overlay::Types\n"
  },
  {
    "path": "src/features/overlay/usecase.cpp",
    "content": "module;\n\nmodule Features.Overlay.UseCase;\n\nimport std;\nimport Core.State;\nimport Core.I18n.State;\nimport Features.Overlay;\nimport Features.Preview;\nimport Features.Preview.State;\nimport Features.Letterbox;\nimport Features.Letterbox.State;\nimport Features.Settings.State;\nimport Features.WindowControl;\nimport Features.Notifications;\nimport Features.Overlay.State;\nimport Utils.Logger;\nimport Utils.String;\n\nnamespace Features::Overlay::UseCase {\n\n// 切换叠加层功能\nauto toggle_overlay(Core::State::AppState& state) -> void {\n  bool is_enabled = state.overlay->enabled;\n\n  // 切换启用状态\n  state.overlay->enabled = !is_enabled;\n\n  if (!is_enabled) {\n    // 用户想启用叠加层\n    // 预览窗与叠加层互斥，若预览窗运行则先关闭\n    if (state.preview && state.preview->running.load(std::memory_order_acquire)) {\n      Features::Preview::stop_preview(state);\n      Features::Notifications::show_notification(\n          state, state.i18n->texts[\"label.app_name\"],\n          state.i18n->texts[\"message.preview_overlay_conflict\"]);\n    }\n    // 如果启用了黑边模式，关闭黑边窗口\n    if (state.letterbox->enabled) {\n      if (auto result = Features::Letterbox::shutdown(state); !result) {\n        Logger().error(\"Failed to shutdown letterbox: {}\", result.error());\n      }\n    }\n\n    std::wstring window_title = Utils::String::FromUtf8(state.settings->raw.window.target_title);\n    auto target_window = Features::WindowControl::find_target_window(window_title);\n\n    if (target_window) {\n      if (auto result = Features::Overlay::start_overlay(state, target_window.value()); !result) {\n        Logger().error(\"Failed to start overlay: {}\", result.error());\n        // 回滚启用状态\n        state.overlay->enabled = false;\n        // 使用新的消息定义并附加错误详情\n        std::string error_message =\n            state.i18n->texts[\"message.overlay_start_failed\"] + result.error();\n        Features::Notifications::show_notification(state, state.i18n->texts[\"label.app_name\"],\n                                                   error_message);\n      }\n    } else {\n      // 找不到目标窗口\n      Logger().warn(\"No target window found for overlay\");\n      state.overlay->enabled = false;\n      Features::Notifications::show_notification(state, state.i18n->texts[\"label.app_name\"],\n                                                 state.i18n->texts[\"message.window_not_found\"]);\n    }\n  } else {\n    // 用户想停用叠加层\n    Features::Overlay::stop_overlay(state);\n\n    // 如果启用了黑边模式，重新显示黑边窗口\n    if (state.letterbox->enabled) {\n      std::wstring window_title = Utils::String::FromUtf8(state.settings->raw.window.target_title);\n      auto target_window = Features::WindowControl::find_target_window(window_title);\n      if (target_window) {\n        if (auto result = Features::Letterbox::show(state, target_window.value()); !result) {\n          Logger().error(\"Failed to show letterbox: {}\", result.error());\n        }\n      }\n    }\n  }\n}\n\n}  // namespace Features::Overlay::UseCase\n"
  },
  {
    "path": "src/features/overlay/usecase.ixx",
    "content": "module;\n\nexport module Features.Overlay.UseCase;\n\nimport Core.State;\nimport UI.FloatingWindow.Events;\n\nnamespace Features::Overlay::UseCase {\n\n// 切换叠加层功能\nexport auto toggle_overlay(Core::State::AppState& state) -> void;\n\n}  // namespace Features::Overlay::UseCase\n"
  },
  {
    "path": "src/features/overlay/window.cpp",
    "content": "module;\n\nmodule Features.Overlay.Window;\n\nimport std;\nimport Features.Overlay.State;\nimport Utils.Logger;\nimport Core.State;\nimport Features.Overlay.Types;\nimport Features.Overlay.Geometry;\nimport Features.Overlay.Interaction;\nimport <dwmapi.h>;\nimport <windows.h>;\n\nnamespace Features::Overlay::Window {\n\n// 窗口过程 - 使用GWLP_USERDATA模式获取状态\nLRESULT CALLBACK overlay_window_proc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam) {\n  Core::State::AppState* state = nullptr;\n\n  if (message == WM_NCCREATE) {\n    const auto* cs = reinterpret_cast<CREATESTRUCT*>(lParam);\n    state = reinterpret_cast<Core::State::AppState*>(cs->lpCreateParams);\n    SetWindowLongPtr(hwnd, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(state));\n  } else {\n    state = reinterpret_cast<Core::State::AppState*>(GetWindowLongPtr(hwnd, GWLP_USERDATA));\n  }\n\n  if (!state) {\n    return DefWindowProcW(hwnd, message, wParam, lParam);\n  }\n\n  // 调用交互模块处理消息\n  auto [handled, result] =\n      Interaction::handle_overlay_message(*state, hwnd, message, wParam, lParam);\n  if (handled) {\n    return result;\n  }\n\n  return DefWindowProcW(hwnd, message, wParam, lParam);\n}\n\nauto register_overlay_window_class(HINSTANCE instance) -> std::expected<void, std::string> {\n  WNDCLASSEXW wc = {};\n  wc.cbSize = sizeof(WNDCLASSEXW);\n  wc.lpfnWndProc = overlay_window_proc;\n  wc.hInstance = instance;\n  wc.lpszClassName = L\"OverlayWindowClass\";\n  wc.hCursor = LoadCursor(nullptr, IDC_ARROW);\n\n  if (!RegisterClassExW(&wc)) {\n    DWORD error = GetLastError();\n    return std::unexpected(\n        std::format(\"Failed to register overlay window class. Error: {}\", error));\n  }\n\n  return {};\n}\n\nauto unregister_overlay_window_class(HINSTANCE instance) -> void {\n  UnregisterClassW(L\"OverlayWindowClass\", instance);\n}\n\nauto create_overlay_window(HINSTANCE instance, Core::State::AppState& state)\n    -> std::expected<HWND, std::string> {\n  auto& overlay_state = *state.overlay;\n  auto [screen_width, screen_height] = Geometry::get_screen_dimensions();\n\n  overlay_state.window.screen_width = screen_width;\n  overlay_state.window.screen_height = screen_height;\n\n  HWND hwnd = CreateWindowExW(WS_EX_TRANSPARENT | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE |\n                                  WS_EX_LAYERED | WS_EX_NOREDIRECTIONBITMAP,\n                              L\"OverlayWindowClass\", L\"Overlay Window\", WS_POPUP, 0, 0,\n                              screen_width, screen_height, nullptr, nullptr, instance, &state);\n\n  if (!hwnd) {\n    DWORD error = GetLastError();\n    auto error_msg = std::format(\"Failed to create overlay window. Error: {}\", error);\n    Logger().error(error_msg);\n    return std::unexpected(error_msg);\n  }\n\n  overlay_state.window.overlay_hwnd = hwnd;\n  Logger().info(\"Overlay window created successfully\");\n  return hwnd;\n}\n\nauto set_window_layered_attributes(HWND hwnd) -> std::expected<void, std::string> {\n  if (!SetLayeredWindowAttributes(hwnd, 0, 255, LWA_ALPHA)) {\n    DWORD error = GetLastError();\n    return std::unexpected(\n        std::format(\"Failed to set layered window attributes. Error: {}\", error));\n  }\n  return {};\n}\n\nauto initialize_overlay_window(Core::State::AppState& state, HINSTANCE instance)\n    -> std::expected<void, std::string> {\n  // 注册窗口类\n  if (auto result = register_overlay_window_class(instance); !result) {\n    return std::unexpected(result.error());\n  }\n\n  // 创建窗口\n  if (auto result = create_overlay_window(instance, state); !result) {\n    unregister_overlay_window_class(instance);\n    return std::unexpected(result.error());\n  }\n\n  return {};\n}\n\nauto hide_overlay_window(Core::State::AppState& state) -> void {\n  auto& overlay_state = *state.overlay;\n  if (overlay_state.window.overlay_hwnd) {\n    ShowWindow(overlay_state.window.overlay_hwnd, SW_HIDE);\n  }\n\n  overlay_state.window.overlay_window_shown = false;\n}\n\nauto set_overlay_window_size(Core::State::AppState& state, int game_width, int game_height)\n    -> void {\n  auto& overlay_state = *state.overlay;\n\n  // 缓存游戏窗口尺寸\n  overlay_state.window.cached_game_width = game_width;\n  overlay_state.window.cached_game_height = game_height;\n\n  // 计算叠加层窗口尺寸\n  if (overlay_state.window.use_letterbox_mode) {\n    overlay_state.window.window_width = overlay_state.window.screen_width;\n    overlay_state.window.window_height = overlay_state.window.screen_height;\n  } else {\n    auto [window_width, window_height] = Geometry::calculate_overlay_dimensions(\n        game_width, game_height, overlay_state.window.screen_width,\n        overlay_state.window.screen_height);\n\n    overlay_state.window.window_width = window_width;\n    overlay_state.window.window_height = window_height;\n  }\n\n  // 计算窗口大小和位置 - 在letterbox模式下，overlay窗口始终是全屏的\n  int screen_width = overlay_state.window.screen_width;\n  int screen_height = overlay_state.window.screen_height;\n  int left = 0;\n  int top = 0;\n  int width = screen_width;\n  int height = screen_height;\n\n  // 在非letterbox模式下使用居中窗口\n  if (!overlay_state.window.use_letterbox_mode) {\n    width = overlay_state.window.window_width;\n    height = overlay_state.window.window_height;\n    left = (screen_width - width) / 2;\n    top = (screen_height - height) / 2;\n    Logger().debug(\"Not using letterbox mode, using window size: {}x{}\", width, height);\n  } else {\n    Logger().debug(\"Using letterbox mode, using full screen size: {}x{}\", width, height);\n  }\n\n  SetWindowPos(overlay_state.window.overlay_hwnd, nullptr, left, top, width, height,\n               SWP_NOZORDER | SWP_NOACTIVATE);\n\n  if (overlay_state.window.target_window) {\n    SetWindowPos(overlay_state.window.target_window, overlay_state.window.overlay_hwnd, 0, 0, 0, 0,\n                 SWP_NOMOVE | SWP_NOSIZE);\n  }\n}\n\nauto destroy_overlay_window(Core::State::AppState& state) -> void {\n  auto& overlay_state = *state.overlay;\n  if (overlay_state.window.overlay_hwnd) {\n    DestroyWindow(overlay_state.window.overlay_hwnd);\n    overlay_state.window.overlay_hwnd = nullptr;\n  }\n}\n\nauto show_overlay_window_first_time(Core::State::AppState& state)\n    -> std::expected<void, std::string> {\n  auto& overlay_state = *state.overlay;\n\n  // 显示叠加层窗口\n  ShowWindow(overlay_state.window.overlay_hwnd, SW_SHOWNA);\n\n  // 添加分层窗口样式到目标窗口\n  LONG exStyle = GetWindowLong(overlay_state.window.target_window, GWL_EXSTYLE);\n  SetWindowLong(overlay_state.window.target_window, GWL_EXSTYLE, exStyle | WS_EX_LAYERED);\n\n  // 设置透明度 (透明度1，但仍可点击)\n  if (!SetLayeredWindowAttributes(overlay_state.window.target_window, 0, 1, LWA_ALPHA)) {\n    DWORD error = GetLastError();\n    return std::unexpected(\n        std::format(\"Failed to set layered window attributes. Error: {}\", error));\n  }\n\n  Logger().debug(\"Set target window layered\");\n  return {};\n}\n\nauto restore_game_window(Core::State::AppState& state) -> void {\n  auto& overlay_state = *state.overlay;\n  if (!overlay_state.window.target_window) return;\n\n  // 移除分层窗口样式\n  LONG ex_style = GetWindowLong(overlay_state.window.target_window, GWL_EXSTYLE);\n  SetWindowLong(overlay_state.window.target_window, GWL_EXSTYLE, ex_style & ~WS_EX_LAYERED);\n\n  // 获取窗口尺寸\n  auto dimensions_result = Geometry::get_window_dimensions(overlay_state.window.target_window);\n  if (!dimensions_result) {\n    return;\n  }\n\n  auto [width, height] = dimensions_result.value();\n\n  int left = (overlay_state.window.screen_width - width) / 2;\n  int top = (overlay_state.window.screen_height - height) / 2;\n  SetWindowPos(overlay_state.window.target_window, HWND_TOP, left, top, 0, 0,\n               SWP_NOSIZE | SWP_SHOWWINDOW | SWP_NOZORDER | SWP_NOACTIVATE);\n\n  SetForegroundWindow(overlay_state.window.target_window);\n}\n\n}  // namespace Features::Overlay::Window\n"
  },
  {
    "path": "src/features/overlay/window.ixx",
    "content": "module;\n\nexport module Features.Overlay.Window;\n\nimport std;\nimport Features.Overlay.State;\nimport Core.State;\nimport <windows.h>;\n\nnamespace Features::Overlay::Window {\n\n// 创建叠加层窗口\nexport auto create_overlay_window(HINSTANCE instance, Core::State::AppState& state)\n    -> std::expected<HWND, std::string>;\n\n// 初始化叠加层窗口系统\nexport auto initialize_overlay_window(Core::State::AppState& state, HINSTANCE instance)\n    -> std::expected<void, std::string>;\n\n// 显示叠加层窗口（首次显示）\nexport auto show_overlay_window_first_time(Core::State::AppState& state)\n    -> std::expected<void, std::string>;\n\n// 隐藏叠加层窗口\nexport auto hide_overlay_window(Core::State::AppState& state) -> void;\n\n// 更新叠加层窗口尺寸\nexport auto set_overlay_window_size(Core::State::AppState& state, int game_width, int game_height)\n    -> void;\n\n// 销毁叠加层窗口\nexport auto destroy_overlay_window(Core::State::AppState& state) -> void;\n\n// 注销叠加层窗口类\nexport auto unregister_overlay_window_class(HINSTANCE instance) -> void;\n\n// 恢复游戏窗口\nexport auto restore_game_window(Core::State::AppState& state) -> void;\n\n}  // namespace Features::Overlay::Window\n"
  },
  {
    "path": "src/features/preview/capture.cpp",
    "content": "module;\n\n#include <wil/com.h>\n\nmodule Features.Preview.Capture;\n\nimport std;\nimport Core.State;\nimport Core.State.RuntimeInfo;\nimport Features.Preview.State;\nimport Features.Preview.Types;\nimport Features.Preview.Rendering;\nimport Features.Preview.Window;\nimport Utils.Graphics.Capture;\nimport Utils.Logger;\nimport <d3d11.h>;\nimport <windows.h>;\n\nnamespace Features::Preview::Capture {\n\nauto on_frame_arrived(Core::State::AppState& state,\n                      Utils::Graphics::Capture::Direct3D11CaptureFrame frame) -> void {\n  if (!state.preview->running.load(std::memory_order_acquire) || !frame) {\n    return;\n  }\n\n  // 检查帧大小是否发生变化\n  auto content_size = frame.ContentSize();\n  auto& last_width = state.preview->capture_state.last_frame_width;\n  auto& last_height = state.preview->capture_state.last_frame_height;\n\n  bool size_changed = (content_size.Width != last_width) || (content_size.Height != last_height);\n\n  if (size_changed) {\n    // 更新记录的尺寸\n    last_width = content_size.Width;\n    last_height = content_size.Height;\n\n    // 重建帧池\n    Utils::Graphics::Capture::recreate_frame_pool(state.preview->capture_state.session,\n                                                  content_size.Width, content_size.Height);\n\n    state.preview->create_new_srv.store(true, std::memory_order_release);\n\n    // 捕获回调线程只负责发现尺寸变化；窗口尺寸状态与 SetWindowPos 统一收口到窗口线程。\n    if (!PostMessage(state.preview->hwnd, Features::Preview::Types::WM_APPLY_CAPTURE_SIZE,\n                     static_cast<WPARAM>(content_size.Width),\n                     static_cast<LPARAM>(content_size.Height))) {\n      Logger().warn(\"Failed to post preview capture size update message\");\n    }\n\n    return;\n  }\n\n  auto surface = frame.Surface();\n  if (surface) {\n    auto texture =\n        Utils::Graphics::Capture::get_dxgi_interface_from_object<ID3D11Texture2D>(surface);\n    if (texture) {\n      // 触发渲染\n      Features::Preview::Rendering::render_frame(state, texture);\n    }\n  }\n}\n\nauto initialize_capture(Core::State::AppState& state, HWND target_window, int width, int height)\n    -> std::expected<void, std::string> {\n  if (!target_window || !IsWindow(target_window)) {\n    return std::unexpected(\"Invalid target window\");\n  }\n\n  // 检查是否支持捕获\n  if (!state.runtime_info->is_capture_supported) {\n    return std::unexpected(\"Capture not supported on this system\");\n  }\n\n  // 确保渲染系统已初始化\n  auto& rendering_resources = state.preview->rendering_resources;\n  if (!rendering_resources.initialized.load(std::memory_order_acquire)) {\n    return std::unexpected(\"D3D not initialized\");\n  }\n\n  // 创建WinRT设备\n  auto winrt_device_result =\n      Utils::Graphics::Capture::create_winrt_device(rendering_resources.d3d_context.device.get());\n  if (!winrt_device_result) {\n    Logger().error(\"Failed to create WinRT device for capture\");\n    return std::unexpected(\"Failed to create WinRT device\");\n  }\n\n  // 创建帧回调\n  auto frame_callback = [&state](winrt::Windows::Graphics::Capture::Direct3D11CaptureFrame frame) {\n    on_frame_arrived(state, frame);\n  };\n\n  // 创建捕获会话\n  auto session_result = Utils::Graphics::Capture::create_capture_session(\n      target_window, winrt_device_result.value(), width, height, frame_callback);\n\n  if (!session_result) {\n    Logger().error(\"Failed to create capture session\");\n    return std::unexpected(\"Failed to create capture session\");\n  }\n\n  state.preview->capture_state.session = std::move(session_result.value());\n\n  Logger().info(\"Capture system initialized successfully\");\n  return {};\n}\n\nauto start_capture(Core::State::AppState& state) -> std::expected<void, std::string> {\n  auto& session = state.preview->capture_state.session;\n\n  auto start_result = Utils::Graphics::Capture::start_capture(session);\n  if (!start_result) {\n    Logger().error(\"Failed to start capture\");\n    return std::unexpected(\"Failed to start capture\");\n  }\n\n  Logger().debug(\"Capture started successfully\");\n  return {};\n}\n\nauto stop_capture(Core::State::AppState& state) -> void {\n  auto& session = state.preview->capture_state.session;\n\n  Utils::Graphics::Capture::stop_capture(session);\n  Logger().debug(\"Capture stopped\");\n}\n\nauto cleanup_capture(Core::State::AppState& state) -> void {\n  auto& session = state.preview->capture_state.session;\n\n  Utils::Graphics::Capture::cleanup_capture_session(session);\n\n  Logger().info(\"Capture resources cleaned up\");\n}\n\n}  // namespace Features::Preview::Capture\n"
  },
  {
    "path": "src/features/preview/capture.ixx",
    "content": "module;\n\nexport module Features.Preview.Capture;\n\nimport std;\nimport Core.State;\nimport <windows.h>;\n\nexport namespace Features::Preview::Capture {\n\n// 初始化捕获系统\nauto initialize_capture(Core::State::AppState& state, HWND target_window, int width, int height)\n    -> std::expected<void, std::string>;\n\n// 开始捕获\nauto start_capture(Core::State::AppState& state) -> std::expected<void, std::string>;\n\n// 停止捕获\nauto stop_capture(Core::State::AppState& state) -> void;\n\n// 清理捕获资源\nauto cleanup_capture(Core::State::AppState& state) -> void;\n\n}  // namespace Features::Preview::Capture"
  },
  {
    "path": "src/features/preview/interaction.cpp",
    "content": "module;\n\nmodule Features.Preview.Interaction;\n\nimport std;\nimport Core.State;\nimport Features.Preview.Capture;\nimport Features.Preview.State;\nimport Features.Preview.Types;\nimport Features.Preview.Rendering;\nimport Features.Preview.Window;\nimport Utils.Logger;\nimport Utils.Throttle;\nimport <dwmapi.h>;\nimport <windows.h>;\nimport <windowsx.h>;\n\nnamespace Features::Preview::Interaction {\n\n// ==================== 任务栏重绘控制 ====================\n\nauto suppress_taskbar_redraw(Core::State::AppState& state) -> void {\n  if (state.preview->interaction.taskbar_redraw_suppressed) {\n    return;  // 已经禁止了，无需重复操作\n  }\n\n  HWND taskbar = FindWindow(L\"Shell_TrayWnd\", nullptr);\n  if (taskbar) {\n    SendMessage(taskbar, WM_SETREDRAW, FALSE, 0);\n    state.preview->interaction.taskbar_redraw_suppressed = true;\n    Logger().debug(\"Taskbar redraw suppressed\");\n  }\n}\n\nauto restore_taskbar_redraw(Core::State::AppState& state) -> void {\n  if (!state.preview->interaction.taskbar_redraw_suppressed) {\n    return;  // 未禁止，无需恢复\n  }\n\n  HWND taskbar = FindWindow(L\"Shell_TrayWnd\", nullptr);\n  if (taskbar) {\n    SendMessage(taskbar, WM_SETREDRAW, TRUE, 0);\n    RedrawWindow(taskbar, nullptr, nullptr, RDW_INVALIDATE | RDW_UPDATENOW);\n    state.preview->interaction.taskbar_redraw_suppressed = false;\n    Logger().debug(\"Taskbar redraw restored\");\n  }\n}\n\n// ==================== 辅助函数实现 ====================\n\nauto is_point_in_title_bar(const Core::State::AppState& state, POINT pt) -> bool {\n  return pt.y < state.preview->dpi_sizes.title_height;\n}\n\nauto is_point_in_viewport(const Core::State::AppState& state, POINT pt) -> bool {\n  return (pt.x >= state.preview->viewport.viewport_rect.left &&\n          pt.x <= state.preview->viewport.viewport_rect.right &&\n          pt.y >= state.preview->viewport.viewport_rect.top &&\n          pt.y <= state.preview->viewport.viewport_rect.bottom);\n}\n\nauto get_border_hit_test(const Core::State::AppState& state, HWND hwnd, POINT pt) -> LRESULT {\n  RECT rc;\n  GetClientRect(hwnd, &rc);\n\n  int borderWidth = state.preview->dpi_sizes.border_width;\n\n  if (pt.x <= borderWidth) {\n    if (pt.y <= borderWidth) return HTTOPLEFT;\n    if (pt.y >= rc.bottom - borderWidth) return HTBOTTOMLEFT;\n    return HTLEFT;\n  }\n  if (pt.x >= rc.right - borderWidth) {\n    if (pt.y <= borderWidth) return HTTOPRIGHT;\n    if (pt.y >= rc.bottom - borderWidth) return HTBOTTOMRIGHT;\n    return HTRIGHT;\n  }\n  if (pt.y <= borderWidth) return HTTOP;\n  if (pt.y >= rc.bottom - borderWidth) return HTBOTTOM;\n\n  return HTCLIENT;\n}\n\nauto move_game_window_to_position(Core::State::AppState& state, float relative_x, float relative_y)\n    -> void {\n  if (!state.preview->target_window) return;\n\n  Logger().debug(\"move_game_window_to_position: relative_x: {}, relative_y: {}\", relative_x,\n                 relative_y);\n\n  // 获取屏幕尺寸\n  int screenWidth = GetSystemMetrics(SM_CXSCREEN);\n  int screenHeight = GetSystemMetrics(SM_CYSCREEN);\n\n  // 获取游戏窗口尺寸\n  float gameWidth = static_cast<float>(state.preview->game_window_rect.right -\n                                       state.preview->game_window_rect.left);\n  float gameHeight = static_cast<float>(state.preview->game_window_rect.bottom -\n                                        state.preview->game_window_rect.top);\n\n  // 计算新位置\n  float targetX, targetY;\n\n  // 水平方向\n  if (gameWidth <= screenWidth) {\n    targetX = (screenWidth - gameWidth) / 2;  // 居中\n  } else {\n    targetX = -relative_x * gameWidth + screenWidth / 2;\n    targetX = std::max(targetX, -gameWidth + screenWidth);\n    targetX = std::min(targetX, 0.0f);\n  }\n\n  // 垂直方向\n  if (gameHeight <= screenHeight) {\n    targetY = (screenHeight - gameHeight) / 2;  // 居中\n  } else {\n    targetY = -relative_y * gameHeight + screenHeight / 2;\n    targetY = std::max(targetY, -gameHeight + screenHeight);\n    targetY = std::min(targetY, 0.0f);\n  }\n\n  // 跳过重复位置\n  POINT newPos = {static_cast<LONG>(targetX), static_cast<LONG>(targetY)};\n  if (auto& lastPos = state.preview->interaction.last_game_window_pos;\n      lastPos && lastPos->x == newPos.x && lastPos->y == newPos.y) {\n    return;\n  }\n\n  // 移动游戏窗口\n  state.preview->interaction.last_game_window_pos = newPos;\n  SetWindowPos(state.preview->target_window, nullptr, newPos.x, newPos.y, 0, 0,\n               SWP_NOSIZE | SWP_NOZORDER | SWP_NOREDRAW | SWP_NOCOPYBITS | SWP_NOSENDCHANGING);\n}\n\n// 窗口拖拽实现\nauto start_window_drag(Core::State::AppState& state, HWND hwnd, POINT pt) -> void {\n  state.preview->interaction.is_dragging = true;\n  state.preview->interaction.drag_start = pt;\n  SetCapture(hwnd);\n}\n\nauto update_window_drag(Core::State::AppState& state, HWND hwnd, POINT pt) -> void {\n  if (!state.preview->interaction.is_dragging) return;\n\n  RECT rect;\n  GetWindowRect(hwnd, &rect);\n  int deltaX = pt.x - state.preview->interaction.drag_start.x;\n  int deltaY = pt.y - state.preview->interaction.drag_start.y;\n\n  SetWindowPos(hwnd, nullptr, rect.left + deltaX, rect.top + deltaY, 0, 0,\n               SWP_NOSIZE | SWP_NOZORDER);\n}\n\nauto end_window_drag(Core::State::AppState& state, HWND hwnd) -> void {\n  state.preview->interaction.is_dragging = false;\n  ReleaseCapture();\n}\n\n// 视口拖拽实现\nauto start_viewport_drag(Core::State::AppState& state, HWND hwnd, POINT pt) -> void {\n  state.preview->interaction.viewport_dragging = true;\n\n  // 重置位置缓存，确保首次移动不会被跳过\n  state.preview->interaction.last_game_window_pos.reset();\n\n  // 初始化/重置节流器 (约60fps)\n  if (!state.preview->interaction.move_throttle) {\n    state.preview->interaction.move_throttle =\n        Utils::Throttle::create<float, float>(std::chrono::milliseconds(16));\n  } else {\n    Utils::Throttle::reset(*state.preview->interaction.move_throttle);\n  }\n\n  // 取消之前的延迟重绘定时器（如果存在）\n  KillTimer(hwnd, Features::Preview::Types::TIMER_ID_TASKBAR_REDRAW);\n\n  // 禁止任务栏重绘\n  suppress_taskbar_redraw(state);\n\n  SetCapture(hwnd);\n}\n\nauto update_viewport_drag(Core::State::AppState& state, HWND hwnd, POINT pt) -> void {\n  if (!state.preview->interaction.viewport_dragging) return;\n\n  RECT clientRect;\n  GetClientRect(hwnd, &clientRect);\n  float previewWidth = static_cast<float>(clientRect.right - clientRect.left);\n  float previewHeight = static_cast<float>(clientRect.bottom - clientRect.top);\n\n  // 计算新的相对位置\n  float relativeX = static_cast<float>(pt.x) / previewWidth;\n  float relativeY = static_cast<float>(pt.y) / previewHeight;\n\n  // 使用节流机制移动窗口\n  Utils::Throttle::call(\n      *state.preview->interaction.move_throttle,\n      [&state](float x, float y) { move_game_window_to_position(state, x, y); }, relativeX,\n      relativeY);\n}\n\nauto end_viewport_drag(Core::State::AppState& state, HWND hwnd) -> void {\n  // 确保最后一次移动被执行\n  Utils::Throttle::flush(*state.preview->interaction.move_throttle,\n                         [&state](float x, float y) { move_game_window_to_position(state, x, y); });\n\n  state.preview->interaction.viewport_dragging = false;\n  ReleaseCapture();\n\n  // 启动延迟定时器恢复任务栏重绘\n  if (state.preview->interaction.taskbar_redraw_suppressed) {\n    SetTimer(hwnd, Features::Preview::Types::TIMER_ID_TASKBAR_REDRAW,\n             Features::Preview::Types::TASKBAR_REDRAW_DELAY_MS, nullptr);\n  }\n}\n\nauto handle_mouse_move(Core::State::AppState& state, HWND hwnd, WPARAM wParam, LPARAM lParam)\n    -> LRESULT {\n  if (state.preview->interaction.is_dragging) {\n    update_window_drag(state, hwnd, {GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)});\n  } else if (state.preview->interaction.viewport_dragging) {\n    update_viewport_drag(state, hwnd, {GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)});\n  }\n  return 0;\n}\n\nauto handle_left_button_down(Core::State::AppState& state, HWND hwnd, WPARAM wParam, LPARAM lParam)\n    -> LRESULT {\n  POINT pt = {GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)};\n\n  // 检查是否点击在标题栏\n  if (is_point_in_title_bar(state, pt)) {\n    start_window_drag(state, hwnd, pt);\n    return 0;\n  }\n\n  // 如果游戏窗口完全可见，整个预览窗口都可以拖拽\n  if (state.preview->viewport.game_window_fully_visible) {\n    start_window_drag(state, hwnd, pt);\n    return 0;\n  }\n\n  // 先开始视口拖拽（设置任务栏保护和位置缓存）\n  start_viewport_drag(state, hwnd, pt);\n\n  // 检查是否点击在视口外，如果是则立即移动游戏窗口到点击位置\n  if (!is_point_in_viewport(state, pt)) {\n    RECT clientRect;\n    GetClientRect(hwnd, &clientRect);\n    float previewWidth = static_cast<float>(clientRect.right - clientRect.left);\n    float previewHeight = static_cast<float>(clientRect.bottom - clientRect.top);\n\n    float relativeX = static_cast<float>(pt.x) / previewWidth;\n    float relativeY = static_cast<float>(pt.y) / previewHeight;\n\n    move_game_window_to_position(state, relativeX, relativeY);\n  }\n\n  return 0;\n}\n\nauto handle_left_button_up(Core::State::AppState& state, HWND hwnd, WPARAM wParam, LPARAM lParam)\n    -> LRESULT {\n  if (state.preview->interaction.is_dragging) {\n    end_window_drag(state, hwnd);\n  } else if (state.preview->interaction.viewport_dragging) {\n    end_viewport_drag(state, hwnd);\n  }\n  return 0;\n}\n\n// 窗口缩放实现\nauto handle_window_scaling(Core::State::AppState& state, HWND hwnd, int wheel_delta,\n                           POINT mouse_pos) -> void {\n  // 计算新的理想尺寸（每次改变10%）\n  int oldIdealSize = state.preview->size.ideal_size;\n  int newIdealSize = static_cast<int>(oldIdealSize * (1.0f + (wheel_delta > 0 ? 0.1f : -0.1f)));\n\n  // 限制在最小最大范围内\n  newIdealSize = std::clamp(newIdealSize, state.preview->size.min_ideal_size,\n                            state.preview->size.max_ideal_size);\n\n  if (newIdealSize != oldIdealSize) {\n    state.preview->size.ideal_size = newIdealSize;\n\n    // 根据宽高比计算实际窗口尺寸\n    int newWidth, newHeight;\n    if (state.preview->size.aspect_ratio >= 1.0f) {\n      newHeight = newIdealSize;\n      newWidth = static_cast<int>(newHeight / state.preview->size.aspect_ratio);\n    } else {\n      newWidth = newIdealSize;\n      newHeight = static_cast<int>(newWidth * state.preview->size.aspect_ratio);\n    }\n\n    // 获取当前窗口位置\n    RECT windowRect;\n    GetWindowRect(hwnd, &windowRect);\n\n    // 计算鼠标相对位置\n    RECT clientRect;\n    GetClientRect(hwnd, &clientRect);\n    float relativeX = static_cast<float>(mouse_pos.x) / (clientRect.right - clientRect.left);\n    float relativeY = static_cast<float>(mouse_pos.y) / (clientRect.bottom - clientRect.top);\n\n    // 计算新位置（保持鼠标指向的点不变）\n    int deltaWidth = newWidth - (windowRect.right - windowRect.left);\n    int deltaHeight = newHeight - (windowRect.bottom - windowRect.top);\n    int newX = windowRect.left - static_cast<int>(deltaWidth * relativeX);\n    int newY = windowRect.top - static_cast<int>(deltaHeight * relativeY);\n\n    // 更新窗口\n    SetWindowPos(hwnd, nullptr, newX, newY, newWidth, newHeight, SWP_NOZORDER | SWP_NOACTIVATE);\n  }\n}\n\nauto handle_mouse_wheel(Core::State::AppState& state, HWND hwnd, WPARAM wParam, LPARAM lParam)\n    -> LRESULT {\n  // 检查鼠标是否在标题栏\n  POINT pt = {GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)};\n  ScreenToClient(hwnd, &pt);\n\n  if (is_point_in_title_bar(state, pt)) {\n    return 0;  // 标题栏不处理缩放\n  }\n\n  int delta = GET_WHEEL_DELTA_WPARAM(wParam);\n  handle_window_scaling(state, hwnd, delta, pt);\n  return 0;\n}\n\nauto handle_sizing(Core::State::AppState& state, HWND hwnd, WPARAM wParam, LPARAM lParam)\n    -> LRESULT {\n  RECT* rect = (RECT*)lParam;\n  int width = rect->right - rect->left;\n  int height = rect->bottom - rect->top;\n\n  // 根据拖动方向调整大小，保持宽高比\n  switch (wParam) {\n    case WMSZ_LEFT:\n    case WMSZ_RIGHT:\n      // 调整宽度，相应调整高度\n      width = std::max(width, state.preview->size.min_ideal_size);\n      height = static_cast<int>(width * state.preview->size.aspect_ratio);\n      if (wParam == WMSZ_LEFT) {\n        rect->left = rect->right - width;\n      } else {\n        rect->right = rect->left + width;\n      }\n      rect->bottom = rect->top + height;\n      break;\n\n    case WMSZ_TOP:\n    case WMSZ_BOTTOM:\n      // 调整高度，相应调整宽度\n      height = std::max(height, state.preview->size.min_ideal_size);\n      width = static_cast<int>(height / state.preview->size.aspect_ratio);\n      if (wParam == WMSZ_TOP) {\n        rect->top = rect->bottom - height;\n      } else {\n        rect->bottom = rect->top + height;\n      }\n      rect->right = rect->left + width;\n      break;\n\n    case WMSZ_TOPLEFT:\n    case WMSZ_TOPRIGHT:\n    case WMSZ_BOTTOMLEFT:\n    case WMSZ_BOTTOMRIGHT:\n      // 对角调整，以宽度为准\n      width = std::max(width, state.preview->size.min_ideal_size);\n      height = static_cast<int>(width * state.preview->size.aspect_ratio);\n\n      if (wParam == WMSZ_TOPLEFT || wParam == WMSZ_BOTTOMLEFT) {\n        rect->left = rect->right - width;\n      } else {\n        rect->right = rect->left + width;\n      }\n\n      if (wParam == WMSZ_TOPLEFT || wParam == WMSZ_TOPRIGHT) {\n        rect->top = rect->bottom - height;\n      } else {\n        rect->bottom = rect->top + height;\n      }\n      break;\n  }\n\n  // 更新理想尺寸\n  state.preview->size.ideal_size = std::max(width, height);\n  return TRUE;\n}\n\nauto handle_size(Core::State::AppState& state, HWND hwnd, WPARAM wParam, LPARAM lParam) -> LRESULT {\n  if (!state.preview->rendering_resources.initialized.load(std::memory_order_acquire)) {\n    return 0;\n  }\n\n  RECT clientRect;\n  GetClientRect(hwnd, &clientRect);\n  int width = clientRect.right - clientRect.left;\n  int height = clientRect.bottom - clientRect.top;\n\n  // 更新理想尺寸\n  state.preview->size.ideal_size = std::max(width, height);\n  state.preview->size.window_width = width;\n  state.preview->size.window_height = height;\n\n  // 调整渲染系统大小\n  if (auto result = Features::Preview::Rendering::resize_rendering(state, width, height); !result) {\n    Logger().error(\"Failed to resize preview rendering: {}\", result.error());\n  }\n\n  return 0;\n}\n\nauto handle_dpi_changed(Core::State::AppState& state, HWND hwnd, WPARAM wParam, LPARAM lParam)\n    -> LRESULT {\n  // 更新DPI\n  UINT newDpi = HIWORD(wParam);\n  state.preview->dpi_sizes.update_dpi_scaling(newDpi);\n\n  // 使用系统建议的新窗口位置\n  RECT* const prcNewWindow = (RECT*)lParam;\n  SetWindowPos(hwnd, nullptr, prcNewWindow->left, prcNewWindow->top,\n               prcNewWindow->right - prcNewWindow->left, prcNewWindow->bottom - prcNewWindow->top,\n               SWP_NOZORDER | SWP_NOACTIVATE);\n  return 0;\n}\n\nauto handle_nc_hit_test(Core::State::AppState& state, HWND hwnd, WPARAM wParam, LPARAM lParam)\n    -> LRESULT {\n  POINT pt = {GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)};\n  ScreenToClient(hwnd, &pt);\n\n  // 检查标题栏区域\n  if (is_point_in_title_bar(state, pt)) {\n    return HTCAPTION;\n  }\n\n  // 检查边框区域\n  return get_border_hit_test(state, hwnd, pt);\n}\n\nauto handle_paint(Core::State::AppState& state, HWND hwnd) -> LRESULT {\n  PAINTSTRUCT ps;\n  HDC hdc = BeginPaint(hwnd, &ps);\n\n  // 获取窗口客户区大小\n  RECT rc;\n  GetClientRect(hwnd, &rc);\n\n  // 绘制标题栏背景\n  RECT titleRect = {0, 0, rc.right, state.preview->dpi_sizes.title_height};\n  HBRUSH titleBrush = CreateSolidBrush(RGB(240, 240, 240));\n  FillRect(hdc, &titleRect, titleBrush);\n  DeleteObject(titleBrush);\n\n  // 绘制标题文本\n  SetBkMode(hdc, TRANSPARENT);\n  SetTextColor(hdc, RGB(51, 51, 51));\n  HFONT hFont = CreateFont(-state.preview->dpi_sizes.font_size, 0, 0, 0, FW_NORMAL, FALSE, FALSE,\n                           FALSE, DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS,\n                           CLEARTYPE_QUALITY, DEFAULT_PITCH | FF_DONTCARE, TEXT(\"Microsoft YaHei\"));\n  HFONT oldFont = (HFONT)SelectObject(hdc, hFont);\n\n  titleRect.left += state.preview->dpi_sizes.font_size;\n  DrawTextW(hdc, L\"Preview\", -1, &titleRect, DT_SINGLELINE | DT_VCENTER | DT_LEFT);\n\n  SelectObject(hdc, oldFont);\n  DeleteObject(hFont);\n\n  // 绘制分隔线\n  RECT sepRect = {0, state.preview->dpi_sizes.title_height - 1, rc.right,\n                  state.preview->dpi_sizes.title_height};\n  HBRUSH sepBrush = CreateSolidBrush(RGB(229, 229, 229));\n  FillRect(hdc, &sepRect, sepBrush);\n  DeleteObject(sepBrush);\n\n  EndPaint(hwnd, &ps);\n  return 0;\n}\n\nauto handle_timer(Core::State::AppState& state, HWND hwnd, WPARAM wParam) -> LRESULT {\n  if (wParam == Features::Preview::Types::TIMER_ID_TASKBAR_REDRAW) {\n    // 停止定时器\n    KillTimer(hwnd, Features::Preview::Types::TIMER_ID_TASKBAR_REDRAW);\n    // 恢复任务栏重绘\n    restore_taskbar_redraw(state);\n    return 0;\n  }\n\n  if (wParam == Features::Preview::Types::TIMER_ID_PREVIEW_CLEANUP) {\n    KillTimer(hwnd, Features::Preview::Types::TIMER_ID_PREVIEW_CLEANUP);\n    Features::Preview::Capture::cleanup_capture(state);\n    Features::Preview::Rendering::cleanup_rendering(state);\n    Logger().info(\"Preview resources cleaned up\");\n  }\n\n  return 0;\n}\n\nauto handle_preview_message(Core::State::AppState& state, HWND hwnd, UINT message, WPARAM wParam,\n                            LPARAM lParam) -> std::pair<bool, LRESULT> {\n  switch (message) {\n    case Features::Preview::Types::WM_SCHEDULE_PREVIEW_CLEANUP:\n      KillTimer(hwnd, Features::Preview::Types::TIMER_ID_PREVIEW_CLEANUP);\n      if (SetTimer(hwnd, Features::Preview::Types::TIMER_ID_PREVIEW_CLEANUP, 3000, nullptr) == 0) {\n        Logger().error(\"Failed to schedule delayed preview cleanup\");\n        return {true, 0};\n      }\n      return {true, 1};\n\n    case Features::Preview::Types::WM_CANCEL_PREVIEW_CLEANUP:\n      KillTimer(hwnd, Features::Preview::Types::TIMER_ID_PREVIEW_CLEANUP);\n      return {true, 1};\n\n    case Features::Preview::Types::WM_IMMEDIATE_PREVIEW_CLEANUP:\n      KillTimer(hwnd, Features::Preview::Types::TIMER_ID_PREVIEW_CLEANUP);\n      Features::Preview::Capture::cleanup_capture(state);\n      Features::Preview::Rendering::cleanup_rendering(state);\n      Logger().info(\"Preview resources cleaned up\");\n      return {true, 1};\n\n    case Features::Preview::Types::WM_APPLY_CAPTURE_SIZE:\n      Features::Preview::Window::set_preview_window_size(*state.preview, static_cast<int>(wParam),\n                                                         static_cast<int>(lParam));\n      return {true, 0};\n\n    case WM_PAINT:\n      return {true, handle_paint(state, hwnd)};\n\n    case WM_MOUSEMOVE:\n      return {true, handle_mouse_move(state, hwnd, wParam, lParam)};\n\n    case WM_LBUTTONDOWN:\n      return {true, handle_left_button_down(state, hwnd, wParam, lParam)};\n\n    case WM_LBUTTONUP:\n      return {true, handle_left_button_up(state, hwnd, wParam, lParam)};\n\n    case WM_MOUSEWHEEL:\n      return {true, handle_mouse_wheel(state, hwnd, wParam, lParam)};\n\n    case WM_SIZING:\n      return {true, handle_sizing(state, hwnd, wParam, lParam)};\n\n    case WM_SIZE:\n      return {true, handle_size(state, hwnd, wParam, lParam)};\n\n    case WM_DPICHANGED:\n      return {true, handle_dpi_changed(state, hwnd, wParam, lParam)};\n\n    case WM_NCHITTEST:\n      return {true, handle_nc_hit_test(state, hwnd, wParam, lParam)};\n\n    case WM_TIMER:\n      return {true, handle_timer(state, hwnd, wParam)};\n\n    case WM_DESTROY:\n      // 清理资源但不调用PostQuitMessage\n      // 确保任务栏重绘被恢复\n      KillTimer(hwnd, Features::Preview::Types::TIMER_ID_TASKBAR_REDRAW);\n      KillTimer(hwnd, Features::Preview::Types::TIMER_ID_PREVIEW_CLEANUP);\n      restore_taskbar_redraw(state);\n      return {true, 0};\n\n    default:\n      return {false, 0};\n  }\n}\n\n}  // namespace Features::Preview::Interaction\n"
  },
  {
    "path": "src/features/preview/interaction.ixx",
    "content": "module;\n\nexport module Features.Preview.Interaction;\n\nimport std;\nimport Core.State;\nimport <windows.h>;\n\nnamespace Features::Preview::Interaction {\n\n// 主消息处理函数\n// 返回值：first为true表示已处理消息，为false表示应使用默认处理\nexport auto handle_preview_message(Core::State::AppState& state, HWND hwnd, UINT message,\n                                   WPARAM wParam, LPARAM lParam) -> std::pair<bool, LRESULT>;\n\n}  // namespace Features::Preview::Interaction"
  },
  {
    "path": "src/features/preview/preview.cpp",
    "content": "module;\n\nmodule Features.Preview;\n\nimport std;\nimport Core.State;\nimport Core.State.RuntimeInfo;\nimport Features.Preview.State;\nimport Features.Preview.Types;\nimport Features.Preview.Window;\nimport Features.Preview.Interaction;\nimport Features.Preview.Rendering;\nimport Features.Preview.Capture;\nimport Utils.Graphics.D3D;\nimport Utils.Graphics.Capture;\nimport Utils.Logger;\nimport <dwmapi.h>;\nimport <windows.h>;\nimport <windowsx.h>;\n\nnamespace Features::Preview {\n\nauto send_preview_control_message(HWND preview_hwnd, UINT message) -> bool {\n  if (!preview_hwnd || !IsWindow(preview_hwnd)) {\n    return false;\n  }\n\n  return SendMessageW(preview_hwnd, message, 0, 0) != 0;\n}\n\nauto start_preview(Core::State::AppState& state, HWND target_window)\n    -> std::expected<void, std::string> {\n  auto& preview_state = *state.preview;\n\n  // 检查是否支持捕获\n  if (!state.runtime_info->is_capture_supported) {\n    return std::unexpected(\"Capture not supported on this system\");\n  }\n\n  // 检查预览窗口是否存在，如果不存在则初始化\n  if (!preview_state.hwnd) {\n    HINSTANCE instance = GetModuleHandle(nullptr);\n\n    if (auto result = Window::initialize_preview_window(state, instance); !result) {\n      return std::unexpected(result.error());\n    }\n  }\n\n  if (!target_window || !IsWindow(target_window)) {\n    return std::unexpected(\"Invalid target window\");\n  }\n\n  // 检查窗口是否最小化\n  if (IsIconic(target_window)) {\n    return std::unexpected(\"Target window is minimized\");\n  }\n\n  if (!send_preview_control_message(preview_state.hwnd, Types::WM_CANCEL_PREVIEW_CLEANUP)) {\n    Logger().warn(\"Failed to cancel pending preview cleanup\");\n  }\n\n  // 保存目标窗口\n  preview_state.target_window = target_window;\n\n  // 计算捕获尺寸\n  RECT clientRect;\n  GetClientRect(target_window, &clientRect);\n  int width = clientRect.right - clientRect.left;\n  int height = clientRect.bottom - clientRect.top;\n\n  // 初始化渲染系统（如果需要）\n  if (!preview_state.rendering_resources.initialized.load(std::memory_order_acquire)) {\n    auto rendering_result =\n        Rendering::initialize_rendering(state, preview_state.hwnd, preview_state.size.window_width,\n                                        preview_state.size.window_height);\n\n    if (!rendering_result) {\n      Logger().error(\"Failed to initialize rendering system\");\n      return std::unexpected(rendering_result.error());\n    }\n  }\n\n  // 初始化捕获系统\n  auto capture_result = Capture::initialize_capture(state, target_window, width, height);\n\n  if (!capture_result) {\n    Logger().error(\"Failed to initialize capture system\");\n    return std::unexpected(capture_result.error());\n  }\n  // 计算窗口尺寸和宽高比\n  Window::set_preview_window_size(preview_state, width, height);\n\n  Window::show_preview_window(state);\n\n  // 启动捕获\n  auto start_result = Capture::start_capture(state);\n  if (!start_result) {\n    Logger().error(\"Failed to start capture\");\n    return std::unexpected(start_result.error());\n  }\n\n  preview_state.running.store(true, std::memory_order_release);\n\n  Logger().info(\"Preview capture started successfully\");\n  return {};\n}\n\nauto stop_preview(Core::State::AppState& state) -> void {\n  auto& preview_state = *state.preview;\n\n  if (!preview_state.running.load(std::memory_order_acquire)) {\n    return;\n  }\n\n  preview_state.running.store(false, std::memory_order_release);\n  preview_state.create_new_srv.store(true, std::memory_order_release);\n\n  // 停止捕获\n  Capture::stop_capture(state);\n\n  // 隐藏窗口\n  Window::hide_preview_window(state);\n\n  if (!send_preview_control_message(preview_state.hwnd, Types::WM_SCHEDULE_PREVIEW_CLEANUP)) {\n    cleanup_preview(state);\n  }\n\n  Logger().info(\"Preview capture stopped\");\n}\n\nauto update_preview_dpi(Core::State::AppState& state, UINT new_dpi) -> void {\n  state.preview->dpi_sizes.update_dpi_scaling(new_dpi);\n  Window::update_preview_window_dpi(state, new_dpi);\n}\n\nauto cleanup_preview(Core::State::AppState& state) -> void {\n  if (send_preview_control_message(state.preview->hwnd, Types::WM_IMMEDIATE_PREVIEW_CLEANUP)) {\n    return;\n  }\n\n  Capture::cleanup_capture(state);\n  Rendering::cleanup_rendering(state);\n\n  Logger().info(\"Preview resources cleaned up\");\n}\n\n}  // namespace Features::Preview\n"
  },
  {
    "path": "src/features/preview/preview.ixx",
    "content": "module;\n\nexport module Features.Preview;\n\nimport std;\nimport Core.State;\nimport <windows.h>;\n\nnamespace Features::Preview {\n\n// 开始捕获并显示预览\nexport auto start_preview(Core::State::AppState& state, HWND target_window)\n    -> std::expected<void, std::string>;\n\n// 停止预览并隐藏窗口\nexport auto stop_preview(Core::State::AppState& state) -> void;\n\n// DPI 处理\nexport auto update_preview_dpi(Core::State::AppState& state, UINT new_dpi) -> void;\n\n// 清理资源\nexport auto cleanup_preview(Core::State::AppState& state) -> void;\n\n}  // namespace Features::Preview"
  },
  {
    "path": "src/features/preview/rendering.cpp",
    "content": "module;\n\n#include <wil/com.h>\n\nmodule Features.Preview.Rendering;\n\nimport std;\nimport Core.State;\nimport Utils.Graphics.D3D;\nimport Utils.Logger;\nimport Features.Preview.State;\nimport Features.Preview.Types;\nimport Features.Preview.Shaders;\nimport Features.Preview.Viewport;\nimport <d3d11.h>;\nimport <windows.h>;\n\nnamespace Features::Preview::Rendering {\n\nauto create_basic_vertex_buffer(ID3D11Device* device)\n    -> std::expected<wil::com_ptr<ID3D11Buffer>, std::string> {\n  // 创建全屏四边形的顶点数据\n  Features::Preview::Types::Vertex vertices[] = {\n      {-1.0f, 1.0f, 0.0f, 0.0f},   // 左上\n      {1.0f, 1.0f, 1.0f, 0.0f},    // 右上\n      {-1.0f, -1.0f, 0.0f, 1.0f},  // 左下\n      {1.0f, -1.0f, 1.0f, 1.0f}    // 右下\n  };\n\n  auto buffer_result = Utils::Graphics::D3D::create_vertex_buffer(\n      device, vertices, 4, sizeof(Features::Preview::Types::Vertex));\n\n  if (!buffer_result) {\n    return std::unexpected(\"Failed to create vertex buffer\");\n  }\n\n  return buffer_result.value();\n}\n\nauto initialize_rendering(Core::State::AppState& state, HWND hwnd, int width, int height)\n    -> std::expected<void, std::string> {\n  auto& resources = state.preview->rendering_resources;\n\n  // 创建D3D上下文\n  auto d3d_result = Utils::Graphics::D3D::create_d3d_context(hwnd, width, height);\n  if (!d3d_result) {\n    Logger().error(\"Failed to create D3D context for preview rendering: {}\", d3d_result.error());\n    return std::unexpected(d3d_result.error().find(\"device\") != std::string::npos\n                               ? \"Failed to initialize D3D device\"\n                               : \"Failed to create D3D resources\");\n  }\n  resources.d3d_context = std::move(d3d_result.value());\n\n  // 创建基本渲染着色器\n  auto basic_shader_result = Utils::Graphics::D3D::create_basic_shader_resources(\n      resources.d3d_context.device.get(), Features::Preview::Shaders::BASIC_VERTEX_SHADER,\n      Features::Preview::Shaders::BASIC_PIXEL_SHADER);\n\n  if (!basic_shader_result) {\n    Logger().error(\"Failed to create basic shader resources\");\n    return std::unexpected(\"Failed to compile basic shaders\");\n  }\n  resources.basic_shaders = std::move(basic_shader_result.value());\n\n  // 创建视口框着色器\n  auto viewport_shader_result = Utils::Graphics::D3D::create_viewport_shader_resources(\n      resources.d3d_context.device.get(), Features::Preview::Shaders::VIEWPORT_VERTEX_SHADER,\n      Features::Preview::Shaders::VIEWPORT_PIXEL_SHADER);\n\n  if (!viewport_shader_result) {\n    Logger().error(\"Failed to create viewport shader resources\");\n    return std::unexpected(\"Failed to compile viewport shaders\");\n  }\n  resources.viewport_shaders = std::move(viewport_shader_result.value());\n\n  // 创建基本顶点缓冲区（全屏四边形）\n  auto vertex_buffer_result = create_basic_vertex_buffer(resources.d3d_context.device.get());\n  if (!vertex_buffer_result) {\n    return std::unexpected(vertex_buffer_result.error());\n  }\n  resources.basic_vertex_buffer = std::move(vertex_buffer_result.value());\n\n  resources.initialized.store(true, std::memory_order_release);\n\n  Logger().info(\"Preview rendering system initialized successfully\");\n  return {};\n}\n\nauto cleanup_rendering(Core::State::AppState& state) -> void {\n  auto& resources = state.preview->rendering_resources;\n\n  resources.initialized.store(false, std::memory_order_release);\n  resources.resources_busy.store(true, std::memory_order_release);\n\n  // 清理着色器资源\n  Utils::Graphics::D3D::cleanup_shader_resources(resources.basic_shaders);\n  Utils::Graphics::D3D::cleanup_shader_resources(resources.viewport_shaders);\n\n  // 清理D3D上下文\n  Utils::Graphics::D3D::cleanup_d3d_context(resources.d3d_context);\n\n  // 重置资源\n  resources.capture_srv.reset();\n  resources.basic_vertex_buffer.reset();\n  resources.viewport_vertex_buffer.reset();\n\n  resources.resources_busy.store(false, std::memory_order_release);\n  Logger().info(\"Preview rendering resources cleaned up\");\n}\n\nauto resize_rendering(Core::State::AppState& state, int width, int height)\n    -> std::expected<void, std::string> {\n  if (!state.preview->rendering_resources.initialized.load(std::memory_order_acquire)) {\n    return std::unexpected(\"D3D not initialized\");\n  }\n\n  auto& resources = state.preview->rendering_resources;\n\n  resources.resources_busy.store(true, std::memory_order_release);\n\n  // 调整交换链大小\n  auto resize_result =\n      Utils::Graphics::D3D::resize_swap_chain(resources.d3d_context, width, height);\n\n  resources.resources_busy.store(false, std::memory_order_release);\n\n  if (!resize_result) {\n    Logger().error(\"Failed to resize swap chain\");\n    return std::unexpected(\"Failed to resize swap chain\");\n  }\n\n  Logger().debug(\"Preview rendering resized to {}x{}\", width, height);\n  return {};\n}\n\nauto update_capture_srv(Core::State::AppState& state, wil::com_ptr<ID3D11Texture2D> texture)\n    -> std::expected<void, std::string> {\n  if (!state.preview->rendering_resources.initialized.load(std::memory_order_acquire) || !texture) {\n    return std::unexpected(\"Invalid rendering resources or texture\");\n  }\n\n  auto& resources = state.preview->rendering_resources;\n\n  // 获取纹理描述\n  D3D11_TEXTURE2D_DESC desc;\n  texture->GetDesc(&desc);\n\n  // 创建着色器资源视图描述\n  D3D11_SHADER_RESOURCE_VIEW_DESC srvDesc = {};\n  srvDesc.Format = desc.Format;\n  srvDesc.ViewDimension = D3D11_SRV_DIMENSION_TEXTURE2D;\n  srvDesc.Texture2D.MostDetailedMip = 0;\n  srvDesc.Texture2D.MipLevels = 1;\n\n  // 创建新的SRV\n  HRESULT hr = resources.d3d_context.device->CreateShaderResourceView(texture.get(), &srvDesc,\n                                                                      resources.capture_srv.put());\n  if (FAILED(hr)) {\n    Logger().error(\"Failed to create shader resource view, HRESULT: 0x{:08X}\",\n                   static_cast<unsigned int>(hr));\n    return std::unexpected(\"Failed to create shader resource view\");\n  }\n\n  return {};\n}\n\nauto render_basic_quad(const Features::Preview::Types::RenderingResources& resources) -> void {\n  auto* context = resources.d3d_context.context.get();\n\n  // 设置着色器和资源\n  context->IASetInputLayout(resources.basic_shaders.input_layout.get());\n  context->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP);\n\n  UINT stride = sizeof(Features::Preview::Types::Vertex);\n  UINT offset = 0;\n  ID3D11Buffer* vertex_buffer = resources.basic_vertex_buffer.get();\n  context->IASetVertexBuffers(0, 1, &vertex_buffer, &stride, &offset);\n\n  context->VSSetShader(resources.basic_shaders.vertex_shader.get(), nullptr, 0);\n  context->PSSetShader(resources.basic_shaders.pixel_shader.get(), nullptr, 0);\n  ID3D11ShaderResourceView* srv = resources.capture_srv.get();\n  context->PSSetShaderResources(0, 1, &srv);\n  ID3D11SamplerState* sampler = resources.basic_shaders.sampler.get();\n  context->PSSetSamplers(0, 1, &sampler);\n\n  // 设置混合状态\n  float blendFactor[4] = {0.0f, 0.0f, 0.0f, 0.0f};\n  context->OMSetBlendState(resources.basic_shaders.blend_state.get(), blendFactor, 0xffffffff);\n\n  // 绘制\n  context->Draw(4, 0);\n}\n\nauto render_viewport_frame(Core::State::AppState& state,\n                           const Features::Preview::Types::RenderingResources& resources) -> void {\n  // 更新视口状态\n  Features::Preview::Viewport::update_viewport_rect(state);\n\n  // 渲染视口框\n  Features::Preview::Viewport::render_viewport_frame(\n      state, resources.d3d_context.context.get(), resources.viewport_shaders.vertex_shader,\n      resources.viewport_shaders.pixel_shader, resources.viewport_shaders.input_layout);\n}\n\nauto render_frame(Core::State::AppState& state, wil::com_ptr<ID3D11Texture2D> capture_texture)\n    -> void {\n  if (!state.preview->rendering_resources.initialized.load(std::memory_order_acquire)) {\n    return;\n  }\n\n  auto& resources = state.preview->rendering_resources;\n\n  // 检查渲染资源是否正忙，如果是则跳过渲染\n  if (resources.resources_busy.load(std::memory_order_acquire)) {\n    return;\n  }\n\n  auto* context = resources.d3d_context.context.get();\n\n  // 更新捕获SRV（如果需要）\n  if (state.preview->create_new_srv.load(std::memory_order_acquire) && capture_texture) {\n    if (auto srv_result = update_capture_srv(state, capture_texture); srv_result) {\n      state.preview->create_new_srv.store(false, std::memory_order_release);\n    }\n  }\n\n  if (!resources.capture_srv) {\n    return;  // 没有可渲染的内容\n  }\n\n  // 清除背景\n  float clearColor[4] = {0.0f, 0.0f, 0.0f, 0.0f};\n  context->ClearRenderTargetView(resources.d3d_context.render_target.get(), clearColor);\n\n  // 设置渲染目标\n  ID3D11RenderTargetView* views[] = {resources.d3d_context.render_target.get()};\n  context->OMSetRenderTargets(1, views, nullptr);\n\n  // 设置视口\n  D3D11_VIEWPORT viewport = {};\n  RECT clientRect;\n  GetClientRect(state.preview->hwnd, &clientRect);\n  viewport.Width = static_cast<float>(clientRect.right - clientRect.left);\n  viewport.Height = static_cast<float>(clientRect.bottom - clientRect.top);\n  viewport.TopLeftX = 0.0f;\n  viewport.TopLeftY = 0.0f;\n  viewport.MinDepth = 0.0f;\n  viewport.MaxDepth = 1.0f;\n  context->RSSetViewports(1, &viewport);\n\n  // 渲染基本四边形（游戏画面）\n  render_basic_quad(resources);\n\n  // 渲染视口框\n  render_viewport_frame(state, resources);\n\n  // 显示\n  resources.d3d_context.swap_chain->Present(0, 0);\n}\n\n}  // namespace Features::Preview::Rendering\n"
  },
  {
    "path": "src/features/preview/rendering.ixx",
    "content": "module;\n\n#include <wil/com.h>\n\nexport module Features.Preview.Rendering;\n\nimport std;\nimport Core.State;\nimport <d3d11.h>;\nimport <windows.h>;\n\nexport namespace Features::Preview::Rendering {\n\n// 初始化渲染系统\nauto initialize_rendering(Core::State::AppState& state, HWND hwnd, int width, int height)\n    -> std::expected<void, std::string>;\n\n// 清理渲染资源\nauto cleanup_rendering(Core::State::AppState& state) -> void;\n\n// 调整渲染尺寸\nauto resize_rendering(Core::State::AppState& state, int width, int height)\n    -> std::expected<void, std::string>;\n\n// 渲染一帧\nauto render_frame(Core::State::AppState& state, wil::com_ptr<ID3D11Texture2D> capture_texture)\n    -> void;\n\n}  // namespace Features::Preview::Rendering"
  },
  {
    "path": "src/features/preview/shaders.ixx",
    "content": "module;\r\n\r\nexport module Features.Preview.Shaders;\r\n\r\nimport std;\r\n\r\nexport namespace Features::Preview::Shaders {\r\n\r\n// 基本渲染着色器（用于显示捕获的游戏画面）\r\nconst std::string BASIC_VERTEX_SHADER = R\"(\r\nstruct VS_INPUT {\r\n    float2 pos : POSITION;\r\n    float2 tex : TEXCOORD;\r\n};\r\nstruct PS_INPUT {\r\n    float4 pos : SV_POSITION;\r\n    float2 tex : TEXCOORD;\r\n};\r\nPS_INPUT main(VS_INPUT input) {\r\n    PS_INPUT output;\r\n    output.pos = float4(input.pos, 0.0f, 1.0f);\r\n    output.tex = input.tex;\r\n    return output;\r\n}\r\n)\";\r\n\r\nconst std::string BASIC_PIXEL_SHADER = R\"(\r\nTexture2D tex : register(t0);\r\nSamplerState samp : register(s0);\r\nfloat4 main(float4 pos : SV_POSITION, float2 texCoord : TEXCOORD) : SV_Target {\r\n    return tex.Sample(samp, texCoord);\r\n}\r\n)\";\r\n\r\n// 视口框渲染着色器（用于显示当前可视区域框架）\r\nconst std::string VIEWPORT_VERTEX_SHADER = R\"(\r\nstruct VS_INPUT {\r\n    float2 pos : POSITION;\r\n    float4 color : COLOR;\r\n};\r\n\r\nstruct PS_INPUT {\r\n    float4 pos : SV_POSITION;\r\n    float4 color : COLOR;\r\n};\r\n\r\nPS_INPUT main(VS_INPUT input) {\r\n    PS_INPUT output;\r\n    output.pos = float4(input.pos.x * 2 - 1, -(input.pos.y * 2 - 1), 0, 1);\r\n    output.color = input.color;\r\n    return output;\r\n}\r\n)\";\r\n\r\nconst std::string VIEWPORT_PIXEL_SHADER = R\"(\r\nstruct PS_INPUT {\r\n    float4 pos : SV_POSITION;\r\n    float4 color : COLOR;\r\n};\r\n\r\nfloat4 main(PS_INPUT input) : SV_Target {\r\n    return input.color;\r\n}\r\n)\";\r\n\r\n}  // namespace Features::Preview::Shaders"
  },
  {
    "path": "src/features/preview/state.ixx",
    "content": "module;\n\nexport module Features.Preview.State;\n\nimport std;\nimport Features.Preview.Types;\nimport <windows.h>;\n\nexport namespace Features::Preview::State {\n\n// 预览窗口完整状态\nstruct PreviewState {\n  // 窗口句柄\n  HWND hwnd = nullptr;\n  HWND target_window = nullptr;\n\n  // 窗口状态\n  bool is_first_show = true;\n\n  // 尺寸相关\n  Features::Preview::Types::WindowSizeState size;\n  Features::Preview::Types::DpiDependentSizes dpi_sizes;\n\n  // 交互状态\n  Features::Preview::Types::InteractionState interaction;\n  Features::Preview::Types::ViewportState viewport;\n\n  // 渲染状态\n  std::atomic<bool> running = false;\n  std::atomic<bool> create_new_srv = true;\n  Features::Preview::Types::RenderingResources rendering_resources;\n\n  // 捕获状态\n  Features::Preview::Types::CaptureState capture_state;\n\n  // 游戏窗口缓存信息\n  RECT game_window_rect{};\n};\n\n}  // namespace Features::Preview::State\n"
  },
  {
    "path": "src/features/preview/types.ixx",
    "content": "module;\n\n#include <wil/com.h>\n\nexport module Features.Preview.Types;\n\nimport std;\nimport Utils.Throttle;\nimport Utils.Graphics.Capture;\nimport Utils.Graphics.D3D;\nimport <d3d11.h>;\nimport <windows.h>;\n\nexport namespace Features::Preview::Types {\n\n// 窗口类名\nconstexpr wchar_t PREVIEW_WINDOW_CLASS[] = L\"SpinningMomoPreviewWindowClass\";\n\n// 内部消息常量\nconstexpr UINT WM_SCHEDULE_PREVIEW_CLEANUP = WM_USER + 1;\nconstexpr UINT WM_CANCEL_PREVIEW_CLEANUP = WM_USER + 2;\nconstexpr UINT WM_IMMEDIATE_PREVIEW_CLEANUP = WM_USER + 3;\nconstexpr UINT WM_APPLY_CAPTURE_SIZE = WM_USER + 4;\n\n// 顶点结构体\nstruct Vertex {\n  float x, y;\n  float u, v;\n};\n\n// 视口框顶点结构体\nstruct ViewportVertex {\n  struct Position {\n    float x, y;\n  } pos;\n  struct Color {\n    float r, g, b, a;\n  } color;\n};\n\n// 视口状态\nstruct ViewportState {\n  RECT viewport_rect{};\n  bool visible = true;\n  bool game_window_fully_visible = false;\n};\n\n// 定时器 ID 常量\nconstexpr UINT_PTR TIMER_ID_TASKBAR_REDRAW = 1;\nconstexpr UINT_PTR TIMER_ID_PREVIEW_CLEANUP = 2;\n\n// 任务栏重绘延迟时间（毫秒）\nconstexpr UINT TASKBAR_REDRAW_DELAY_MS = 200;\n\n// 交互状态\nstruct InteractionState {\n  bool is_dragging = false;\n  bool viewport_dragging = false;\n  POINT drag_start{};\n  std::unique_ptr<Utils::Throttle::ThrottleState<float, float>> move_throttle;\n\n  // 上次设置的游戏窗口位置（用于跳过重复的 SetWindowPos 调用）\n  std::optional<POINT> last_game_window_pos;\n\n  // 任务栏重绘状态\n  bool taskbar_redraw_suppressed = false;\n};\n\n// DPI相关尺寸\nstruct DpiDependentSizes {\n  static constexpr int BASE_TITLE_HEIGHT = 24;\n  static constexpr int BASE_FONT_SIZE = 12;\n  static constexpr int BASE_BORDER_WIDTH = 8;\n  static constexpr int BASE_VIEWPORT_LINE_WIDTH = 3;  // 视口框线宽基准值 (3dp)\n\n  UINT dpi = 96;\n  int title_height = BASE_TITLE_HEIGHT;\n  int font_size = BASE_FONT_SIZE;\n  int border_width = BASE_BORDER_WIDTH;\n  int viewport_line_width = BASE_VIEWPORT_LINE_WIDTH;\n\n  auto update_dpi_scaling(UINT new_dpi) -> void {\n    dpi = new_dpi;\n    const double scale = static_cast<double>(new_dpi) / 96.0;\n    title_height = static_cast<int>(BASE_TITLE_HEIGHT * scale);\n    font_size = static_cast<int>(BASE_FONT_SIZE * scale);\n    border_width = static_cast<int>(BASE_BORDER_WIDTH * scale);\n    viewport_line_width = static_cast<int>(BASE_VIEWPORT_LINE_WIDTH * scale);\n  }\n};\n\n// 窗口尺寸状态\nstruct WindowSizeState {\n  int window_width = 0;\n  int window_height = 0;\n  float aspect_ratio = 1.0f;\n  int ideal_size = 0;\n  int min_ideal_size = 0;\n  int max_ideal_size = 0;\n};\n\n// 渲染相关资源\nstruct RenderingResources {\n  std::atomic<bool> initialized = false;\n  std::atomic<bool> resources_busy = false;  // 标记渲染资源是否正忙（如尺寸调整等）\n  Utils::Graphics::D3D::D3DContext d3d_context;\n  Utils::Graphics::D3D::ShaderResources basic_shaders;\n  Utils::Graphics::D3D::ShaderResources viewport_shaders;\n  wil::com_ptr<ID3D11Buffer> basic_vertex_buffer;\n  wil::com_ptr<ID3D11Buffer> viewport_vertex_buffer;\n  wil::com_ptr<ID3D11ShaderResourceView> capture_srv;\n};\n\n// 捕获会话（业务层封装）\nstruct CaptureState {\n  Utils::Graphics::Capture::CaptureSession session;\n  int last_frame_width = 0;\n  int last_frame_height = 0;\n};\n\n}  // namespace Features::Preview::Types\n"
  },
  {
    "path": "src/features/preview/usecase.cpp",
    "content": "module;\n\nmodule Features.Preview.UseCase;\n\nimport std;\nimport Core.State;\nimport Core.I18n.State;\nimport Features.Preview;\nimport Features.Overlay;\nimport Features.Overlay.State;\nimport Features.Letterbox;\nimport Features.Letterbox.State;\nimport Features.Settings.State;\nimport Features.WindowControl;\nimport Features.Notifications;\nimport Features.Preview.State;\nimport Utils.Logger;\nimport Utils.String;\n\nnamespace Features::Preview::UseCase {\n\n// 切换预览功能\nauto toggle_preview(Core::State::AppState& state) -> void {\n  bool is_running = state.preview && state.preview->running.load(std::memory_order_acquire);\n\n  if (!is_running) {\n    // 启动预览\n    // 预览窗与叠加层互斥：以 overlay->enabled（与浮动窗菜单勾选）为准\n    if (state.overlay->enabled) {\n      state.overlay->enabled = false;\n      if (state.overlay->running.load(std::memory_order_acquire)) {\n        Features::Overlay::stop_overlay(state);\n      }\n      if (state.letterbox->enabled) {\n        std::wstring lb_window_title =\n            Utils::String::FromUtf8(state.settings->raw.window.target_title);\n        auto lb_target_window = Features::WindowControl::find_target_window(lb_window_title);\n        if (lb_target_window) {\n          if (auto lb_result = Features::Letterbox::show(state, lb_target_window.value());\n              !lb_result) {\n            Logger().error(\"Failed to show letterbox: {}\", lb_result.error());\n          }\n        }\n      }\n      Features::Notifications::show_notification(\n          state, state.i18n->texts[\"label.app_name\"],\n          state.i18n->texts[\"message.preview_overlay_conflict\"]);\n    }\n\n    std::wstring window_title = Utils::String::FromUtf8(state.settings->raw.window.target_title);\n    auto target_window = Features::WindowControl::find_target_window(window_title);\n\n    if (target_window) {\n      if (auto result = Features::Preview::start_preview(state, target_window.value()); !result) {\n        Logger().error(\"Failed to start preview: {}\", result.error());\n        // 使用新的消息定义并附加错误详情\n        std::string error_message =\n            state.i18n->texts[\"message.preview_start_failed\"] + result.error();\n        Features::Notifications::show_notification(state, state.i18n->texts[\"label.app_name\"],\n                                                   error_message);\n      }\n    } else {\n      Logger().warn(\"No target window found for preview\");\n      Features::Notifications::show_notification(state, state.i18n->texts[\"label.app_name\"],\n                                                 state.i18n->texts[\"message.window_not_found\"]);\n    }\n  } else {\n    // 停止预览\n    Features::Preview::stop_preview(state);\n  }\n}\n\n}  // namespace Features::Preview::UseCase\n"
  },
  {
    "path": "src/features/preview/usecase.ixx",
    "content": "module;\n\nexport module Features.Preview.UseCase;\n\nimport Core.State;\nimport UI.FloatingWindow.Events;\n\nnamespace Features::Preview::UseCase {\n\n// 切换预览功能\nexport auto toggle_preview(Core::State::AppState& state) -> void;\n\n}  // namespace Features::Preview::UseCase\n"
  },
  {
    "path": "src/features/preview/viewport.cpp",
    "content": "module;\n\n#include <wil/com.h>\n\nmodule Features.Preview.Viewport;\n\nimport std;\nimport Core.State;\nimport Utils.Graphics.D3D;\nimport Features.Preview.State;\nimport Features.Preview.Types;\nimport Features.Preview.Rendering;\nimport Utils.Logger;\nimport <d3d11.h>;\nimport <windows.h>;\n\nnamespace Features::Preview::Viewport {\n\nauto get_game_window_screen_rect(const Core::State::AppState& state) -> RECT {\n  RECT rect = {0, 0, 0, 0};\n\n  if (state.preview->target_window && IsWindow(state.preview->target_window)) {\n    GetWindowRect(state.preview->target_window, &rect);\n  }\n\n  return rect;\n}\n\nauto calculate_visible_game_area(const Core::State::AppState& state) -> RECT {\n  // 获取屏幕可见区域（即屏幕边界）\n  RECT screenRect = {0, 0, GetSystemMetrics(SM_CXSCREEN), GetSystemMetrics(SM_CYSCREEN)};\n  RECT gameRect = get_game_window_screen_rect(state);\n\n  // 计算游戏窗口与屏幕的交集（可见部分）\n  RECT visibleRect;\n  if (!IntersectRect(&visibleRect, &gameRect, &screenRect)) {\n    // 如果没有交集，返回空矩形\n    visibleRect = {0, 0, 0, 0};\n  }\n\n  return visibleRect;\n}\n\nauto calculate_viewport_position(const Core::State::AppState& state) -> RECT {\n  RECT result = {0, 0, 0, 0};\n\n  if (!state.preview->hwnd || !state.preview->target_window) {\n    return result;\n  }\n\n  // 获取预览窗口客户区\n  RECT clientRect;\n  GetClientRect(state.preview->hwnd, &clientRect);\n\n  // 计算预览区域\n  int previewTop = state.preview->dpi_sizes.title_height;\n  int previewWidth = clientRect.right - clientRect.left;\n  int previewHeight = clientRect.bottom - clientRect.top;\n\n  if (previewWidth <= 0 || previewHeight <= 0) {\n    return result;\n  }\n\n  // 获取可见区域相对游戏窗口的比例\n  RECT visibleArea = calculate_visible_game_area(state);\n  RECT gameRect = state.preview->game_window_rect;\n\n  float gameWidth = static_cast<float>(gameRect.right - gameRect.left);\n  float gameHeight = static_cast<float>(gameRect.bottom - gameRect.top);\n\n  if (gameWidth <= 0 || gameHeight <= 0) {\n    return result;\n  }\n\n  // 计算视口框在预览窗口中的像素位置\n  float relativeLeft = static_cast<float>(visibleArea.left - gameRect.left) / gameWidth;\n  float relativeTop = static_cast<float>(visibleArea.top - gameRect.top) / gameHeight;\n  float relativeRight = static_cast<float>(visibleArea.right - gameRect.left) / gameWidth;\n  float relativeBottom = static_cast<float>(visibleArea.bottom - gameRect.top) / gameHeight;\n\n  result.left = static_cast<LONG>(relativeLeft * previewWidth);\n  result.top = static_cast<LONG>(relativeTop * previewHeight) + previewTop;\n  result.right = static_cast<LONG>(relativeRight * previewWidth);\n  result.bottom = static_cast<LONG>(relativeBottom * previewHeight) + previewTop;\n\n  return result;\n}\n\nauto check_game_window_visibility(Core::State::AppState& state) -> bool {\n  if (!state.preview->target_window) {\n    return false;\n  }\n\n  RECT gameRect = get_game_window_screen_rect(state);\n\n  // 获取屏幕尺寸\n  int screenWidth = GetSystemMetrics(SM_CXSCREEN);\n  int screenHeight = GetSystemMetrics(SM_CYSCREEN);\n\n  // 检查游戏窗口是否完全在屏幕内\n  return (gameRect.left >= 0 && gameRect.top >= 0 && gameRect.right <= screenWidth &&\n          gameRect.bottom <= screenHeight);\n}\n\nauto update_viewport_rect(Core::State::AppState& state) -> void {\n  if (!state.preview->target_window || !state.preview->hwnd) {\n    return;\n  }\n\n  // 更新游戏窗口位置信息\n  state.preview->game_window_rect = get_game_window_screen_rect(state);\n\n  // 检查游戏窗口是否完全可见\n  state.preview->viewport.game_window_fully_visible = check_game_window_visibility(state);\n\n  if (state.preview->viewport.game_window_fully_visible) {\n    // 如果游戏窗口完全可见，隐藏视口框\n    state.preview->viewport.visible = false;\n    return;\n  }\n\n  // 游戏窗口超出屏幕，显示视口框\n  state.preview->viewport.visible = true;\n  state.preview->viewport.viewport_rect = calculate_viewport_position(state);\n}\n\nauto create_viewport_vertices(const Core::State::AppState& state,\n                              std::vector<Features::Preview::Types::ViewportVertex>& vertices)\n    -> void {\n  vertices.clear();\n\n  if (!state.preview->viewport.visible) {\n    return;\n  }\n\n  // 获取预览窗口客户区大小\n  RECT clientRect;\n  GetClientRect(state.preview->hwnd, &clientRect);\n  float previewWidth = static_cast<float>(clientRect.right - clientRect.left);\n  float previewHeight = static_cast<float>(clientRect.bottom - clientRect.top);\n\n  if (previewWidth <= 0 || previewHeight <= 0) {\n    return;\n  }\n\n  // 计算可见区域在预览窗口中的相对位置\n  RECT visibleArea = calculate_visible_game_area(state);\n  RECT gameRect = state.preview->game_window_rect;\n\n  float gameWidth = static_cast<float>(gameRect.right - gameRect.left);\n  float gameHeight = static_cast<float>(gameRect.bottom - gameRect.top);\n\n  if (gameWidth <= 0 || gameHeight <= 0) {\n    return;\n  }\n\n  // 计算视口框在预览窗口中的归一化坐标 (0-1)\n  float viewportLeft = static_cast<float>(visibleArea.left - gameRect.left) / gameWidth;\n  float viewportTop = static_cast<float>(visibleArea.top - gameRect.top) / gameHeight;\n  float viewportRight = static_cast<float>(visibleArea.right - gameRect.left) / gameWidth;\n  float viewportBottom = static_cast<float>(visibleArea.bottom - gameRect.top) / gameHeight;\n\n  // 限制在0-1范围内\n  viewportLeft = std::clamp(viewportLeft, 0.0f, 1.0f);\n  viewportTop = std::clamp(viewportTop, 0.0f, 1.0f);\n  viewportRight = std::clamp(viewportRight, 0.0f, 1.0f);\n  viewportBottom = std::clamp(viewportBottom, 0.0f, 1.0f);\n\n  // 获取 DPI 缩放后的线宽，并转换为归一化坐标\n  float lineWidthPx = static_cast<float>(state.preview->dpi_sizes.viewport_line_width);\n  float halfThicknessX = (lineWidthPx / 2.0f) / previewWidth;\n  float halfThicknessY = (lineWidthPx / 2.0f) / previewHeight;\n\n  // 视口框颜色 RGBA(255, 160, 80, 0.8)\n  Features::Preview::Types::ViewportVertex::Color frameColor = {255.0f / 255.0f, 160.0f / 255.0f,\n                                                                80.0f / 255.0f, 0.8f};\n\n  // 创建矩形框顶点（4条边，每条边6个顶点 = 24个顶点）\n  vertices.reserve(24);\n\n  // 辅助 lambda：添加一个矩形（2个三角形，6个顶点）\n  auto add_rect = [&](float x1, float y1, float x2, float y2, float x3, float y3, float x4,\n                      float y4) {\n    // 三角形 1: (x1,y1), (x2,y2), (x3,y3)\n    vertices.push_back({{x1, y1}, frameColor});\n    vertices.push_back({{x2, y2}, frameColor});\n    vertices.push_back({{x3, y3}, frameColor});\n    // 三角形 2: (x3,y3), (x4,y4), (x1,y1)\n    vertices.push_back({{x3, y3}, frameColor});\n    vertices.push_back({{x4, y4}, frameColor});\n    vertices.push_back({{x1, y1}, frameColor});\n  };\n\n  // 上边（水平矩形）\n  add_rect(viewportLeft - halfThicknessX, viewportTop - halfThicknessY,   // 左上\n           viewportRight + halfThicknessX, viewportTop - halfThicknessY,  // 右上\n           viewportRight + halfThicknessX, viewportTop + halfThicknessY,  // 右下\n           viewportLeft - halfThicknessX, viewportTop + halfThicknessY);  // 左下\n\n  // 下边（水平矩形）\n  add_rect(viewportLeft - halfThicknessX, viewportBottom - halfThicknessY,   // 左上\n           viewportRight + halfThicknessX, viewportBottom - halfThicknessY,  // 右上\n           viewportRight + halfThicknessX, viewportBottom + halfThicknessY,  // 右下\n           viewportLeft - halfThicknessX, viewportBottom + halfThicknessY);  // 左下\n\n  // 左边（垂直矩形，避免与上下边重叠）\n  add_rect(viewportLeft - halfThicknessX, viewportTop + halfThicknessY,      // 左上\n           viewportLeft + halfThicknessX, viewportTop + halfThicknessY,      // 右上\n           viewportLeft + halfThicknessX, viewportBottom - halfThicknessY,   // 右下\n           viewportLeft - halfThicknessX, viewportBottom - halfThicknessY);  // 左下\n\n  // 右边（垂直矩形，避免与上下边重叠）\n  add_rect(viewportRight - halfThicknessX, viewportTop + halfThicknessY,      // 左上\n           viewportRight + halfThicknessX, viewportTop + halfThicknessY,      // 右上\n           viewportRight + halfThicknessX, viewportBottom - halfThicknessY,   // 右下\n           viewportRight - halfThicknessX, viewportBottom - halfThicknessY);  // 左下\n}\n\nauto render_viewport_frame(Core::State::AppState& state, ID3D11DeviceContext* context,\n                           const wil::com_ptr<ID3D11VertexShader>& vertex_shader,\n                           const wil::com_ptr<ID3D11PixelShader>& pixel_shader,\n                           const wil::com_ptr<ID3D11InputLayout>& input_layout) -> void {\n  if (!state.preview->viewport.visible || !context) {\n    return;\n  }\n\n  // 创建视口框顶点数据\n  std::vector<Features::Preview::Types::ViewportVertex> vertices;\n  create_viewport_vertices(state, vertices);\n\n  if (vertices.empty()) {\n    return;\n  }\n\n  // 获取渲染资源\n  auto& rendering_resources = state.preview->rendering_resources;\n  if (!rendering_resources.initialized.load(std::memory_order_acquire)) {\n    Logger().error(\"Rendering resources not initialized\");\n    return;\n  }\n\n  // 创建动态顶点缓冲区\n  auto buffer_result = Utils::Graphics::D3D::create_vertex_buffer(\n      rendering_resources.d3d_context.device.get(), vertices.data(), vertices.size(),\n      sizeof(Features::Preview::Types::ViewportVertex),\n      true);  // 动态缓冲区\n\n  if (!buffer_result) {\n    Logger().error(\"Failed to create viewport vertex buffer\");\n    return;\n  }\n\n  auto viewport_buffer = buffer_result.value();\n\n  // 设置渲染状态\n  context->IASetInputLayout(input_layout.get());\n  context->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST);\n\n  UINT stride = sizeof(Features::Preview::Types::ViewportVertex);\n  UINT offset = 0;\n  ID3D11Buffer* buffer = viewport_buffer.get();\n  context->IASetVertexBuffers(0, 1, &buffer, &stride, &offset);\n\n  context->VSSetShader(vertex_shader.get(), nullptr, 0);\n  context->PSSetShader(pixel_shader.get(), nullptr, 0);\n\n  // 绘制视口框矩形\n  context->Draw(static_cast<UINT>(vertices.size()), 0);\n}\n\n}  // namespace Features::Preview::Viewport\n"
  },
  {
    "path": "src/features/preview/viewport.ixx",
    "content": "module;\n\n#include <wil/com.h>\n\nexport module Features.Preview.Viewport;\n\nimport std;\nimport Core.State;\nimport <d3d11.h>;\n\nexport namespace Features::Preview::Viewport {\n\n// 更新视口矩形状态\nauto update_viewport_rect(Core::State::AppState& state) -> void;\n\n// 渲染视口框到屏幕\nauto render_viewport_frame(Core::State::AppState& state, ID3D11DeviceContext* context,\n                           const wil::com_ptr<ID3D11VertexShader>& vertex_shader,\n                           const wil::com_ptr<ID3D11PixelShader>& pixel_shader,\n                           const wil::com_ptr<ID3D11InputLayout>& input_layout) -> void;\n\n}  // namespace Features::Preview::Viewport"
  },
  {
    "path": "src/features/preview/window.cpp",
    "content": "module;\n\nmodule Features.Preview.Window;\n\nimport std;\nimport Core.State;\nimport Core.State.RuntimeInfo;\nimport Features.Preview.State;\nimport Features.Preview.Types;\nimport Features.Preview.Interaction;\nimport Features.Preview.Rendering;\nimport Features.Preview.Capture;\nimport Utils.Graphics.D3D;\nimport Utils.Graphics.Capture;\nimport Utils.Logger;\nimport <dwmapi.h>;\nimport <windows.h>;\nimport <windowsx.h>;\n\nnamespace Features::Preview::Window {\n\n// 窗口过程 - 使用GWLP_USERDATA模式获取状态\nLRESULT CALLBACK preview_window_proc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam) {\n  Core::State::AppState* state = nullptr;\n\n  if (message == WM_NCCREATE) {\n    const auto* cs = reinterpret_cast<CREATESTRUCT*>(lParam);\n    state = reinterpret_cast<Core::State::AppState*>(cs->lpCreateParams);\n    SetWindowLongPtr(hwnd, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(state));\n  } else {\n    state = reinterpret_cast<Core::State::AppState*>(GetWindowLongPtr(hwnd, GWLP_USERDATA));\n  }\n\n  if (!state) {\n    return DefWindowProcW(hwnd, message, wParam, lParam);\n  }\n\n  // 使用交互模块处理消息\n  auto [handled, lresult] =\n      Features::Preview::Interaction::handle_preview_message(*state, hwnd, message, wParam, lParam);\n\n  if (handled) {\n    return lresult;\n  }\n\n  return DefWindowProcW(hwnd, message, wParam, lParam);\n}\n\nauto register_preview_window_class(HINSTANCE instance) -> bool {\n  WNDCLASSEXW wc = {};\n  wc.cbSize = sizeof(WNDCLASSEXW);\n  wc.lpfnWndProc = preview_window_proc;\n  wc.hInstance = instance;\n  wc.lpszClassName = Features::Preview::Types::PREVIEW_WINDOW_CLASS;\n  wc.hCursor = LoadCursor(nullptr, IDC_ARROW);\n  wc.hbrBackground = (HBRUSH)(COLOR_WINDOW + 1);\n  wc.style = CS_HREDRAW | CS_VREDRAW;\n\n  return RegisterClassExW(&wc) != 0;\n}\n\nauto create_preview_window(HINSTANCE instance, int width, int height, Core::State::AppState* state)\n    -> HWND {\n  return CreateWindowExW(WS_EX_TOOLWINDOW | WS_EX_TOPMOST | WS_EX_LAYERED,\n                         Features::Preview::Types::PREVIEW_WINDOW_CLASS, L\"PreviewWindow\", WS_POPUP,\n                         0, 0, width, height, nullptr, nullptr, instance, state);\n}\n\nauto setup_window_appearance(HWND hwnd) -> void {\n  // 设置透明度\n  SetLayeredWindowAttributes(hwnd, 0, 255, LWA_ALPHA);\n\n  // 设置DWM属性\n  MARGINS margins = {1, 1, 1, 1};\n  DwmExtendFrameIntoClientArea(hwnd, &margins);\n\n  DWMNCRENDERINGPOLICY policy = DWMNCRP_ENABLED;\n  DwmSetWindowAttribute(hwnd, DWMWA_NCRENDERING_POLICY, &policy, sizeof(policy));\n\n  BOOL value = TRUE;\n  DwmSetWindowAttribute(hwnd, DWMWA_ALLOW_NCPAINT, &value, sizeof(value));\n\n  // Windows 11 圆角\n  DWM_WINDOW_CORNER_PREFERENCE corner = DWMWCP_ROUNDSMALL;\n  DwmSetWindowAttribute(hwnd, DWMWA_WINDOW_CORNER_PREFERENCE, &corner, sizeof(corner));\n}\n\nauto set_preview_window_size(Features::Preview::State::PreviewState& state, int capture_width,\n                             int capture_height) -> void {\n  state.size.aspect_ratio = static_cast<float>(capture_height) / capture_width;\n\n  if (state.size.aspect_ratio >= 1.0f) {\n    // 高度大于等于宽度\n    state.size.window_height = state.size.ideal_size;\n    state.size.window_width = static_cast<int>(state.size.window_height / state.size.aspect_ratio);\n  } else {\n    // 宽度大于高度\n    state.size.window_width = state.size.ideal_size;\n    state.size.window_height = static_cast<int>(state.size.window_width * state.size.aspect_ratio);\n  }\n\n  if (state.is_first_show) {\n    state.is_first_show = false;\n    SetWindowPos(state.hwnd, nullptr, 20, 20, state.size.window_width, state.size.window_height,\n                 SWP_NOZORDER | SWP_NOACTIVATE);\n  } else {\n    SetWindowPos(state.hwnd, nullptr, 0, 0, state.size.window_width, state.size.window_height,\n                 SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE);\n  }\n}\n\nauto create_window(HINSTANCE instance, Core::State::AppState& state)\n    -> std::expected<HWND, std::string> {\n  // 1. 注册窗口类\n  if (!register_preview_window_class(instance)) {\n    return std::unexpected(\"Failed to register preview window class\");\n  }\n\n  int screenHeight = GetSystemMetrics(SM_CYSCREEN);\n  int idealSize = static_cast<int>(screenHeight * 0.5);\n\n  // 2. 创建窗口\n  HWND hwnd = create_preview_window(instance, idealSize, idealSize + 24, &state);\n  if (!hwnd) {\n    return std::unexpected(\"Failed to create preview window\");\n  }\n\n  // 3. 设置窗口外观\n  setup_window_appearance(hwnd);\n  return hwnd;\n}\n\nauto initialize_preview_window(Core::State::AppState& state, HINSTANCE instance)\n    -> std::expected<void, std::string> {\n  // 创建窗口，传递状态引用\n  auto window_result = create_window(instance, state);\n  if (!window_result) {\n    return std::unexpected(window_result.error());\n  }\n\n  // 初始化预览状态\n  state.preview->hwnd = window_result.value();\n  state.preview->is_first_show = true;\n\n  // 初始化DPI\n  HDC hdc = GetDC(nullptr);\n  UINT dpi = GetDeviceCaps(hdc, LOGPIXELSX);\n  ReleaseDC(nullptr, hdc);\n  state.preview->dpi_sizes.update_dpi_scaling(dpi);\n\n  // 计算理想尺寸范围\n  int screenWidth = GetSystemMetrics(SM_CXSCREEN);\n  int screenHeight = GetSystemMetrics(SM_CYSCREEN);\n  state.preview->size.min_ideal_size = std::min(screenWidth, screenHeight) / 10;\n  state.preview->size.max_ideal_size = std::max(screenWidth, screenHeight);\n  state.preview->size.ideal_size = screenHeight / 2;\n\n  return {};\n}\n\nauto show_preview_window(Core::State::AppState& state) -> void {\n  if (state.preview->hwnd) {\n    ShowWindow(state.preview->hwnd, SW_SHOW);\n  }\n}\n\nauto hide_preview_window(Core::State::AppState& state) -> void {\n  if (state.preview->hwnd) {\n    ShowWindow(state.preview->hwnd, SW_HIDE);\n  }\n}\n\nauto update_preview_window_dpi(Core::State::AppState& state, UINT new_dpi) -> void {\n  if (state.preview->hwnd) {\n    // 获取当前窗口位置和大小\n    RECT rect;\n    GetWindowRect(state.preview->hwnd, &rect);\n    int width = rect.right - rect.left;\n    int height = rect.bottom - rect.top;\n\n    // 更新窗口\n    SetWindowPos(state.preview->hwnd, nullptr, 0, 0, width, height,\n                 SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE);\n\n    // 强制重绘\n    InvalidateRect(state.preview->hwnd, nullptr, TRUE);\n  }\n}\n\nauto destroy_preview_window(Core::State::AppState& state) -> void {\n  if (state.preview->hwnd) {\n    DestroyWindow(state.preview->hwnd);\n    state.preview->hwnd = nullptr;\n  }\n}\n\n}  // namespace Features::Preview::Window"
  },
  {
    "path": "src/features/preview/window.ixx",
    "content": "module;\n\nexport module Features.Preview.Window;\n\nimport std;\nimport Core.State;\nimport Features.Preview.State;\nimport <windows.h>;\n\nnamespace Features::Preview::Window {\n\n// 显示窗口\nexport auto show_preview_window(Core::State::AppState& state) -> void;\n\n// 隐藏窗口\nexport auto hide_preview_window(Core::State::AppState& state) -> void;\n\n// 更新DPI\nexport auto update_preview_window_dpi(Core::State::AppState& state, UINT new_dpi) -> void;\n\n// 销毁窗口\nexport auto destroy_preview_window(Core::State::AppState& state) -> void;\n\n// 计算窗口尺寸\nexport auto set_preview_window_size(Features::Preview::State::PreviewState& state,\n                                    int capture_width, int capture_height) -> void;\n\n// 初始化预览窗口系统\nexport auto initialize_preview_window(Core::State::AppState& state, HINSTANCE instance)\n    -> std::expected<void, std::string>;\n\n}  // namespace Features::Preview::Window"
  },
  {
    "path": "src/features/recording/audio_capture.cpp",
    "content": "module;\n\nmodule Features.Recording.AudioCapture;\n\nimport std;\nimport Features.Recording.State;\nimport Features.Recording.Types;\nimport Utils.Media.AudioCapture;\nimport Utils.Logger;\nimport <mfapi.h>;\nimport <wil/com.h>;\nimport <windows.h>;\n\nnamespace Features::Recording::AudioCapture {\n\nauto initialize(Utils::Media::AudioCapture::AudioCaptureContext& ctx,\n                Utils::Media::AudioCapture::AudioSource source, std::uint32_t process_id)\n    -> std::expected<void, std::string> {\n  return Utils::Media::AudioCapture::initialize(ctx, source, process_id);\n}\n\nauto start_capture_thread(Features::Recording::State::RecordingState& state) -> void {\n  Utils::Media::AudioCapture::start_capture_thread(\n      state.audio,\n      // get_elapsed_100ns\n      [&state]() -> std::int64_t {\n        auto now = std::chrono::steady_clock::now();\n        auto elapsed = now - state.start_time;\n        return std::chrono::duration_cast<std::chrono::nanoseconds>(elapsed).count() / 100;\n      },\n      // is_active\n      [&state]() -> bool {\n        return state.status.load(std::memory_order_acquire) ==\n                   Features::Recording::Types::RecordingStatus::Recording &&\n               state.encoder.has_audio;\n      },\n      // on_packet: 在锁外创建 MF Sample，只在写入时加锁\n      [&state](const BYTE* data, UINT32 num_frames, UINT32 bytes_per_frame,\n               std::int64_t timestamp_100ns) {\n        auto& encoder = state.encoder;\n        DWORD buffer_size = num_frames * bytes_per_frame;\n        wil::com_ptr<IMFSample> sample;\n        wil::com_ptr<IMFMediaBuffer> buffer;\n\n        if (SUCCEEDED(MFCreateSample(sample.put())) &&\n            SUCCEEDED(MFCreateMemoryBuffer(buffer_size, buffer.put()))) {\n          BYTE* buffer_data = nullptr;\n          if (SUCCEEDED(buffer->Lock(&buffer_data, nullptr, nullptr))) {\n            std::memcpy(buffer_data, data, buffer_size);\n            buffer->Unlock();\n            buffer->SetCurrentLength(buffer_size);\n\n            sample->AddBuffer(buffer.get());\n            sample->SetSampleTime(timestamp_100ns);\n\n            std::lock_guard write_lock(state.encoder_write_mutex);\n            HRESULT hr = encoder.sink_writer->WriteSample(encoder.audio_stream_index, sample.get());\n            if (FAILED(hr)) {\n              Logger().error(\"Failed to write audio sample: {:08X}\", static_cast<uint32_t>(hr));\n            }\n          }\n        }\n      });\n}\n\nauto stop(Utils::Media::AudioCapture::AudioCaptureContext& ctx) -> void {\n  Utils::Media::AudioCapture::stop(ctx);\n}\n\nauto cleanup(Utils::Media::AudioCapture::AudioCaptureContext& ctx) -> void {\n  Utils::Media::AudioCapture::cleanup(ctx);\n}\n\n}  // namespace Features::Recording::AudioCapture\n"
  },
  {
    "path": "src/features/recording/audio_capture.ixx",
    "content": "module;\n\nexport module Features.Recording.AudioCapture;\n\nimport std;\nimport Features.Recording.State;\nimport Features.Recording.Types;\nimport Utils.Media.AudioCapture;\n\nexport namespace Features::Recording::AudioCapture {\n\n// 初始化音频捕获（委托给 Utils.Media.AudioCapture）\nauto initialize(Utils::Media::AudioCapture::AudioCaptureContext& ctx,\n                Utils::Media::AudioCapture::AudioSource source, std::uint32_t process_id)\n    -> std::expected<void, std::string>;\n\n// 启动音频捕获线程（为 Recording 创建回调）\nauto start_capture_thread(Features::Recording::State::RecordingState& state) -> void;\n\n// 停止音频捕获\nauto stop(Utils::Media::AudioCapture::AudioCaptureContext& ctx) -> void;\n\n// 清理音频资源\nauto cleanup(Utils::Media::AudioCapture::AudioCaptureContext& ctx) -> void;\n\n}  // namespace Features::Recording::AudioCapture\n"
  },
  {
    "path": "src/features/recording/recording.cpp",
    "content": "module;\n\n#include <winrt/Windows.Graphics.Capture.h>\n\nmodule Features.Recording;\n\nimport std;\nimport Core.State;\nimport Features.Recording.State;\nimport Features.Recording.AudioCapture;\nimport UI.FloatingWindow;\nimport Utils.Graphics.Capture;\nimport Utils.Graphics.CaptureRegion;\nimport Utils.Graphics.D3D;\nimport Utils.Media.Encoder;\nimport Utils.Media.Encoder.Types;\nimport Utils.Logger;\nimport Utils.String;\nimport <d3d11_4.h>;\nimport <dwmapi.h>;\nimport <mfapi.h>;\nimport <wil/com.h>;\nimport <windows.h>;\n\nnamespace Features::Recording {\n\nauto floor_to_even(int value) -> int { return (value / 2) * 2; }\nauto start(Core::State::AppState& app_state, Features::Recording::State::RecordingState& state,\n           HWND target_window, const Features::Recording::Types::RecordingConfig& config)\n    -> std::expected<void, std::string>;\nauto stop(Features::Recording::State::RecordingState& state) -> void;\n\n// 统一几何入口：\n// 无论录整窗还是客户区，都先把“源帧多大、最终编码多大、是否需要裁剪”\n// 收敛成同一份 CapturePlan，避免 start / frame callback / resize 三处各算各的。\nauto resolve_capture_plan(HWND target_window, bool capture_client_area, int frame_width,\n                          int frame_height)\n    -> std::expected<Features::Recording::State::CapturePlan, std::string> {\n  if (frame_width <= 0 || frame_height <= 0) {\n    return std::unexpected(\"Invalid frame size\");\n  }\n\n  Features::Recording::State::CapturePlan plan;\n  plan.source_width = frame_width;\n  plan.source_height = frame_height;\n\n  if (!capture_client_area) {\n    auto output_width = floor_to_even(frame_width);\n    auto output_height = floor_to_even(frame_height);\n    if (output_width <= 0 || output_height <= 0) {\n      return std::unexpected(\"Resolved full-window output size is invalid\");\n    }\n\n    plan.output_width = static_cast<std::uint32_t>(output_width);\n    plan.output_height = static_cast<std::uint32_t>(output_height);\n    plan.should_crop = output_width != frame_width || output_height != frame_height;\n    plan.region = {\n        .left = 0,\n        .top = 0,\n        .width = static_cast<UINT>(output_width),\n        .height = static_cast<UINT>(output_height),\n    };\n    return plan;\n  }\n\n  auto crop_region_result = Utils::Graphics::CaptureRegion::calculate_client_crop_region(\n      target_window, static_cast<UINT>(frame_width), static_cast<UINT>(frame_height));\n  if (!crop_region_result) {\n    return std::unexpected(\"Failed to calculate client crop region: \" + crop_region_result.error());\n  }\n\n  auto region = *crop_region_result;\n  auto output_width = floor_to_even(static_cast<int>(region.width));\n  auto output_height = floor_to_even(static_cast<int>(region.height));\n  if (output_width <= 0 || output_height <= 0) {\n    return std::unexpected(\"Resolved client-area output size is invalid\");\n  }\n\n  region.width = static_cast<UINT>(output_width);\n  region.height = static_cast<UINT>(output_height);\n\n  plan.output_width = static_cast<std::uint32_t>(output_width);\n  plan.output_height = static_cast<std::uint32_t>(output_height);\n  plan.should_crop = region.left != 0 || region.top != 0 || output_width != frame_width ||\n                     output_height != frame_height;\n  plan.region = region;\n  return plan;\n}\n\nauto calculate_frame_crop_plan(HWND target_window,\n                               const Features::Recording::Types::RecordingConfig& config,\n                               int frame_width, int frame_height)\n    -> std::expected<Features::Recording::State::CapturePlan, std::string> {\n  auto capture_plan_result =\n      resolve_capture_plan(target_window, config.capture_client_area, frame_width, frame_height);\n  if (!capture_plan_result) {\n    return std::unexpected(capture_plan_result.error());\n  }\n\n  if (capture_plan_result->output_width != config.width ||\n      capture_plan_result->output_height != config.height) {\n    return std::unexpected(std::format(\"Unexpected recording output size {}x{} (expected {}x{})\",\n                                       capture_plan_result->output_width,\n                                       capture_plan_result->output_height, config.width,\n                                       config.height));\n  }\n\n  return capture_plan_result;\n}\n\nauto build_working_output_path(const std::filesystem::path& final_output_path)\n    -> std::filesystem::path {\n  // 录制过程中先写入“无后缀”的中间文件，只有 finalize 成功后才补上 .mp4。\n  auto working_output_path = final_output_path;\n  working_output_path.replace_extension();\n  return working_output_path;\n}\n\nauto rename_working_output_to_final(const std::filesystem::path& working_output_path,\n                                    const std::filesystem::path& final_output_path)\n    -> std::expected<void, std::string> {\n  if (working_output_path.empty() || final_output_path.empty() ||\n      working_output_path == final_output_path) {\n    return {};\n  }\n\n  std::error_code ec;\n  if (std::filesystem::exists(final_output_path, ec)) {\n    if (ec) {\n      return std::unexpected(\"Failed to probe final output path: \" + ec.message());\n    }\n\n    std::filesystem::remove(final_output_path, ec);\n    if (ec) {\n      return std::unexpected(\"Failed to remove existing final output file: \" + ec.message());\n    }\n  }\n\n  std::filesystem::rename(working_output_path, final_output_path, ec);\n  if (ec) {\n    return std::unexpected(\"Failed to move finalized recording to destination: \" + ec.message());\n  }\n\n  return {};\n}\n\nauto clear_runtime_resources(Features::Recording::State::RecordingState& state) -> void {\n  state.capture_session = {};\n  state.encoder = {};\n  state.capture_plan = {};\n  state.working_output_path.clear();\n  state.last_encoded_texture = nullptr;\n  state.last_frame_width = 0;\n  state.last_frame_height = 0;\n  state.cropped_texture = nullptr;\n  state.device = nullptr;\n  state.context = nullptr;\n}\n\nauto clear_runtime_resources_after_failed_start(Features::Recording::State::RecordingState& state)\n    -> void {\n  auto working_output_path = state.working_output_path;\n  Utils::Graphics::Capture::cleanup_capture_session(state.capture_session);\n  state.encoder = {};\n  state.capture_plan = {};\n  state.working_output_path.clear();\n  state.last_encoded_texture = nullptr;\n  state.last_frame_width = 0;\n  state.last_frame_height = 0;\n  state.cropped_texture = nullptr;\n  state.device = nullptr;\n  state.context = nullptr;\n  Features::Recording::AudioCapture::cleanup(state.audio);\n\n  if (!working_output_path.empty()) {\n    std::error_code ec;\n    std::filesystem::remove(working_output_path, ec);\n  }\n}\n\nauto build_startup_capture_plan(HWND target_window, bool capture_client_area)\n    -> std::expected<Features::Recording::State::CapturePlan, std::string> {\n  // 启动阶段优先相信 WGC 自己报告的捕获尺寸，而不是窗口矩形的推算结果。\n  auto capture_size_result = Utils::Graphics::Capture::get_capture_item_size(target_window);\n  if (!capture_size_result) {\n    return std::unexpected(capture_size_result.error());\n  }\n\n  return resolve_capture_plan(target_window, capture_client_area, capture_size_result->first,\n                              capture_size_result->second);\n}\n\nauto build_timestamp_output_path(const std::filesystem::path& reference_output_path)\n    -> std::filesystem::path {\n  auto filename = Utils::String::FormatTimestamp(std::chrono::system_clock::now());\n  auto dot_pos = filename.rfind('.');\n  if (dot_pos != std::string::npos) {\n    filename = filename.substr(0, dot_pos) + \".mp4\";\n  } else {\n    filename += \".mp4\";\n  }\n  return reference_output_path.parent_path() / filename;\n}\n\nauto start_resize_restart_task(Core::State::AppState& app_state,\n                               Features::Recording::State::RecordingState& state) -> void {\n  bool expected = false;\n  if (!state.resize_restart_in_progress.compare_exchange_strong(expected, true,\n                                                                std::memory_order_acq_rel)) {\n    return;\n  }\n\n  if (state.resize_restart_thread.joinable() &&\n      state.resize_restart_thread.get_id() != std::this_thread::get_id()) {\n    state.resize_restart_thread.join();\n  }\n\n  state.resize_restart_thread = std::jthread([&app_state, &state](std::stop_token) {\n    auto finish_task = [&state]() {\n      state.resize_restart_in_progress.store(false, std::memory_order_release);\n    };\n\n    HRESULT hr = CoInitializeEx(nullptr, COINIT_MULTITHREADED);\n    const bool need_uninitialize = SUCCEEDED(hr);\n    if (FAILED(hr) && hr != RPC_E_CHANGED_MODE) {\n      Logger().warn(\"CoInitializeEx failed in resize restart task: {:08X}\",\n                    static_cast<uint32_t>(hr));\n    }\n\n    try {\n      std::lock_guard control_lock(state.control_mutex);\n\n      if (state.shutdown_requested.load(std::memory_order_acquire)) {\n        finish_task();\n        if (need_uninitialize) {\n          CoUninitialize();\n        }\n        return;\n      }\n\n      if (state.status.load(std::memory_order_acquire) !=\n          Features::Recording::Types::RecordingStatus::Recording) {\n        finish_task();\n        if (need_uninitialize) {\n          CoUninitialize();\n        }\n        return;\n      }\n\n      Features::Recording::Types::RecordingConfig restart_config;\n      HWND target_window = nullptr;\n      {\n        std::lock_guard resource_lock(state.resource_mutex);\n        restart_config = state.config;\n        target_window = state.target_window;\n      }\n\n      if (!target_window || !IsWindow(target_window)) {\n        Logger().error(\"Skip recording auto restart after resize: target window is invalid\");\n        finish_task();\n        if (need_uninitialize) {\n          CoUninitialize();\n        }\n        return;\n      }\n\n      restart_config.output_path = build_timestamp_output_path(restart_config.output_path);\n      Logger().info(\"Recording restarted with timestamp output after resize: {}\",\n                    restart_config.output_path.string());\n\n      stop(state);\n      auto restart_result = start(app_state, state, target_window, restart_config);\n      if (!restart_result) {\n        Logger().error(\"Failed to restart recording after resize: {}\", restart_result.error());\n      } else {\n        UI::FloatingWindow::request_repaint(app_state);\n      }\n\n      finish_task();\n      if (need_uninitialize) {\n        CoUninitialize();\n      }\n    } catch (const std::exception& e) {\n      Logger().error(\"Resize restart task exception: {}\", e.what());\n      finish_task();\n      if (need_uninitialize) {\n        CoUninitialize();\n      }\n    } catch (...) {\n      Logger().error(\"Resize restart task exception: unknown\");\n      finish_task();\n      if (need_uninitialize) {\n        CoUninitialize();\n      }\n    }\n  });\n}\n\nauto initialize(Features::Recording::State::RecordingState& state)\n    -> std::expected<void, std::string> {\n  // 可以在这里进行一些预初始化\n  if (FAILED(MFStartup(MF_VERSION))) {\n    return std::unexpected(\"Failed to initialize Media Foundation\");\n  }\n  return {};\n}\n\nauto on_frame_arrived(Core::State::AppState& app_state,\n                      Features::Recording::State::RecordingState& state,\n                      Utils::Graphics::Capture::Direct3D11CaptureFrame frame) -> void {\n  // 使用 atomic 读取状态，无需上锁\n  if (state.status.load(std::memory_order_acquire) !=\n      Features::Recording::Types::RecordingStatus::Recording) {\n    return;\n  }\n\n  auto content_size = frame.ContentSize();\n  if (content_size.Width > 0 && content_size.Height > 0) {\n    bool frame_size_changed = content_size.Width != state.last_frame_width ||\n                              content_size.Height != state.last_frame_height;\n\n    if (frame_size_changed) {\n      Utils::Graphics::Capture::recreate_frame_pool(state.capture_session, content_size.Width,\n                                                    content_size.Height);\n\n      // 这里先按“新窗口尺寸”直接重算新 plan；\n      // 如果新 plan 的输出尺寸变了，后面的逻辑会决定自动切段，\n      // 不能在这里先拿新尺寸去和当前旧录制段做硬校验。\n      auto capture_plan_result =\n          resolve_capture_plan(state.target_window, state.config.capture_client_area,\n                               content_size.Width, content_size.Height);\n      if (!capture_plan_result) {\n        Logger().error(\"Failed to resolve recording crop plan after resize: {}\",\n                       capture_plan_result.error());\n        return;\n      }\n\n      state.last_frame_width = content_size.Width;\n      state.last_frame_height = content_size.Height;\n\n      if (state.config.auto_restart_on_resize &&\n          (capture_plan_result->output_width != state.config.width ||\n           capture_plan_result->output_height != state.config.height)) {\n        // 输出尺寸真的变化了：当前编码器已经不再适配，切段重开新文件。\n        start_resize_restart_task(app_state, state);\n        return;\n      }\n\n      {\n        std::lock_guard resource_lock(state.resource_mutex);\n        if (state.status.load(std::memory_order_acquire) !=\n            Features::Recording::Types::RecordingStatus::Recording) {\n          return;\n        }\n\n        state.capture_plan = *capture_plan_result;\n      }\n    }\n  }\n\n  // 获取当前时间和帧的系统时间戳\n  auto now = std::chrono::steady_clock::now();\n  auto elapsed = now - state.start_time;\n  auto elapsed_100ns = std::chrono::duration_cast<std::chrono::nanoseconds>(elapsed).count() / 100;\n\n  // 计算理论上应该编码到第几帧\n  int64_t frame_duration_100ns = 10'000'000 / state.config.fps;  // 每帧的时长（100ns单位）\n  int64_t target_frame_index = elapsed_100ns / frame_duration_100ns;\n\n  // 获取当前帧纹理\n  auto texture =\n      Utils::Graphics::Capture::get_dxgi_interface_from_object<ID3D11Texture2D>(frame.Surface());\n  if (!texture) {\n    Logger().error(\"Failed to get texture from capture frame\");\n    return;\n  }\n\n  D3D11_TEXTURE2D_DESC source_desc{};\n  texture->GetDesc(&source_desc);\n  if (source_desc.Width == 0 || source_desc.Height == 0) {\n    Logger().error(\"Failed to resolve recording source texture size\");\n    return;\n  }\n\n  // 填充缺失的帧（使用上一帧或当前帧重复）\n  // 使用 resource_mutex 保护帧填充逻辑和 frame_index\n  std::lock_guard resource_lock(state.resource_mutex);\n\n  ID3D11Texture2D* current_texture = texture.get();\n  auto capture_plan = state.capture_plan;\n  if (capture_plan.output_width == 0 || capture_plan.output_height == 0) {\n    auto capture_plan_result = calculate_frame_crop_plan(state.target_window, state.config,\n                                                         static_cast<int>(source_desc.Width),\n                                                         static_cast<int>(source_desc.Height));\n    if (!capture_plan_result) {\n      Logger().error(\"Failed to resolve recording crop plan: {}\", capture_plan_result.error());\n      return;\n    }\n    state.capture_plan = *capture_plan_result;\n    capture_plan = *capture_plan_result;\n  }\n\n  if (capture_plan.source_width != static_cast<int>(source_desc.Width) ||\n      capture_plan.source_height != static_cast<int>(source_desc.Height)) {\n    auto capture_plan_result = calculate_frame_crop_plan(state.target_window, state.config,\n                                                         static_cast<int>(source_desc.Width),\n                                                         static_cast<int>(source_desc.Height));\n    if (!capture_plan_result) {\n      Logger().error(\"Failed to refresh recording crop plan: {}\", capture_plan_result.error());\n      return;\n    }\n    state.capture_plan = *capture_plan_result;\n    capture_plan = *capture_plan_result;\n  }\n\n  if (capture_plan.output_width == 0 || capture_plan.output_height == 0) {\n    Logger().error(\"Recording capture plan is invalid\");\n    return;\n  }\n\n  if (capture_plan.should_crop) {\n    auto crop_result = Utils::Graphics::CaptureRegion::crop_texture_to_region(\n        state.device.get(), state.context.get(), texture.get(), capture_plan.region,\n        state.cropped_texture);\n    if (!crop_result) {\n      Logger().error(\"Failed to crop recording frame: {}\", crop_result.error());\n      return;\n    }\n    current_texture = *crop_result;\n  }\n\n  D3D11_TEXTURE2D_DESC current_desc{};\n  current_texture->GetDesc(&current_desc);\n  if (current_desc.Width != capture_plan.output_width ||\n      current_desc.Height != capture_plan.output_height) {\n    Logger().error(\"Recording frame size mismatch after crop: got {}x{}, expected {}x{}\",\n                   current_desc.Width, current_desc.Height, capture_plan.output_width,\n                   capture_plan.output_height);\n    return;\n  }\n\n  while (state.frame_index <= target_frame_index) {\n    int64_t timestamp = state.frame_index * frame_duration_100ns;\n\n    // 选择要编码的纹理：如果有上一帧就用上一帧，否则用当前帧\n    ID3D11Texture2D* encode_texture =\n        (state.frame_index < target_frame_index && state.last_encoded_texture)\n            ? state.last_encoded_texture.get()\n            : current_texture;\n\n    // 编码帧\n    std::expected<void, std::string> result;\n    {\n      std::lock_guard write_lock(state.encoder_write_mutex);\n      result = Utils::Media::Encoder::encode_frame(state.encoder, state.context.get(),\n                                                   encode_texture, timestamp, state.config.fps);\n    }\n\n    if (!result) {\n      Logger().error(\"Failed to encode frame {}: {}\", state.frame_index, result.error());\n      // 编码失败时停止填充，避免连锁错误\n      break;\n    }\n\n    state.frame_index++;\n  }\n\n  // 缓存当前帧作为下一次填充的参考\n  // 首次调用时创建缓存纹理\n  if (!state.last_encoded_texture) {\n    D3D11_TEXTURE2D_DESC desc;\n    current_texture->GetDesc(&desc);\n    desc.BindFlags = 0;\n    desc.MiscFlags = 0;\n    desc.Usage = D3D11_USAGE_DEFAULT;\n    desc.CPUAccessFlags = 0;\n\n    if (FAILED(state.device->CreateTexture2D(&desc, nullptr, state.last_encoded_texture.put()))) {\n      Logger().error(\"Failed to create texture for frame caching\");\n      return;\n    }\n  }\n\n  // 每帧都更新缓存（WGC 帧池会复用纹理，指针比较不可靠）\n  state.context->CopyResource(state.last_encoded_texture.get(), current_texture);\n}\n\nauto start(Core::State::AppState& app_state, Features::Recording::State::RecordingState& state,\n           HWND target_window, const Features::Recording::Types::RecordingConfig& config)\n    -> std::expected<void, std::string> {\n  // 使用 resource_mutex 保护资源初始化\n  std::lock_guard resource_lock(state.resource_mutex);\n\n  // 原子检查状态\n  auto current_status = state.status.load(std::memory_order_acquire);\n  if (current_status != Features::Recording::Types::RecordingStatus::Idle) {\n    return std::unexpected(\"Recording is not idle\");\n  }\n\n  state.config = config;\n  state.target_window = target_window;\n  state.working_output_path = build_working_output_path(config.output_path);\n\n  // 1. 基于 WGC 实际源尺寸解析统一的捕获计划。\n  // 后面 encoder/session/frame callback 都围绕这份 plan 工作。\n  auto capture_plan_result = build_startup_capture_plan(target_window, config.capture_client_area);\n  if (!capture_plan_result) {\n    return std::unexpected(capture_plan_result.error());\n  }\n  const auto& capture_plan = *capture_plan_result;\n  int source_width = capture_plan.source_width;\n  int source_height = capture_plan.source_height;\n  int output_width = static_cast<int>(capture_plan.output_width);\n  int output_height = static_cast<int>(capture_plan.output_height);\n\n  state.config.width = output_width;\n  state.config.height = output_height;\n  state.capture_plan = capture_plan;\n  state.last_frame_width = source_width;\n  state.last_frame_height = source_height;\n  state.cropped_texture = nullptr;\n  state.last_encoded_texture = nullptr;\n\n  // 2. 创建 Headless D3D 设备\n  auto d3d_result = Utils::Graphics::D3D::create_headless_d3d_device();\n  if (!d3d_result) {\n    state.working_output_path.clear();\n    state.capture_plan = {};\n    return std::unexpected(\"Failed to create D3D device: \" + d3d_result.error());\n  }\n  state.device = d3d_result->first;\n  state.context = d3d_result->second;\n\n  // 2.5. 启用 D3D11 多线程保护 (对 GPU 编码很重要)\n  wil::com_ptr<ID3D11Multithread> multithread;\n  if (SUCCEEDED(state.device->QueryInterface(IID_PPV_ARGS(multithread.put())))) {\n    multithread->SetMultithreadProtected(TRUE);\n  }\n\n  // 3. 创建 WinRT 设备\n  auto winrt_device_result = Utils::Graphics::Capture::create_winrt_device(state.device.get());\n  if (!winrt_device_result) {\n    clear_runtime_resources_after_failed_start(state);\n    return std::unexpected(\"Failed to create WinRT device: \" + winrt_device_result.error());\n  }\n\n  // 4. 初始化音频捕获\n  WAVEFORMATEX* wave_format = nullptr;\n\n  // 获取目标窗口的进程 ID\n  DWORD process_id = 0;\n  GetWindowThreadProcessId(target_window, &process_id);\n\n  auto audio_result =\n      Features::Recording::AudioCapture::initialize(state.audio, config.audio_source, process_id);\n  if (!audio_result) {\n    Logger().warn(\"Audio capture initialization failed: {}, continuing without audio\",\n                  audio_result.error());\n  } else {\n    wave_format = state.audio.wave_format;\n    Logger().info(\"Audio capture initialized\");\n  }\n\n  // 5. 创建编码器（音频流在内部添加）。\n  // 注意这里写的是无后缀的中间路径，不是最终对外可见的 .mp4。\n  Utils::Media::Encoder::Types::EncoderConfig encoder_config;\n  encoder_config.output_path = state.working_output_path;\n  encoder_config.width = output_width;\n  encoder_config.height = output_height;\n  encoder_config.fps = config.fps;\n  encoder_config.bitrate = config.bitrate;\n  encoder_config.quality = config.quality;\n  encoder_config.qp = config.qp;\n  encoder_config.keyframe_interval = 2;  // 录制默认 2s 关键帧间隔\n  encoder_config.rate_control = Utils::Media::Encoder::Types::rate_control_mode_from_string(\n      Features::Recording::Types::rate_control_mode_to_string(config.rate_control));\n  encoder_config.encoder_mode = Utils::Media::Encoder::Types::encoder_mode_from_string(\n      Features::Recording::Types::encoder_mode_to_string(config.encoder_mode));\n  encoder_config.codec = Utils::Media::Encoder::Types::video_codec_from_string(\n      Features::Recording::Types::video_codec_to_string(config.codec));\n  encoder_config.audio_bitrate = config.audio_bitrate;\n\n  auto encoder_result =\n      Utils::Media::Encoder::create_encoder(encoder_config, state.device.get(), wave_format);\n  if (!encoder_result) {\n    clear_runtime_resources_after_failed_start(state);\n    return std::unexpected(\"Failed to create encoder: \" + encoder_result.error());\n  }\n  state.encoder = std::move(*encoder_result);\n\n  Utils::Graphics::Capture::CaptureSessionOptions capture_options;\n  capture_options.capture_cursor = config.capture_cursor;\n  capture_options.border_required = false;\n\n  // 6. 创建捕获会话（使用 2 帧缓冲以容忍编码延迟）\n  auto capture_result = Utils::Graphics::Capture::create_capture_session(\n      target_window, *winrt_device_result, source_width, source_height,\n      [&app_state, &state](auto frame) { on_frame_arrived(app_state, state, frame); }, 2,\n      capture_options);\n\n  if (!capture_result) {\n    clear_runtime_resources_after_failed_start(state);\n    return std::unexpected(\"Failed to create capture session: \" + capture_result.error());\n  }\n  state.capture_session = std::move(*capture_result);\n\n  // 7. 启动捕获\n  auto start_result = Utils::Graphics::Capture::start_capture(state.capture_session);\n  if (!start_result) {\n    clear_runtime_resources_after_failed_start(state);\n    return std::unexpected(\"Failed to start capture: \" + start_result.error());\n  }\n\n  // 8. 更新状态\n  state.start_time = std::chrono::steady_clock::now();\n  state.frame_index = 0;\n\n  // 9. 启动音频捕获线程（如果有音频）\n  if (state.encoder.has_audio) {\n    Features::Recording::AudioCapture::start_capture_thread(state);\n  }\n\n  // 10. 原子设置状态为 Recording（最后设置，确保所有资源就绪）\n  state.status.store(Features::Recording::Types::RecordingStatus::Recording,\n                     std::memory_order_release);\n\n  Logger().info(\"Recording started: {}\", config.output_path.string());\n  return {};\n}\n\nauto stop(Features::Recording::State::RecordingState& state) -> void {\n  // 阶段1: 原子检查并设置状态为 Stopping（无需锁）\n  auto expected = Features::Recording::Types::RecordingStatus::Recording;\n  if (!state.status.compare_exchange_strong(expected,\n                                            Features::Recording::Types::RecordingStatus::Stopping,\n                                            std::memory_order_acq_rel)) {\n    // 不是 Recording 状态，直接返回\n    return;\n  }\n\n  // 阶段2: 通知并等待线程退出（无需锁，避免死锁）\n  // 1. 停止音频捕获线程\n  if (state.encoder.has_audio) {\n    Features::Recording::AudioCapture::stop(state.audio);\n  }\n\n  // 2. 停止视频捕捉\n  Utils::Graphics::Capture::stop_capture(state.capture_session);\n\n  const auto final_output_path = state.config.output_path;\n  const auto working_output_path = state.working_output_path;\n  bool finalize_succeeded = false;\n\n  // 阶段3: 清理资源（使用 resource_mutex 保护）\n  {\n    std::lock_guard resource_lock(state.resource_mutex);\n\n    // 3. 填充最后的视频帧（确保录制时长完整）\n    if (state.last_encoded_texture) {\n      auto now = std::chrono::steady_clock::now();\n      auto elapsed = now - state.start_time;\n      auto elapsed_100ns =\n          std::chrono::duration_cast<std::chrono::nanoseconds>(elapsed).count() / 100;\n\n      int64_t frame_duration_100ns = 10'000'000 / state.config.fps;\n      int64_t final_frame_index = elapsed_100ns / frame_duration_100ns;\n\n      // 用最后一帧填充到结束\n      while (state.frame_index <= final_frame_index) {\n        int64_t timestamp = state.frame_index * frame_duration_100ns;\n        std::expected<void, std::string> result;\n        {\n          std::lock_guard write_lock(state.encoder_write_mutex);\n          result = Utils::Media::Encoder::encode_frame(state.encoder, state.context.get(),\n                                                       state.last_encoded_texture.get(), timestamp,\n                                                       state.config.fps);\n        }\n\n        if (!result) {\n          Logger().error(\"Failed to encode final frame {}: {}\", state.frame_index, result.error());\n          break;\n        }\n        state.frame_index++;\n      }\n\n      Logger().info(\"Filled {} total frames for recording duration\", state.frame_index);\n    }\n\n    // 4. 完成编码\n    auto finalize_result = Utils::Media::Encoder::finalize_encoder(state.encoder);\n    if (!finalize_result) {\n      Logger().error(\"Failed to finalize encoder: {}\", finalize_result.error());\n    } else {\n      finalize_succeeded = true;\n    }\n\n    // 5. 清理资源\n    clear_runtime_resources(state);\n\n    // 6. 清理音频资源\n    Features::Recording::AudioCapture::cleanup(state.audio);\n  }\n\n  if (finalize_succeeded) {\n    // 只有在容器收尾成功后，才把临时文件发布为最终成品。\n    auto rename_result = rename_working_output_to_final(working_output_path, final_output_path);\n    if (!rename_result) {\n      Logger().error(\"Failed to publish finalized recording '{}': {}\", final_output_path.string(),\n                     rename_result.error());\n    }\n  }\n\n  // 阶段4: 原子设置最终状态\n  state.status.store(Features::Recording::Types::RecordingStatus::Idle, std::memory_order_release);\n  Logger().info(\"Recording stopped\");\n}\n\nauto cleanup(Features::Recording::State::RecordingState& state) -> void {\n  stop(state);\n  MFShutdown();\n}\n\n}  // namespace Features::Recording\n"
  },
  {
    "path": "src/features/recording/recording.ixx",
    "content": "module;\n\nexport module Features.Recording;\n\nimport std;\nimport Core.State;\nimport Features.Recording.Types;\nimport Features.Recording.State;\nimport <windows.h>;\n\nexport namespace Features::Recording {\n\n// 初始化录制模块\nauto initialize(Features::Recording::State::RecordingState& state)\n    -> std::expected<void, std::string>;\n\n// 开始录制\nauto start(Core::State::AppState& app_state, Features::Recording::State::RecordingState& state,\n           HWND target_window, const Features::Recording::Types::RecordingConfig& config)\n    -> std::expected<void, std::string>;\n\n// 停止录制\nauto stop(Features::Recording::State::RecordingState& state) -> void;\n\n// 清理资源\nauto cleanup(Features::Recording::State::RecordingState& state) -> void;\n\n}  // namespace Features::Recording\n"
  },
  {
    "path": "src/features/recording/state.ixx",
    "content": "module;\n\nexport module Features.Recording.State;\n\nimport std;\nimport Features.Recording.Types;\nimport Utils.Graphics.Capture;\nimport Utils.Graphics.CaptureRegion;\nimport Utils.Media.AudioCapture;\nimport Utils.Media.Encoder.State;\nimport <d3d11.h>;\nimport <wil/com.h>;\nimport <windows.h>;\n\nexport namespace Features::Recording::State {\n\n// 录制几何计划：\n// source_* 表示 WGC 实际给到的源帧尺寸；\n// output_* 表示最终送给编码器的尺寸；\n// should_crop/region 描述是否需要先从源帧裁出一块再编码。\nstruct CapturePlan {\n  int source_width = 0;\n  int source_height = 0;\n  std::uint32_t output_width = 0;\n  std::uint32_t output_height = 0;\n  bool should_crop = false;\n  Utils::Graphics::CaptureRegion::CropRegion region{};\n};\n\n// 录制完整状态\nstruct RecordingState {\n  Features::Recording::Types::RecordingConfig config;\n  std::filesystem::path working_output_path;\n  CapturePlan capture_plan;\n\n  // 状态标志 - 使用 atomic 避免锁竞争\n  std::atomic<Features::Recording::Types::RecordingStatus> status{\n      Features::Recording::Types::RecordingStatus::Idle};\n  // 防止重复触发录制切换任务\n  std::atomic<bool> op_in_progress{false};\n  // 录制开关专用线程（仅执行开始/停止控制逻辑）\n  std::jthread toggle_thread;\n  // 窗口尺寸变化时的自动切段重启线程\n  std::jthread resize_restart_thread;\n  // 自动切段重启任务是否正在执行\n  std::atomic<bool> resize_restart_in_progress{false};\n  // shutdown 开始后置为 true，阻止新的 toggle / resize restart 再抢控制权\n  std::atomic<bool> shutdown_requested{false};\n\n  // D3D 资源 (Headless)\n  wil::com_ptr<ID3D11Device> device;\n  wil::com_ptr<ID3D11DeviceContext> context;\n\n  // WGC 捕获会话\n  Utils::Graphics::Capture::CaptureSession capture_session;\n  wil::com_ptr<ID3D11Texture2D> cropped_texture;\n\n  // 编码器（使用共享编码器模块）\n  Utils::Media::Encoder::State::EncoderContext encoder;\n\n  // 帧率控制\n  std::chrono::steady_clock::time_point start_time;\n  std::uint64_t frame_index = 0;\n  int last_frame_width = 0;\n  int last_frame_height = 0;\n\n  // 最后编码的帧纹理（用于帧重复填充）\n  wil::com_ptr<ID3D11Texture2D> last_encoded_texture;\n\n  // 目标窗口信息\n  HWND target_window = nullptr;\n\n  // 音频捕获（使用共享音频捕获模块）\n  Utils::Media::AudioCapture::AudioCaptureContext audio;\n\n  // 线程同步\n  // encoder_write_mutex: 保护 sink_writer 的写入操作（视频帧回调和音频线程共享）\n  std::mutex encoder_write_mutex;\n  // resource_mutex: 保护资源的初始化、清理和帧填充逻辑（主线程独占）\n  std::mutex resource_mutex;\n  // control_mutex: 串行化 start/stop/toggle/restart/shutdown 控制流\n  std::mutex control_mutex;\n};\n\n}  // namespace Features::Recording::State\n"
  },
  {
    "path": "src/features/recording/types.ixx",
    "content": "module;\n\nexport module Features.Recording.Types;\n\nimport std;\nimport Utils.Media.AudioCapture;\n\nnamespace Features::Recording::Types {\n\n// 码率控制模式\nexport enum class RateControlMode {\n  CBR,      // 固定码率 - 使用 bitrate\n  VBR,      // 质量优先 VBR - 使用 quality (0-100)\n  ManualQP  // 手动 QP 模式 - 使用 qp (0-51)\n};\n\n// 从字符串转换为 RateControlMode\nexport constexpr RateControlMode rate_control_mode_from_string(std::string_view str) {\n  if (str == \"vbr\") return RateControlMode::VBR;\n  if (str == \"manual_qp\") return RateControlMode::ManualQP;\n  return RateControlMode::CBR;  // 默认\n}\n\n// RateControlMode 转换为字符串\nexport constexpr std::string_view rate_control_mode_to_string(RateControlMode mode) {\n  switch (mode) {\n    case RateControlMode::VBR:\n      return \"vbr\";\n    case RateControlMode::ManualQP:\n      return \"manual_qp\";\n    default:\n      return \"cbr\";\n  }\n}\n\n// 编码器模式\nexport enum class EncoderMode {\n  Auto,  // 自动检测，优先 GPU\n  GPU,   // 强制 GPU（不可用则失败）\n  CPU    // 强制 CPU\n};\n\n// 从字符串转换为 EncoderMode\nexport constexpr EncoderMode encoder_mode_from_string(std::string_view str) {\n  if (str == \"gpu\") return EncoderMode::GPU;\n  if (str == \"cpu\") return EncoderMode::CPU;\n  return EncoderMode::Auto;  // 默认或 \"auto\"\n}\n\n// EncoderMode 转换为字符串\nexport constexpr std::string_view encoder_mode_to_string(EncoderMode mode) {\n  switch (mode) {\n    case EncoderMode::GPU:\n      return \"gpu\";\n    case EncoderMode::CPU:\n      return \"cpu\";\n    default:\n      return \"auto\";\n  }\n}\n\n// 视频编码格式\nexport enum class VideoCodec {\n  H264,  // H.264/AVC\n  H265   // H.265/HEVC\n};\n\n// 从字符串转换为 VideoCodec\nexport constexpr VideoCodec video_codec_from_string(std::string_view str) {\n  if (str == \"h265\" || str == \"hevc\") return VideoCodec::H265;\n  return VideoCodec::H264;  // 默认\n}\n\n// VideoCodec 转换为字符串\nexport constexpr std::string_view video_codec_to_string(VideoCodec codec) {\n  switch (codec) {\n    case VideoCodec::H265:\n      return \"h265\";\n    default:\n      return \"h264\";\n  }\n}\n\n// 录制配置\nexport struct RecordingConfig {\n  std::filesystem::path output_path;                    // 输出文件路径\n  std::uint32_t width = 0;                              // 视频宽度\n  std::uint32_t height = 0;                             // 视频高度\n  std::uint32_t fps = 30;                               // 帧率\n  std::uint32_t bitrate = 80'000'000;                   // 视频比特率 (默认 80Mbps, CBR 模式使用)\n  std::uint32_t quality = 70;                           // 质量值 (0-100, VBR 模式使用)\n  std::uint32_t qp = 23;                                // 量化参数 (0-51, ManualQP 模式使用)\n  RateControlMode rate_control = RateControlMode::CBR;  // 码率控制模式\n  EncoderMode encoder_mode = EncoderMode::Auto;         // 编码器模式\n  VideoCodec codec = VideoCodec::H264;                  // 视频编码格式 (默认 H.264)\n  bool capture_client_area = true;                      // 是否只捕获客户区（无边框）\n  bool capture_cursor = false;                          // 是否捕获鼠标指针\n  bool auto_restart_on_resize = true;                   // 尺寸变化时是否自动切段重启录制\n\n  // 音频配置\n  Utils::Media::AudioCapture::AudioSource audio_source =\n      Utils::Media::AudioCapture::AudioSource::System;  // 音频源类型 (默认系统音频)\n  std::uint32_t audio_bitrate = 256'000;                // 音频码率 (默认 256kbps)\n};\n\n// 录制状态枚举\nexport enum class RecordingStatus {\n  Idle,       // 空闲\n  Recording,  // 正在录制\n  Stopping,   // 正在停止\n  Error       // 发生错误\n};\n\n}  // namespace Features::Recording::Types\n"
  },
  {
    "path": "src/features/recording/usecase.cpp",
    "content": "module;\n\nmodule Features.Recording.UseCase;\n\nimport std;\nimport Core.Events;\nimport Core.State;\nimport Core.I18n.State;\nimport Features.Recording;\nimport Features.Recording.Types;\nimport Features.Recording.State;\nimport Features.Settings.Types;\nimport Features.Settings.State;\nimport Features.WindowControl;\nimport UI.FloatingWindow.Events;\nimport Utils.Logger;\nimport Utils.Media.AudioCapture;\nimport Utils.Path;\nimport Utils.String;\nimport <windows.h>;\n\nnamespace Features::Recording::UseCase {\n\nauto join_resize_restart_thread(Features::Recording::State::RecordingState& recording_state)\n    -> void {\n  if (recording_state.resize_restart_thread.joinable() &&\n      recording_state.resize_restart_thread.get_id() != std::this_thread::get_id()) {\n    recording_state.resize_restart_thread.join();\n  }\n}\n\nauto show_recording_notification(Core::State::AppState& state, const std::string& message) -> void {\n  if (!state.events || !state.i18n) {\n    Logger().warn(\"Skip recording notification because events/i18n state is not initialized\");\n    return;\n  }\n\n  Core::Events::post(*state.events,\n                     UI::FloatingWindow::Events::NotificationEvent{\n                         .title = state.i18n->texts[\"label.app_name\"], .message = message});\n}\n\nauto notify_recording_toggled(Core::State::AppState& state, bool enabled) -> void {\n  if (!state.events) {\n    return;\n  }\n\n  Core::Events::post(*state.events,\n                     UI::FloatingWindow::Events::RecordingToggleEvent{.enabled = enabled});\n}\n\n// 生成输出文件路径\nauto generate_output_path(const Core::State::AppState& state)\n    -> std::expected<std::filesystem::path, std::string> {\n  auto output_dir_result =\n      Utils::Path::GetOutputDirectory(state.settings->raw.features.output_dir_path);\n  if (!output_dir_result) {\n    return std::unexpected(\"Failed to get output directory: \" + output_dir_result.error());\n  }\n  const auto& recordings_dir = output_dir_result.value();\n\n  auto filename = Utils::String::FormatTimestamp(std::chrono::system_clock::now());\n  // 与截图模块一致：FormatTimestamp 返回 .png，录制使用 .mp4\n  auto dot_pos = filename.rfind('.');\n  if (dot_pos != std::string::npos) {\n    filename = filename.substr(0, dot_pos) + \".mp4\";\n  } else {\n    filename += \".mp4\";\n  }\n\n  return recordings_dir / filename;\n}\n\nauto toggle_recording_impl(Core::State::AppState& state) -> std::expected<void, std::string> {\n  auto status = state.recording->status.load(std::memory_order_acquire);\n\n  if (status == Features::Recording::Types::RecordingStatus::Recording) {\n    // 停止录制（保存路径在 stop 前读取，stop 不清理 config）\n    std::filesystem::path saved_path = state.recording->config.output_path;\n    Features::Recording::stop(*state.recording);\n    show_recording_notification(state, state.i18n->texts[\"message.recording_saved\"] +\n                                           Utils::String::ToUtf8(saved_path.wstring()));\n    notify_recording_toggled(state, false);\n  } else if (status == Features::Recording::Types::RecordingStatus::Idle) {\n    // 开始录制\n\n    // 1. 查找窗口\n    std::wstring window_title = Utils::String::FromUtf8(state.settings->raw.window.target_title);\n    auto target = Features::WindowControl::find_target_window(window_title);\n    if (!target) {\n      show_recording_notification(state, state.i18n->texts[\"message.window_not_found\"]);\n      return std::unexpected(\"Target window not found\");\n    }\n\n    // 2. 准备配置\n    auto path_result = generate_output_path(state);\n    if (!path_result) {\n      show_recording_notification(\n          state, state.i18n->texts[\"message.recording_start_failed\"] + path_result.error());\n      return std::unexpected(path_result.error());\n    }\n\n    // 从设置读取录制参数\n    const auto& recording_settings = state.settings->raw.features.recording;\n\n    Features::Recording::Types::RecordingConfig config;\n    config.output_path = *path_result;\n    config.fps = recording_settings.fps;\n    config.bitrate = recording_settings.bitrate;\n    config.quality = recording_settings.quality;\n    config.qp = recording_settings.qp;\n    config.rate_control =\n        Features::Recording::Types::rate_control_mode_from_string(recording_settings.rate_control);\n    config.encoder_mode =\n        Features::Recording::Types::encoder_mode_from_string(recording_settings.encoder_mode);\n    config.codec = Features::Recording::Types::video_codec_from_string(recording_settings.codec);\n    config.capture_client_area = recording_settings.capture_client_area;\n    config.capture_cursor = recording_settings.capture_cursor;\n    config.auto_restart_on_resize = recording_settings.auto_restart_on_resize;\n    config.audio_source =\n        Utils::Media::AudioCapture::audio_source_from_string(recording_settings.audio_source);\n    config.audio_bitrate = recording_settings.audio_bitrate;\n\n    // 3. 启动\n    auto result = Features::Recording::start(state, *state.recording, target.value(), config);\n    if (!result) {\n      show_recording_notification(\n          state, state.i18n->texts[\"message.recording_start_failed\"] + result.error());\n      return result;\n    }\n\n    show_recording_notification(state, state.i18n->texts[\"message.recording_started\"]);\n    notify_recording_toggled(state, true);\n  } else {\n    return std::unexpected(\"Recording is in a transitional state\");\n  }\n\n  return {};\n}\n\nauto toggle_recording(Core::State::AppState& state) -> std::expected<void, std::string> {\n  if (!state.recording) {\n    return std::unexpected(\"Recording state is not initialized\");\n  }\n\n  // shutdown 已经开始时，不再接受新的录制开关请求，避免退出阶段和 toggle 抢状态。\n  if (state.recording->shutdown_requested.load(std::memory_order_acquire)) {\n    return std::unexpected(\"Recording shutdown is in progress\");\n  }\n\n  bool expected = false;\n  if (!state.recording->op_in_progress.compare_exchange_strong(expected, true,\n                                                               std::memory_order_acq_rel)) {\n    return {};\n  }\n\n  if (state.recording->toggle_thread.joinable()) {\n    state.recording->toggle_thread.join();\n  }\n\n  state.recording->toggle_thread = std::jthread([&state](std::stop_token) {\n    try {\n      HRESULT hr = CoInitializeEx(nullptr, COINIT_MULTITHREADED);\n      const bool need_uninitialize = SUCCEEDED(hr);\n      if (FAILED(hr) && hr != RPC_E_CHANGED_MODE) {\n        Logger().warn(\"CoInitializeEx failed in recording task: {:08X}\", static_cast<uint32_t>(hr));\n      }\n\n      join_resize_restart_thread(*state.recording);\n\n      std::expected<void, std::string> result;\n      {\n        // toggle / resize restart / shutdown stop 共享同一把控制锁，\n        // 保证任意时刻只有一条控制路径在操作录制状态机。\n        std::lock_guard control_lock(state.recording->control_mutex);\n        if (state.recording->shutdown_requested.load(std::memory_order_acquire)) {\n          result = {};\n        } else {\n          result = toggle_recording_impl(state);\n        }\n      }\n\n      if (!result) {\n        Logger().error(\"Recording toggle failed: {}\", result.error());\n      }\n\n      state.recording->op_in_progress.store(false, std::memory_order_release);\n\n      if (need_uninitialize) {\n        CoUninitialize();\n      }\n    } catch (const std::exception& e) {\n      Logger().error(\"Recording toggle thread exception: {}\", e.what());\n      state.recording->op_in_progress.store(false, std::memory_order_release);\n    } catch (...) {\n      Logger().error(\"Recording toggle thread exception: unknown\");\n      state.recording->op_in_progress.store(false, std::memory_order_release);\n    }\n  });\n\n  return {};\n}\n\nauto stop_recording_if_running(Core::State::AppState& state) -> void {\n  if (!state.recording) {\n    return;\n  }\n\n  // 退出阶段先宣告 shutdown，再接管 stop；\n  // 这样 resize restart / toggle thread 会主动让路，不会再互相等待。\n  state.recording->shutdown_requested.store(true, std::memory_order_release);\n\n  join_resize_restart_thread(*state.recording);\n\n  {\n    std::lock_guard control_lock(state.recording->control_mutex);\n    if (state.recording->status.load(std::memory_order_acquire) ==\n        Features::Recording::Types::RecordingStatus::Recording) {\n      Features::Recording::stop(*state.recording);\n      notify_recording_toggled(state, false);\n    }\n  }\n\n  if (state.recording->toggle_thread.joinable()) {\n    state.recording->toggle_thread.join();\n  }\n}\n\n}  // namespace Features::Recording::UseCase\n"
  },
  {
    "path": "src/features/recording/usecase.ixx",
    "content": "module;\n\nexport module Features.Recording.UseCase;\n\nimport std;\nimport Core.State;\n\nexport namespace Features::Recording::UseCase {\n\n// 切换录制状态 (开始/停止)\nauto toggle_recording(Core::State::AppState& state) -> std::expected<void, std::string>;\n\n// 停止录制 (如果正在运行)\nauto stop_recording_if_running(Core::State::AppState& state) -> void;\n\n}  // namespace Features::Recording::UseCase\n"
  },
  {
    "path": "src/features/replay_buffer/disk_ring_buffer.cpp",
    "content": "module;\n\n#include <mfidl.h>\n\nmodule Features.ReplayBuffer.DiskRingBuffer;\n\nimport std;\nimport Utils.Logger;\nimport Utils.Media.RawEncoder;\nimport <mfapi.h>;\nimport <wil/com.h>;\n\nnamespace Features::ReplayBuffer::DiskRingBuffer {\n\n// 环形覆盖：删除旧帧以腾出空间（内部函数）\nauto trim_old_frames(DiskRingBufferContext& ctx) -> void {\n  // 删除最旧的帧，但保证至少保留一个关键帧\n  if (ctx.frame_index.empty()) {\n    return;\n  }\n\n  // 统计有多少关键帧\n  size_t keyframe_count = 0;\n  for (const auto& frame : ctx.frame_index) {\n    if (frame.is_keyframe && !frame.is_audio) {\n      keyframe_count++;\n    }\n  }\n\n  // 删除最旧的帧，直到释放足够空间或只剩一个关键帧\n  while (ctx.frame_index.size() > 1) {\n    const auto& oldest = ctx.frame_index.front();\n\n    // 如果是关键帧且只剩一个关键帧，不删除\n    if (oldest.is_keyframe && !oldest.is_audio && keyframe_count <= 1) {\n      break;\n    }\n\n    // 删除这一帧\n    if (oldest.is_keyframe && !oldest.is_audio) {\n      keyframe_count--;\n    }\n\n    // 从索引中移除（不需要实际删除文件数据，会被覆盖）\n    ctx.frame_index.pop_front();\n\n    // 释放了足够空间就停止\n    if (!ctx.frame_index.empty()) {\n      std::int64_t oldest_offset = ctx.frame_index.front().file_offset;\n      if (ctx.write_position < oldest_offset) {\n        // 写指针已经回绕，可以重用前面的空间\n        ctx.write_position = 0;\n        break;\n      }\n    }\n  }\n}\n\nauto initialize(DiskRingBufferContext& ctx, const std::filesystem::path& cache_dir,\n                std::int64_t max_file_size, IMFMediaType* video_type, IMFMediaType* audio_type,\n                const std::vector<std::uint8_t>& video_codec_data)\n    -> std::expected<void, std::string> {\n  std::lock_guard lock(ctx.mutex);\n\n  // 1. 确保缓存目录存在\n  try {\n    std::filesystem::create_directories(cache_dir);\n  } catch (const std::exception& e) {\n    return std::unexpected(\"Failed to create cache directory: \" + std::string(e.what()));\n  }\n\n  // 2. 设置数据文件路径\n  ctx.data_file_path = cache_dir / \"buffer.dat\";\n\n  // 3. 打开数据文件（读写模式，如果存在则清空）\n  ctx.data_file.open(ctx.data_file_path,\n                     std::ios::in | std::ios::out | std::ios::binary | std::ios::trunc);\n  if (!ctx.data_file.is_open()) {\n    return std::unexpected(\"Failed to open data file: \" + ctx.data_file_path.string());\n  }\n\n  // 4. 保存媒体类型（深拷贝）\n  if (video_type) {\n    MFCreateMediaType(ctx.video_media_type.put());\n    video_type->CopyAllItems(ctx.video_media_type.get());\n  }\n  if (audio_type) {\n    MFCreateMediaType(ctx.audio_media_type.put());\n    audio_type->CopyAllItems(ctx.audio_media_type.get());\n  }\n\n  // 5. 保存 codec private data\n  ctx.video_codec_data = video_codec_data;\n\n  // 6. 初始化状态\n  ctx.file_size_limit = max_file_size;\n  ctx.write_position = 0;\n  ctx.frame_index.clear();\n\n  Logger().info(\"DiskRingBuffer initialized: file_size_limit={} MB\", max_file_size / 1024 / 1024);\n  return {};\n}\n\nauto append_frame(DiskRingBufferContext& ctx, const BYTE* data, std::uint32_t size,\n                  std::int64_t timestamp_100ns, std::int64_t duration_100ns, bool is_keyframe,\n                  bool is_audio) -> std::expected<void, std::string> {\n  if (!data || size == 0) {\n    return std::unexpected(\"Invalid frame data\");\n  }\n\n  std::lock_guard lock(ctx.mutex);\n\n  if (!ctx.data_file.is_open()) {\n    return std::unexpected(\"Data file not initialized\");\n  }\n\n  // 1. 检查是否需要环形覆盖\n  while (ctx.write_position + size > ctx.file_size_limit && !ctx.frame_index.empty()) {\n    trim_old_frames(ctx);\n  }\n\n  // 2. 如果覆盖后仍然超出限制，说明单帧过大\n  if (ctx.write_position + size > ctx.file_size_limit) {\n    return std::unexpected(\"Frame size exceeds buffer limit\");\n  }\n\n  // 3. 写入数据\n  ctx.data_file.seekp(ctx.write_position);\n  ctx.data_file.write(reinterpret_cast<const char*>(data), size);\n  if (!ctx.data_file.good()) {\n    return std::unexpected(\"Failed to write frame data to disk\");\n  }\n  // 4. 记录元数据\n  FrameMetadata meta;\n  meta.file_offset = ctx.write_position;\n  meta.timestamp_100ns = timestamp_100ns;\n  meta.size = size;\n  meta.duration_100ns = duration_100ns;\n  meta.is_keyframe = is_keyframe;\n  meta.is_audio = is_audio;\n  ctx.frame_index.push_back(meta);\n\n  // 5. 更新写入位置\n  ctx.write_position += size;\n\n  return {};\n}\n\nauto append_encoded_frame(DiskRingBufferContext& ctx,\n                          const Utils::Media::RawEncoder::EncodedFrame& frame)\n    -> std::expected<void, std::string> {\n  return append_frame(ctx, frame.data.data(), static_cast<std::uint32_t>(frame.data.size()),\n                      frame.timestamp_100ns, frame.duration_100ns, frame.is_keyframe,\n                      frame.is_audio);\n}\n\nauto get_recent_frames(const DiskRingBufferContext& ctx, double duration_seconds)\n    -> std::expected<std::vector<FrameMetadata>, std::string> {\n  std::lock_guard lock(ctx.mutex);\n\n  if (ctx.frame_index.empty()) {\n    return std::unexpected(\"No frames in buffer\");\n  }\n\n  std::vector<FrameMetadata> result;\n  std::int64_t target_duration_100ns = static_cast<std::int64_t>(duration_seconds * 10'000'000);\n\n  // 从最后一帧开始往前累加时长\n  std::int64_t accumulated_duration = 0;\n  size_t start_index = ctx.frame_index.size();\n\n  for (size_t i = ctx.frame_index.size(); i > 0; --i) {\n    size_t idx = i - 1;\n    const auto& frame = ctx.frame_index[idx];\n\n    accumulated_duration += frame.duration_100ns;\n    start_index = idx;\n\n    if (accumulated_duration >= target_duration_100ns) {\n      break;\n    }\n  }\n\n  // 从 start_index 往前找最近的关键帧\n  size_t keyframe_index = start_index;\n  for (size_t i = start_index; i > 0; --i) {\n    size_t idx = i - 1;\n    if (ctx.frame_index[idx].is_keyframe && !ctx.frame_index[idx].is_audio) {\n      keyframe_index = idx;\n      break;\n    }\n  }\n\n  // 从关键帧开始收集所有帧\n  for (size_t i = keyframe_index; i < ctx.frame_index.size(); ++i) {\n    result.push_back(ctx.frame_index[i]);\n  }\n\n  Logger().debug(\"get_recent_frames: collected {} frames from index {} (keyframe at {})\",\n                 result.size(), start_index, keyframe_index);\n  return result;\n}\n\nauto read_frame(const DiskRingBufferContext& ctx, const FrameMetadata& meta)\n    -> std::expected<std::vector<std::uint8_t>, std::string> {\n  std::lock_guard lock(ctx.mutex);\n\n  if (!ctx.data_file.is_open()) {\n    return std::unexpected(\"Data file not open\");\n  }\n\n  // 分配缓冲区\n  std::vector<std::uint8_t> out_buffer(meta.size);\n\n  // 定位并读取\n  ctx.data_file.seekg(meta.file_offset);\n  ctx.data_file.read(reinterpret_cast<char*>(out_buffer.data()), meta.size);\n\n  if (!ctx.data_file.good()) {\n    return std::unexpected(\"Failed to read frame data from disk\");\n  }\n\n  return out_buffer;\n}\n\nauto read_frames_bulk(const DiskRingBufferContext& ctx, const std::vector<FrameMetadata>& frames)\n    -> std::expected<std::vector<std::vector<std::uint8_t>>, std::string> {\n  std::lock_guard lock(ctx.mutex);\n\n  if (!ctx.data_file.is_open()) {\n    return std::unexpected(\"Data file not open\");\n  }\n\n  std::vector<std::vector<std::uint8_t>> result;\n  result.reserve(frames.size());\n\n  for (const auto& meta : frames) {\n    std::vector<std::uint8_t> buffer(meta.size);\n    ctx.data_file.seekg(meta.file_offset);\n    ctx.data_file.read(reinterpret_cast<char*>(buffer.data()), meta.size);\n\n    if (!ctx.data_file.good()) {\n      // 清除错误状态并继续\n      ctx.data_file.clear();\n      Logger().warn(\"Failed to read frame at offset {}\", meta.file_offset);\n      result.emplace_back();  // 空数据占位\n    } else {\n      result.push_back(std::move(buffer));\n    }\n  }\n\n  return result;\n}\n\nauto read_frames_unlocked(const std::filesystem::path& data_file_path,\n                          const std::vector<FrameMetadata>& frames)\n    -> std::expected<std::vector<std::vector<std::uint8_t>>, std::string> {\n  // 使用独立文件句柄读取，完全不需要获取 mutex\n  // Windows 允许多个线程同时以只读模式打开同一个文件\n  std::ifstream reader(data_file_path, std::ios::binary);\n  if (!reader.is_open()) {\n    return std::unexpected(\"Failed to open data file for reading: \" + data_file_path.string());\n  }\n\n  std::vector<std::vector<std::uint8_t>> result;\n  result.reserve(frames.size());\n\n  for (const auto& meta : frames) {\n    std::vector<std::uint8_t> buffer(meta.size);\n    reader.seekg(meta.file_offset);\n    reader.read(reinterpret_cast<char*>(buffer.data()), meta.size);\n\n    if (!reader.good()) {\n      reader.clear();\n      Logger().warn(\"Failed to read frame at offset {} (unlocked)\", meta.file_offset);\n      result.emplace_back();  // 空数据占位\n    } else {\n      result.push_back(std::move(buffer));\n    }\n  }\n\n  return result;\n}\n\nauto cleanup(DiskRingBufferContext& ctx) -> void {\n  std::lock_guard lock(ctx.mutex);\n\n  // 关闭文件\n  if (ctx.data_file.is_open()) {\n    ctx.data_file.close();\n  }\n\n  // 删除缓存文件\n  if (!ctx.data_file_path.empty()) {\n    std::error_code ec;\n    std::filesystem::remove(ctx.data_file_path, ec);\n    if (ec) {\n      Logger().warn(\"Failed to remove cache file: {}\", ctx.data_file_path.string());\n    }\n  }\n\n  // 清理状态\n  ctx.frame_index.clear();\n  ctx.write_position = 0;\n  ctx.video_media_type = nullptr;\n  ctx.audio_media_type = nullptr;\n  ctx.video_codec_data.clear();\n}\n\n}  // namespace Features::ReplayBuffer::DiskRingBuffer\n"
  },
  {
    "path": "src/features/replay_buffer/disk_ring_buffer.ixx",
    "content": "module;\n\nexport module Features.ReplayBuffer.DiskRingBuffer;\n\nimport std;\nimport Utils.Media.RawEncoder;\nimport <mfapi.h>;\nimport <wil/com.h>;\n\nexport namespace Features::ReplayBuffer::DiskRingBuffer {\n\n// 压缩帧元数据\nstruct FrameMetadata {\n  std::int64_t file_offset;      // 在数据文件中的字节偏移\n  std::int64_t timestamp_100ns;  // 时间戳（100ns 单位）\n  std::uint32_t size;            // 帧数据大小（字节）\n  std::int64_t duration_100ns;   // 帧持续时长（100ns 单位）\n  bool is_keyframe;              // 是否为关键帧\n  bool is_audio;                 // 是否为音频帧\n};\n\n// 硬盘环形缓冲上下文\nstruct DiskRingBufferContext {\n  // 数据文件路径\n  std::filesystem::path data_file_path;\n\n  // 数据文件流（读写模式）\n  mutable std::fstream data_file;\n\n  // 帧索引（内存中，快速查找）\n  std::deque<FrameMetadata> frame_index;\n\n  // 编码器媒体类型（用于 mux）\n  wil::com_ptr<IMFMediaType> video_media_type;\n  wil::com_ptr<IMFMediaType> audio_media_type;\n\n  // 视频 codec private data（SPS/PPS）\n  std::vector<std::uint8_t> video_codec_data;\n\n  // 写入状态\n  std::int64_t write_position = 0;   // 当前写入位置\n  std::int64_t file_size_limit = 0;  // 文件大小上限\n\n  // 线程安全\n  mutable std::mutex mutex;\n};\n\n// 初始化缓冲（传入编码器的媒体类型和 SPS/PPS）\nauto initialize(DiskRingBufferContext& ctx, const std::filesystem::path& cache_dir,\n                std::int64_t max_file_size, IMFMediaType* video_type, IMFMediaType* audio_type,\n                const std::vector<std::uint8_t>& video_codec_data)\n    -> std::expected<void, std::string>;\n\n// 追加压缩帧数据（原始接口）\nauto append_frame(DiskRingBufferContext& ctx, const BYTE* data, std::uint32_t size,\n                  std::int64_t timestamp_100ns, std::int64_t duration_100ns, bool is_keyframe,\n                  bool is_audio) -> std::expected<void, std::string>;\n\n// 追加压缩帧（从 RawEncoder::EncodedFrame）\nauto append_encoded_frame(DiskRingBufferContext& ctx,\n                          const Utils::Media::RawEncoder::EncodedFrame& frame)\n    -> std::expected<void, std::string>;\n\n// 获取最近 N 秒的帧元数据（从最近的关键帧开始）\nauto get_recent_frames(const DiskRingBufferContext& ctx, double duration_seconds)\n    -> std::expected<std::vector<FrameMetadata>, std::string>;\n\n// 读取单帧数据（返回数据缓冲区）\nauto read_frame(const DiskRingBufferContext& ctx, const FrameMetadata& meta)\n    -> std::expected<std::vector<std::uint8_t>, std::string>;\n\n// 批量读取多帧数据（一次加锁，减少锁竞争）\nauto read_frames_bulk(const DiskRingBufferContext& ctx, const std::vector<FrameMetadata>& frames)\n    -> std::expected<std::vector<std::vector<std::uint8_t>>, std::string>;\n\n// 无锁批量读取多帧数据（使用独立文件句柄，避免与编码线程竞争锁）\n// 注意：调用者应确保帧数据在读取期间不会被覆盖（2GB缓冲区足够大，正常使用不会出问题）\nauto read_frames_unlocked(const std::filesystem::path& data_file_path,\n                          const std::vector<FrameMetadata>& frames)\n    -> std::expected<std::vector<std::vector<std::uint8_t>>, std::string>;\n\n// 清理资源并删除缓存文件\nauto cleanup(DiskRingBufferContext& ctx) -> void;\n\n}  // namespace Features::ReplayBuffer::DiskRingBuffer\n"
  },
  {
    "path": "src/features/replay_buffer/motion_photo.cpp",
    "content": "module;\n\nmodule Features.ReplayBuffer.MotionPhoto;\n\nimport std;\nimport Utils.Logger;\n\nnamespace Features::ReplayBuffer::MotionPhoto {\n\n// 构造 XMP 元数据 XML\nauto build_xmp_xml(std::int64_t mp4_size, std::int64_t presentation_timestamp_us) -> std::string {\n  return std::format(R\"(<x:xmpmeta xmlns:x=\"adobe:ns:meta/\" x:xmptk=\"Adobe XMP Core 5.1.0-jc003\">)\"\n                     R\"(<rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\">)\"\n                     R\"(<rdf:Description rdf:about=\"\" )\"\n                     R\"(xmlns:GCamera=\"http://ns.google.com/photos/1.0/camera/\" )\"\n                     R\"(GCamera:MicroVideoVersion=\"1\" )\"\n                     R\"(GCamera:MicroVideo=\"1\" )\"\n                     R\"(GCamera:MicroVideoOffset=\"{}\" )\"\n                     R\"(GCamera:MicroVideoPresentationTimestampUs=\"{}\"/>)\"\n                     R\"(</rdf:RDF>)\"\n                     R\"(</x:xmpmeta>)\",\n                     mp4_size, presentation_timestamp_us);\n}\n\n// 将 XMP 注入 JPEG（作为 APP1 段）\n// JPEG 结构: FF D8 [APP0...] [APP1...] ... 图像数据 ... FF D9\n// 我们在 FF D8 后插入 APP1 段\nauto inject_xmp_into_jpeg(const std::vector<uint8_t>& jpeg_data, const std::string& xmp_xml)\n    -> std::expected<std::vector<uint8_t>, std::string> {\n  if (jpeg_data.size() < 2 || jpeg_data[0] != 0xFF || jpeg_data[1] != 0xD8) {\n    return std::unexpected(\"Invalid JPEG data: missing SOI marker\");\n  }\n\n  // 构造 APP1 段\n  // APP1 marker: FF E1\n  // 长度: 2 字节（包含长度本身）+ namespace header + XMP 数据\n  std::string xmp_namespace = \"http://ns.adobe.com/xap/1.0/\";\n  xmp_namespace += '\\0';  // null terminator\n\n  size_t payload_size = xmp_namespace.size() + xmp_xml.size();\n  size_t segment_length = 2 + payload_size;  // 2 bytes for length field itself\n\n  if (segment_length > 0xFFFF) {\n    return std::unexpected(\"XMP data too large for APP1 segment\");\n  }\n\n  // 构建结果\n  std::vector<uint8_t> result;\n  result.reserve(jpeg_data.size() + 2 + segment_length);\n\n  // 1. SOI marker (FF D8)\n  result.push_back(0xFF);\n  result.push_back(0xD8);\n\n  // 2. APP1 segment\n  result.push_back(0xFF);\n  result.push_back(0xE1);\n  result.push_back(static_cast<uint8_t>((segment_length >> 8) & 0xFF));\n  result.push_back(static_cast<uint8_t>(segment_length & 0xFF));\n\n  // XMP namespace header\n  result.insert(result.end(), xmp_namespace.begin(), xmp_namespace.end());\n\n  // XMP data\n  result.insert(result.end(), xmp_xml.begin(), xmp_xml.end());\n\n  // 3. 原始 JPEG 数据（跳过 SOI）\n  result.insert(result.end(), jpeg_data.begin() + 2, jpeg_data.end());\n\n  return result;\n}\n\nauto create_motion_photo(const std::filesystem::path& jpeg_path,\n                         const std::filesystem::path& mp4_path,\n                         const std::filesystem::path& output_path,\n                         std::int64_t presentation_timestamp_us)\n    -> std::expected<void, std::string> {\n  try {\n    // 1. 读取 JPEG 文件\n    std::ifstream jpeg_file(jpeg_path, std::ios::binary);\n    if (!jpeg_file) {\n      return std::unexpected(\"Failed to open JPEG file: \" + jpeg_path.string());\n    }\n    std::vector<uint8_t> jpeg_data((std::istreambuf_iterator<char>(jpeg_file)),\n                                   std::istreambuf_iterator<char>());\n    jpeg_file.close();\n\n    // 2. 获取 MP4 文件大小\n    std::error_code ec;\n    auto mp4_size = std::filesystem::file_size(mp4_path, ec);\n    if (ec) {\n      return std::unexpected(\"Failed to get MP4 file size: \" + ec.message());\n    }\n\n    // 3. 构造 XMP 元数据\n    auto xmp_xml = build_xmp_xml(static_cast<std::int64_t>(mp4_size), presentation_timestamp_us);\n\n    // 4. 将 XMP 注入 JPEG\n    auto injected_result = inject_xmp_into_jpeg(jpeg_data, xmp_xml);\n    if (!injected_result) {\n      return std::unexpected(injected_result.error());\n    }\n\n    // 5. 写入输出文件: [注入XMP的JPEG] [MP4]\n    std::ofstream output_file(output_path, std::ios::binary);\n    if (!output_file) {\n      return std::unexpected(\"Failed to create output file: \" + output_path.string());\n    }\n\n    // 写入带 XMP 的 JPEG\n    output_file.write(reinterpret_cast<const char*>(injected_result->data()),\n                      injected_result->size());\n\n    // 追加 MP4 数据\n    std::ifstream mp4_file(mp4_path, std::ios::binary);\n    if (!mp4_file) {\n      return std::unexpected(\"Failed to open MP4 file: \" + mp4_path.string());\n    }\n\n    // 分块复制以避免一次性加载大文件\n    constexpr size_t chunk_size = 64 * 1024;  // 64KB\n    std::vector<char> buffer(chunk_size);\n    while (mp4_file.read(buffer.data(), chunk_size) || mp4_file.gcount() > 0) {\n      output_file.write(buffer.data(), mp4_file.gcount());\n    }\n\n    output_file.close();\n\n    Logger().info(\"Motion Photo created: {}\", output_path.string());\n    return {};\n  } catch (const std::exception& e) {\n    return std::unexpected(std::format(\"Motion Photo creation failed: {}\", e.what()));\n  }\n}\n\n}  // namespace Features::ReplayBuffer::MotionPhoto\n"
  },
  {
    "path": "src/features/replay_buffer/motion_photo.ixx",
    "content": "module;\n\nexport module Features.ReplayBuffer.MotionPhoto;\n\nimport std;\n\nnamespace Features::ReplayBuffer::MotionPhoto {\n\n// 合成 Google Motion Photo 文件\n// jpeg_path: 源 JPEG 图片路径\n// mp4_path: 源 MP4 视频路径\n// output_path: 输出 Motion Photo 文件路径\n// presentation_timestamp_us: 截图时刻在视频中的位置（微秒）\nexport auto create_motion_photo(const std::filesystem::path& jpeg_path,\n                                const std::filesystem::path& mp4_path,\n                                const std::filesystem::path& output_path,\n                                std::int64_t presentation_timestamp_us)\n    -> std::expected<void, std::string>;\n\n}  // namespace Features::ReplayBuffer::MotionPhoto\n"
  },
  {
    "path": "src/features/replay_buffer/muxer.cpp",
    "content": "module;\n\n#include <mfidl.h>\n#include <mfreadwrite.h>\n\nmodule Features.ReplayBuffer.Muxer;\n\nimport std;\nimport Features.ReplayBuffer.DiskRingBuffer;\nimport Utils.Logger;\nimport <d3d11.h>;\nimport <mfapi.h>;\nimport <mferror.h>;\nimport <wil/com.h>;\n\nnamespace Features::ReplayBuffer::Muxer {\n\nauto mux_frames_to_mp4(\n    const std::vector<Features::ReplayBuffer::DiskRingBuffer::FrameMetadata>& frames,\n    const std::filesystem::path& data_file_path, IMFMediaType* video_type, IMFMediaType* audio_type,\n    const std::filesystem::path& output_path) -> std::expected<void, std::string> {\n  if (frames.empty()) {\n    return std::unexpected(\"No frames to mux\");\n  }\n\n  if (!video_type) {\n    return std::unexpected(\"Video type is required\");\n  }\n\n  // 确保目录存在\n  std::filesystem::create_directories(output_path.parent_path());\n\n  // 1. 创建 SinkWriter 属性\n  wil::com_ptr<IMFAttributes> attributes;\n  HRESULT hr = MFCreateAttributes(attributes.put(), 2);\n  if (FAILED(hr)) {\n    return std::unexpected(\"Failed to create MF attributes\");\n  }\n\n  // 禁用编码器（stream copy 模式）\n  attributes->SetUINT32(MF_READWRITE_DISABLE_CONVERTERS, TRUE);\n\n  // 2. 创建 SinkWriter\n  wil::com_ptr<IMFSinkWriter> sink_writer;\n  hr = MFCreateSinkWriterFromURL(output_path.c_str(), nullptr, attributes.get(), sink_writer.put());\n  if (FAILED(hr)) {\n    return std::unexpected(\"Failed to create sink writer: \" + std::to_string(hr));\n  }\n\n  // 3. 添加视频流（直接使用压缩后的类型）\n  DWORD video_stream_index = 0;\n  hr = sink_writer->AddStream(video_type, &video_stream_index);\n  if (FAILED(hr)) {\n    return std::unexpected(\"Failed to add video stream: \" + std::to_string(hr));\n  }\n\n  // 设置输入类型（与输出相同，实现 stream copy）\n  hr = sink_writer->SetInputMediaType(video_stream_index, video_type, nullptr);\n  if (FAILED(hr)) {\n    return std::unexpected(\"Failed to set video input type: \" + std::to_string(hr));\n  }\n\n  // 4. 添加音频流（可选）\n  DWORD audio_stream_index = 0;\n  bool has_audio = (audio_type != nullptr);\n  if (has_audio) {\n    hr = sink_writer->AddStream(audio_type, &audio_stream_index);\n    if (FAILED(hr)) {\n      Logger().warn(\"Failed to add audio stream, continuing without audio\");\n      has_audio = false;\n    } else {\n      hr = sink_writer->SetInputMediaType(audio_stream_index, audio_type, nullptr);\n      if (FAILED(hr)) {\n        Logger().warn(\"Failed to set audio input type, continuing without audio\");\n        has_audio = false;\n      }\n    }\n  }\n\n  // 5. 开始写入\n  hr = sink_writer->BeginWriting();\n  if (FAILED(hr)) {\n    return std::unexpected(\"Failed to begin writing: \" + std::to_string(hr));\n  }\n\n  // 6. 计算时间戳偏移（使第一帧从 0 开始）\n  std::int64_t first_video_timestamp = 0;\n  std::int64_t first_audio_timestamp = 0;\n\n  for (const auto& frame : frames) {\n    if (!frame.is_audio) {\n      first_video_timestamp = frame.timestamp_100ns;\n      break;\n    }\n  }\n\n  for (const auto& frame : frames) {\n    if (frame.is_audio) {\n      first_audio_timestamp = frame.timestamp_100ns;\n      break;\n    }\n  }\n\n  // 7. 无锁批量读取所有帧数据（使用独立文件句柄，避免与编码线程竞争锁）\n  auto bulk_result = DiskRingBuffer::read_frames_unlocked(data_file_path, frames);\n  if (!bulk_result) {\n    return std::unexpected(\"Failed to bulk read frame data: \" + bulk_result.error());\n  }\n  auto& all_frame_data = *bulk_result;\n\n  // 8. 写入所有帧（此时不再访问 ring_buffer）\n  std::uint32_t video_count = 0;\n  std::uint32_t audio_count = 0;\n\n  for (size_t i = 0; i < frames.size(); ++i) {\n    const auto& frame = frames[i];\n    const auto& frame_data = all_frame_data[i];\n\n    // 跳过读取失败的帧\n    if (frame_data.empty()) {\n      continue;\n    }\n\n    // 创建 sample\n    wil::com_ptr<IMFSample> sample;\n    hr = MFCreateSample(sample.put());\n    if (FAILED(hr)) {\n      Logger().warn(\"Failed to create sample\");\n      continue;\n    }\n\n    // 创建 buffer\n    wil::com_ptr<IMFMediaBuffer> buffer;\n    hr = MFCreateMemoryBuffer(static_cast<DWORD>(frame_data.size()), buffer.put());\n    if (FAILED(hr)) {\n      Logger().warn(\"Failed to create buffer\");\n      continue;\n    }\n\n    // 复制数据到 buffer\n    BYTE* dest = nullptr;\n    hr = buffer->Lock(&dest, nullptr, nullptr);\n    if (SUCCEEDED(hr)) {\n      std::memcpy(dest, frame_data.data(), frame_data.size());\n      buffer->Unlock();\n      buffer->SetCurrentLength(static_cast<DWORD>(frame_data.size()));\n    } else {\n      Logger().warn(\"Failed to lock buffer\");\n      continue;\n    }\n\n    sample->AddBuffer(buffer.get());\n\n    // 设置时间戳（相对于第一帧）\n    std::int64_t base_timestamp = frame.is_audio ? first_audio_timestamp : first_video_timestamp;\n    std::int64_t adjusted_timestamp = frame.timestamp_100ns - base_timestamp;\n    if (adjusted_timestamp < 0) {\n      adjusted_timestamp = 0;\n    }\n\n    sample->SetSampleTime(adjusted_timestamp);\n\n    if (frame.duration_100ns > 0) {\n      sample->SetSampleDuration(frame.duration_100ns);\n    }\n\n    // 标记关键帧\n    if (frame.is_keyframe) {\n      sample->SetUINT32(MFSampleExtension_CleanPoint, TRUE);\n    }\n\n    // 写入 sample\n    DWORD stream_index = frame.is_audio ? audio_stream_index : video_stream_index;\n    if (frame.is_audio && !has_audio) {\n      continue;  // 跳过音频帧\n    }\n\n    hr = sink_writer->WriteSample(stream_index, sample.get());\n    if (FAILED(hr)) {\n      Logger().warn(\"Failed to write sample: {}\", hr);\n      continue;\n    }\n\n    if (frame.is_audio) {\n      audio_count++;\n    } else {\n      video_count++;\n    }\n  }\n\n  // 9. 完成写入\n  hr = sink_writer->Finalize();\n  if (FAILED(hr)) {\n    return std::unexpected(\"Failed to finalize: \" + std::to_string(hr));\n  }\n\n  Logger().info(\"Muxed {} video frames, {} audio frames to {}\", video_count, audio_count,\n                output_path.string());\n\n  return {};\n}\n\n}  // namespace Features::ReplayBuffer::Muxer\n"
  },
  {
    "path": "src/features/replay_buffer/muxer.ixx",
    "content": "module;\n\n#include <mfidl.h>\n\nexport module Features.ReplayBuffer.Muxer;\n\nimport std;\nimport Features.ReplayBuffer.DiskRingBuffer;\nimport <mfapi.h>;\nimport <wil/com.h>;\n\nexport namespace Features::ReplayBuffer::Muxer {\n\n// 将压缩帧 mux 成 MP4（stream copy，不转码）\n// frames: 要写入的帧元数据列表\n// data_file_path: 缓冲数据文件路径（用于无锁读取）\n// video_type: 视频输出媒体类型\n// audio_type: 音频输出媒体类型（可选）\n// output_path: 输出文件路径\nauto mux_frames_to_mp4(\n    const std::vector<Features::ReplayBuffer::DiskRingBuffer::FrameMetadata>& frames,\n    const std::filesystem::path& data_file_path, IMFMediaType* video_type, IMFMediaType* audio_type,\n    const std::filesystem::path& output_path) -> std::expected<void, std::string>;\n\n}  // namespace Features::ReplayBuffer::Muxer\n"
  },
  {
    "path": "src/features/replay_buffer/replay_buffer.cpp",
    "content": "module;\n\n#include <winrt/Windows.Graphics.Capture.h>\n\nmodule Features.ReplayBuffer;\n\nimport std;\nimport Features.ReplayBuffer.State;\nimport Features.ReplayBuffer.Types;\nimport Features.ReplayBuffer.DiskRingBuffer;\nimport Features.ReplayBuffer.Muxer;\nimport Utils.Graphics.Capture;\nimport Utils.Graphics.CaptureRegion;\nimport Utils.Graphics.D3D;\nimport Utils.Media.AudioCapture;\nimport Utils.Media.RawEncoder;\nimport Utils.Logger;\nimport Utils.Path;\nimport <d3d11_4.h>;\nimport <mfapi.h>;\nimport <wil/com.h>;\nimport <windows.h>;\n\nnamespace Features::ReplayBuffer {\n\n// 默认缓冲文件大小限制（2GB）\nconstexpr std::int64_t kDefaultBufferSizeLimit = 2LL * 1024 * 1024 * 1024;\nauto floor_to_even(int value) -> int { return (value / 2) * 2; }\n\n// 帧到达回调\nauto on_frame_arrived(Features::ReplayBuffer::State::ReplayBufferState& state,\n                      Utils::Graphics::Capture::Direct3D11CaptureFrame frame) -> void {\n  if (state.status.load(std::memory_order_acquire) !=\n      Features::ReplayBuffer::Types::ReplayBufferStatus::Buffering) {\n    return;\n  }\n\n  auto now = std::chrono::steady_clock::now();\n  auto elapsed = now - state.start_time;\n  auto elapsed_100ns = std::chrono::duration_cast<std::chrono::nanoseconds>(elapsed).count() / 100;\n\n  std::int64_t frame_duration_100ns = 10'000'000 / state.config.fps;\n  std::int64_t target_frame_index = elapsed_100ns / frame_duration_100ns;\n\n  auto texture =\n      Utils::Graphics::Capture::get_dxgi_interface_from_object<ID3D11Texture2D>(frame.Surface());\n  if (!texture) {\n    Logger().error(\"Failed to get texture from capture frame\");\n    return;\n  }\n\n  D3D11_TEXTURE2D_DESC source_desc{};\n  texture->GetDesc(&source_desc);\n  if (source_desc.Width == 0 || source_desc.Height == 0) {\n    Logger().error(\"Failed to resolve replay source texture size\");\n    return;\n  }\n\n  std::lock_guard resource_lock(state.resource_mutex);\n  ID3D11Texture2D* current_texture = texture.get();\n  if (state.crop_to_client_area) {\n    auto crop_region_result = Utils::Graphics::CaptureRegion::calculate_client_crop_region(\n        state.target_window, source_desc.Width, source_desc.Height);\n    if (!crop_region_result) {\n      Logger().error(\"Failed to calculate replay crop region: {}\", crop_region_result.error());\n      return;\n    }\n\n    auto crop_result = Utils::Graphics::CaptureRegion::crop_texture_to_region(\n        state.device.get(), state.context.get(), texture.get(), *crop_region_result,\n        state.cropped_texture);\n    if (!crop_result) {\n      Logger().error(\"Failed to crop replay frame to client area: {}\", crop_result.error());\n      return;\n    }\n    current_texture = *crop_result;\n  }\n\n  D3D11_TEXTURE2D_DESC current_desc{};\n  current_texture->GetDesc(&current_desc);\n  if (current_desc.Width != state.raw_encoder.frame_width ||\n      current_desc.Height != state.raw_encoder.frame_height) {\n    Logger().error(\"Replay frame size mismatch after crop: got {}x{}, expected {}x{}\",\n                   current_desc.Width, current_desc.Height, state.raw_encoder.frame_width,\n                   state.raw_encoder.frame_height);\n    return;\n  }\n\n  while (state.frame_index <= target_frame_index) {\n    std::int64_t timestamp = state.frame_index * frame_duration_100ns;\n\n    ID3D11Texture2D* encode_texture =\n        (state.frame_index < target_frame_index && state.last_encoded_texture)\n            ? state.last_encoded_texture.get()\n            : current_texture;\n\n    // 使用 RawEncoder 编码\n    std::expected<std::vector<Utils::Media::RawEncoder::EncodedFrame>, std::string> result;\n    {\n      std::lock_guard write_lock(state.encoder_write_mutex);\n      result = Utils::Media::RawEncoder::encode_video_frame(state.raw_encoder, state.context.get(),\n                                                            encode_texture, timestamp);\n    }\n\n    if (!result) {\n      Logger().error(\"Failed to encode frame {}: {}\", state.frame_index, result.error());\n      break;\n    }\n\n    // 将编码输出写入环形缓冲\n    for (auto& encoded_frame : *result) {\n      auto append_result = DiskRingBuffer::append_encoded_frame(state.ring_buffer, encoded_frame);\n      if (!append_result) {\n        Logger().warn(\"Failed to append frame to ring buffer: {}\", append_result.error());\n      }\n    }\n\n    state.frame_index++;\n  }\n\n  // 缓存当前帧\n  if (!state.last_encoded_texture) {\n    D3D11_TEXTURE2D_DESC desc;\n    current_texture->GetDesc(&desc);\n    desc.BindFlags = 0;\n    desc.MiscFlags = 0;\n    desc.Usage = D3D11_USAGE_DEFAULT;\n    desc.CPUAccessFlags = 0;\n\n    if (FAILED(state.device->CreateTexture2D(&desc, nullptr, state.last_encoded_texture.put()))) {\n      Logger().error(\"Failed to create texture for frame caching\");\n      return;\n    }\n  }\n\n  state.context->CopyResource(state.last_encoded_texture.get(), current_texture);\n}\n\nauto initialize(Features::ReplayBuffer::State::ReplayBufferState& state)\n    -> std::expected<void, std::string> {\n  if (FAILED(MFStartup(MF_VERSION))) {\n    return std::unexpected(\"Failed to initialize Media Foundation for ReplayBuffer\");\n  }\n  return {};\n}\n\nauto start_buffering(Features::ReplayBuffer::State::ReplayBufferState& state, HWND target_window,\n                     const Features::ReplayBuffer::Types::ReplayBufferConfig& config)\n    -> std::expected<void, std::string> {\n  std::lock_guard resource_lock(state.resource_mutex);\n\n  auto current_status = state.status.load(std::memory_order_acquire);\n  if (current_status != Features::ReplayBuffer::Types::ReplayBufferStatus::Idle) {\n    return std::unexpected(\"ReplayBuffer is not idle\");\n  }\n\n  state.config = config;\n  state.target_window = target_window;\n\n  // 1. 创建缓存目录\n  auto cache_root_result = Utils::Path::GetAppDataSubdirectory(\"cache\");\n  if (!cache_root_result) {\n    return std::unexpected(\"Failed to get cache directory: \" + cache_root_result.error());\n  }\n  state.cache_dir = cache_root_result.value() / \"replay_buffer\";\n\n  auto ensure_result = Utils::Path::EnsureDirectoryExists(state.cache_dir);\n  if (!ensure_result) {\n    return std::unexpected(\"Failed to create cache directory: \" + ensure_result.error());\n  }\n\n  // 2. 创建 Headless D3D 设备\n  auto d3d_result = Utils::Graphics::D3D::create_headless_d3d_device();\n  if (!d3d_result) {\n    return std::unexpected(\"Failed to create D3D device: \" + d3d_result.error());\n  }\n  state.device = d3d_result->first;\n  state.context = d3d_result->second;\n\n  // 启用 D3D11 多线程保护\n  wil::com_ptr<ID3D11Multithread> multithread;\n  if (SUCCEEDED(state.device->QueryInterface(IID_PPV_ARGS(multithread.put())))) {\n    multithread->SetMultithreadProtected(TRUE);\n  }\n\n  // 3. 创建 WinRT 设备\n  auto winrt_device_result = Utils::Graphics::Capture::create_winrt_device(state.device.get());\n  if (!winrt_device_result) {\n    return std::unexpected(\"Failed to create WinRT device: \" + winrt_device_result.error());\n  }\n\n  // 4. 初始化音频捕获\n  DWORD process_id = 0;\n  GetWindowThreadProcessId(target_window, &process_id);\n\n  auto audio_source = Utils::Media::AudioCapture::audio_source_from_string(config.audio_source);\n  auto audio_result = Utils::Media::AudioCapture::initialize(state.audio, audio_source, process_id);\n  if (!audio_result) {\n    Logger().warn(\"Audio capture initialization failed: {}, continuing without audio\",\n                  audio_result.error());\n  } else {\n    Logger().info(\"ReplayBuffer audio capture initialized\");\n  }\n\n  // 5. 计算捕获尺寸与输出尺寸\n  int width = 0;\n  int height = 0;\n  int capture_width = 0;\n  int capture_height = 0;\n  state.crop_to_client_area = false;\n  state.cropped_texture = nullptr;\n\n  if (config.use_recording_capture_options) {\n    RECT window_rect{};\n    RECT client_rect{};\n    GetWindowRect(target_window, &window_rect);\n    GetClientRect(target_window, &client_rect);\n\n    int window_width = window_rect.right - window_rect.left;\n    int window_height = window_rect.bottom - window_rect.top;\n    int client_width = client_rect.right - client_rect.left;\n    int client_height = client_rect.bottom - client_rect.top;\n\n    if (window_width <= 0 || window_height <= 0 || client_width <= 0 || client_height <= 0) {\n      return std::unexpected(\"Invalid window size\");\n    }\n\n    capture_width = window_width;\n    capture_height = window_height;\n    width = config.capture_client_area ? client_width : window_width;\n    height = config.capture_client_area ? client_height : window_height;\n    width = floor_to_even(width);\n    height = floor_to_even(height);\n\n    if (width <= 0 || height <= 0) {\n      return std::unexpected(\"Invalid output size\");\n    }\n\n    if (config.capture_client_area) {\n      state.crop_to_client_area = true;\n    }\n  } else {\n    // 兼容旧行为：按客户区尺寸创建帧池，不启用客户区裁剪逻辑\n    RECT rect{};\n    GetClientRect(target_window, &rect);\n    width = floor_to_even(rect.right - rect.left);\n    height = floor_to_even(rect.bottom - rect.top);\n    capture_width = width;\n    capture_height = height;\n\n    if (width <= 0 || height <= 0) {\n      return std::unexpected(\"Invalid window size\");\n    }\n  }\n\n  // 6. 创建 RawEncoder\n  Utils::Media::RawEncoder::RawEncoderConfig encoder_config;\n  encoder_config.width = width;\n  encoder_config.height = height;\n  encoder_config.fps = config.fps;\n  encoder_config.bitrate = config.bitrate;\n  encoder_config.keyframe_interval = config.keyframe_interval;\n  encoder_config.use_hardware = true;\n\n  WAVEFORMATEX* wave_format = state.audio.wave_format;\n  auto encoder_result =\n      Utils::Media::RawEncoder::create_encoder(encoder_config, state.device.get(), wave_format);\n  if (!encoder_result) {\n    return std::unexpected(\"Failed to create RawEncoder: \" + encoder_result.error());\n  }\n  state.raw_encoder = std::move(*encoder_result);\n\n  // 7. 初始化硬盘环形缓冲\n  auto video_type = Utils::Media::RawEncoder::get_video_output_type(state.raw_encoder);\n  auto audio_type = Utils::Media::RawEncoder::get_audio_output_type(state.raw_encoder);\n  auto codec_data = Utils::Media::RawEncoder::get_video_codec_private_data(state.raw_encoder);\n\n  auto ring_result =\n      DiskRingBuffer::initialize(state.ring_buffer, state.cache_dir, kDefaultBufferSizeLimit,\n                                 video_type, audio_type, codec_data);\n  if (!ring_result) {\n    return std::unexpected(\"Failed to initialize ring buffer: \" + ring_result.error());\n  }\n\n  Utils::Graphics::Capture::CaptureSessionOptions capture_options;\n  capture_options.capture_cursor =\n      config.use_recording_capture_options ? config.capture_cursor : false;\n  capture_options.border_required = false;\n\n  // 8. 创建捕获会话（2 帧缓冲）\n  auto capture_result = Utils::Graphics::Capture::create_capture_session(\n      target_window, *winrt_device_result, capture_width, capture_height,\n      [&state](auto frame) { on_frame_arrived(state, frame); }, 2, capture_options);\n\n  if (!capture_result) {\n    return std::unexpected(\"Failed to create capture session: \" + capture_result.error());\n  }\n  state.capture_session = std::move(*capture_result);\n\n  // 9. 启动捕获\n  auto start_result = Utils::Graphics::Capture::start_capture(state.capture_session);\n  if (!start_result) {\n    return std::unexpected(\"Failed to start capture: \" + start_result.error());\n  }\n\n  // 10. 启动音频捕获线程\n  if (state.raw_encoder.has_audio) {\n    Utils::Media::AudioCapture::start_capture_thread(\n        state.audio,\n        // get_elapsed_100ns: 相对于开始时间\n        [&state]() -> std::int64_t {\n          auto now = std::chrono::steady_clock::now();\n          auto elapsed = now - state.start_time;\n          return std::chrono::duration_cast<std::chrono::nanoseconds>(elapsed).count() / 100;\n        },\n        // is_active\n        [&state]() -> bool {\n          return state.status.load(std::memory_order_acquire) ==\n                     Features::ReplayBuffer::Types::ReplayBufferStatus::Buffering &&\n                 state.raw_encoder.has_audio;\n        },\n        // on_packet\n        [&state](const BYTE* data, UINT32 num_frames, UINT32 bytes_per_frame,\n                 std::int64_t timestamp_100ns) {\n          DWORD buffer_size = num_frames * bytes_per_frame;\n\n          std::lock_guard write_lock(state.encoder_write_mutex);\n          auto result = Utils::Media::RawEncoder::encode_audio_frame(state.raw_encoder, data,\n                                                                     buffer_size, timestamp_100ns);\n\n          if (result && result->has_value()) {\n            auto append_result =\n                DiskRingBuffer::append_encoded_frame(state.ring_buffer, result->value());\n            if (!append_result) {\n              Logger().warn(\"Failed to append audio frame: {}\", append_result.error());\n            }\n          }\n        });\n  }\n\n  // 11. 设置状态\n  state.start_time = std::chrono::steady_clock::now();\n  state.frame_index = 0;\n  state.status.store(Features::ReplayBuffer::Types::ReplayBufferStatus::Buffering,\n                     std::memory_order_release);\n\n  Logger().info(\"ReplayBuffer started buffering (new architecture)\");\n  return {};\n}\n\nauto stop_buffering(Features::ReplayBuffer::State::ReplayBufferState& state) -> void {\n  auto expected = Features::ReplayBuffer::Types::ReplayBufferStatus::Buffering;\n  if (!state.status.compare_exchange_strong(expected,\n                                            Features::ReplayBuffer::Types::ReplayBufferStatus::Idle,\n                                            std::memory_order_acq_rel)) {\n    return;\n  }\n\n  // 1. 停止音频\n  if (state.raw_encoder.has_audio) {\n    Utils::Media::AudioCapture::stop(state.audio);\n  }\n\n  // 2. 停止视频捕获\n  Utils::Graphics::Capture::stop_capture(state.capture_session);\n\n  // 3. 清理资源\n  {\n    std::lock_guard resource_lock(state.resource_mutex);\n\n    // Flush 并清理 RawEncoder\n    Utils::Media::RawEncoder::finalize(state.raw_encoder);\n\n    state.capture_session = {};\n    state.raw_encoder = {};\n    state.last_encoded_texture = nullptr;\n    state.cropped_texture = nullptr;\n    state.crop_to_client_area = false;\n    state.device = nullptr;\n    state.context = nullptr;\n\n    Utils::Media::AudioCapture::cleanup(state.audio);\n\n    // 清理环形缓冲\n    DiskRingBuffer::cleanup(state.ring_buffer);\n  }\n\n  Logger().info(\"ReplayBuffer stopped\");\n}\n\nauto get_recent_frames(const Features::ReplayBuffer::State::ReplayBufferState& state,\n                       double duration_seconds)\n    -> std::expected<std::vector<DiskRingBuffer::FrameMetadata>, std::string> {\n  return DiskRingBuffer::get_recent_frames(state.ring_buffer, duration_seconds);\n}\n\nauto save_replay(Features::ReplayBuffer::State::ReplayBufferState& state, double duration_seconds,\n                 const std::filesystem::path& output_path) -> std::expected<void, std::string> {\n  if (state.status.load(std::memory_order_acquire) !=\n      Features::ReplayBuffer::Types::ReplayBufferStatus::Buffering) {\n    return std::unexpected(\"ReplayBuffer is not buffering\");\n  }\n\n  // 获取最近的帧\n  auto frames_result = DiskRingBuffer::get_recent_frames(state.ring_buffer, duration_seconds);\n  if (!frames_result) {\n    return std::unexpected(\"Failed to get recent frames: \" + frames_result.error());\n  }\n\n  if (frames_result->empty()) {\n    return std::unexpected(\"No frames available for replay\");\n  }\n\n  // 使用 Muxer 保存为 MP4（传入文件路径以使用无锁读取）\n  auto video_type = state.ring_buffer.video_media_type.get();\n  auto audio_type = state.ring_buffer.audio_media_type.get();\n\n  return Muxer::mux_frames_to_mp4(*frames_result, state.ring_buffer.data_file_path, video_type,\n                                  audio_type, output_path);\n}\n\nauto cleanup(Features::ReplayBuffer::State::ReplayBufferState& state) -> void {\n  stop_buffering(state);\n  MFShutdown();\n}\n\n}  // namespace Features::ReplayBuffer\n"
  },
  {
    "path": "src/features/replay_buffer/replay_buffer.ixx",
    "content": "module;\n\nexport module Features.ReplayBuffer;\n\nimport std;\nimport Features.ReplayBuffer.Types;\nimport Features.ReplayBuffer.State;\nimport Features.ReplayBuffer.DiskRingBuffer;\nimport Features.ReplayBuffer.Muxer;\nimport <windows.h>;\n\nexport namespace Features::ReplayBuffer {\n\n// 初始化回放缓冲模块\nauto initialize(Features::ReplayBuffer::State::ReplayBufferState& state)\n    -> std::expected<void, std::string>;\n\n// 开始缓冲\nauto start_buffering(Features::ReplayBuffer::State::ReplayBufferState& state, HWND target_window,\n                     const Features::ReplayBuffer::Types::ReplayBufferConfig& config)\n    -> std::expected<void, std::string>;\n\n// 停止缓冲\nauto stop_buffering(Features::ReplayBuffer::State::ReplayBufferState& state) -> void;\n\n// 获取最近 N 秒的帧元数据（从关键帧开始）\nauto get_recent_frames(const Features::ReplayBuffer::State::ReplayBufferState& state,\n                       double duration_seconds)\n    -> std::expected<std::vector<DiskRingBuffer::FrameMetadata>, std::string>;\n\n// 保存最近 N 秒到 MP4 文件\nauto save_replay(Features::ReplayBuffer::State::ReplayBufferState& state, double duration_seconds,\n                 const std::filesystem::path& output_path) -> std::expected<void, std::string>;\n\n// 清理资源\nauto cleanup(Features::ReplayBuffer::State::ReplayBufferState& state) -> void;\n\n}  // namespace Features::ReplayBuffer\n"
  },
  {
    "path": "src/features/replay_buffer/state.ixx",
    "content": "module;\n\nexport module Features.ReplayBuffer.State;\n\nimport std;\nimport Features.ReplayBuffer.Types;\nimport Features.ReplayBuffer.DiskRingBuffer;\nimport Utils.Graphics.Capture;\nimport Utils.Media.AudioCapture;\nimport Utils.Media.RawEncoder;\nimport <d3d11.h>;\nimport <wil/com.h>;\nimport <windows.h>;\n\nexport namespace Features::ReplayBuffer::State {\n\n// 回放缓冲完整状态（新架构：单编码器 + 硬盘环形缓冲）\nstruct ReplayBufferState {\n  Features::ReplayBuffer::Types::ReplayBufferConfig config;\n\n  // 状态标志 - 使用 atomic 避免锁竞争\n  std::atomic<Features::ReplayBuffer::Types::ReplayBufferStatus> status{\n      Features::ReplayBuffer::Types::ReplayBufferStatus::Idle};\n\n  // 即时回放运行时开关（不持久化）\n  std::atomic<bool> replay_enabled{false};\n\n  // 动态照片运行时开关（不持久化）\n  std::atomic<bool> motion_photo_enabled{false};\n\n  // D3D 资源 (Headless)\n  wil::com_ptr<ID3D11Device> device;\n  wil::com_ptr<ID3D11DeviceContext> context;\n\n  // WGC 捕获会话\n  Utils::Graphics::Capture::CaptureSession capture_session;\n  bool crop_to_client_area = false;\n  wil::com_ptr<ID3D11Texture2D> cropped_texture;\n\n  // RawEncoder（单一实例，全程运行）\n  Utils::Media::RawEncoder::RawEncoderContext raw_encoder;\n\n  // 硬盘环形缓冲\n  DiskRingBuffer::DiskRingBufferContext ring_buffer;\n\n  // 帧率控制\n  std::chrono::steady_clock::time_point start_time;\n  std::uint64_t frame_index = 0;\n\n  // 最后编码的帧纹理（用于帧重复填充）\n  wil::com_ptr<ID3D11Texture2D> last_encoded_texture;\n\n  // 音频捕获（使用共享音频捕获模块）\n  Utils::Media::AudioCapture::AudioCaptureContext audio;\n\n  // 目标窗口\n  HWND target_window = nullptr;\n\n  // 线程同步\n  std::mutex encoder_write_mutex;\n  std::mutex resource_mutex;\n\n  // 缓存目录\n  std::filesystem::path cache_dir;\n};\n\n}  // namespace Features::ReplayBuffer::State\n"
  },
  {
    "path": "src/features/replay_buffer/types.ixx",
    "content": "module;\n\nexport module Features.ReplayBuffer.Types;\n\nimport std;\n\nnamespace Features::ReplayBuffer::Types {\n\n// 回放缓冲状态\nexport enum class ReplayBufferStatus {\n  Idle,       // 空闲\n  Buffering,  // 正在缓冲\n  Saving,     // 正在保存\n  Error       // 发生错误\n};\n\n// 回放缓冲配置（运行时参数）\nexport struct ReplayBufferConfig {\n  // 后台录制参数（来自 recording 或 motion_photo）\n  std::uint32_t fps = 30;               // 帧率\n  std::uint32_t bitrate = 20'000'000;   // 视频比特率\n  std::uint32_t quality = 70;           // 质量值 (0-100, VBR 模式)\n  std::string codec = \"h264\";           // 视频编码格式\n  std::string rate_control = \"cbr\";     // 码率控制模式\n  std::string encoder_mode = \"auto\";    // 编码器模式\n  std::uint32_t keyframe_interval = 1;  // 关键帧间隔（秒），裁剪精度\n\n  // 捕获配置\n  bool use_recording_capture_options = false;  // 是否启用录制继承的捕获参数\n  bool capture_client_area = false;            // 是否只捕获客户区\n  bool capture_cursor = false;                 // 是否捕获鼠标指针\n\n  // 音频配置\n  std::string audio_source = \"system\";    // 音频源: \"none\" | \"system\" | \"game_only\"\n  std::uint32_t audio_bitrate = 256'000;  // 音频码率\n\n  // 即时回放参数\n  std::uint32_t max_duration = 30;  // 即时回放最大时长（秒）\n\n  // Motion Photo 参数\n  std::uint32_t motion_photo_duration = 3;       // Motion Photo 视频时长（秒）\n  std::uint32_t motion_photo_resolution = 1080;  // Motion Photo 目标短边分辨率，0 表示不缩放\n};\n\n}  // namespace Features::ReplayBuffer::Types\n"
  },
  {
    "path": "src/features/replay_buffer/usecase.cpp",
    "content": "module;\n\nmodule Features.ReplayBuffer.UseCase;\n\nimport std;\nimport Core.State;\nimport Features.ReplayBuffer;\nimport Features.ReplayBuffer.Types;\nimport Features.ReplayBuffer.State;\nimport Features.ReplayBuffer.MotionPhoto;\nimport Features.Recording.State;\nimport Utils.Media.VideoScaler;\nimport Features.Recording.Types;\nimport Features.Settings;\nimport Features.Settings.State;\nimport Features.WindowControl;\nimport Utils.Logger;\nimport Utils.Path;\nimport Utils.String;\n\nnamespace Features::ReplayBuffer::UseCase {\n\n// 内部辅助：检查是否需要后台录制\nauto is_buffering_needed(Core::State::AppState& state) -> bool {\n  bool motion_photo_enabled = state.replay_buffer && state.replay_buffer->motion_photo_enabled.load(\n                                                         std::memory_order_acquire);\n  bool replay_enabled =\n      state.replay_buffer && state.replay_buffer->replay_enabled.load(std::memory_order_acquire);\n  return motion_photo_enabled || replay_enabled;\n}\n\n// 内部辅助：构建配置\n// 参数源选择：\n// - 有 Instant Replay 启用 → 使用 recording 参数（高质量）\n// - 仅 Motion Photo 启用 → 使用 motion_photo 参数（轻量化）\nauto build_config(Core::State::AppState& state)\n    -> Features::ReplayBuffer::Types::ReplayBufferConfig {\n  const auto& mp_settings = state.settings->raw.features.motion_photo;\n  const auto& rb_settings = state.settings->raw.features.replay_buffer;\n  const auto& rec_settings = state.settings->raw.features.recording;\n\n  bool replay_enabled =\n      state.replay_buffer && state.replay_buffer->replay_enabled.load(std::memory_order_acquire);\n  bool motion_photo_enabled = state.replay_buffer && state.replay_buffer->motion_photo_enabled.load(\n                                                         std::memory_order_acquire);\n\n  Features::ReplayBuffer::Types::ReplayBufferConfig config;\n\n  if (replay_enabled) {\n    // Instant Replay 启用：使用 recording 参数\n    config.fps = rec_settings.fps;\n    config.bitrate = rec_settings.bitrate;\n    config.quality = rec_settings.quality;\n    config.codec = rec_settings.codec;\n    config.rate_control = rec_settings.rate_control;\n    config.encoder_mode = rec_settings.encoder_mode;\n    config.use_recording_capture_options = true;\n    config.capture_client_area = rec_settings.capture_client_area;\n    config.capture_cursor = rec_settings.capture_cursor;\n    config.audio_source = rec_settings.audio_source;\n    config.audio_bitrate = rec_settings.audio_bitrate;\n  } else if (motion_photo_enabled) {\n    // 仅 Motion Photo：使用 motion_photo 参数\n    config.fps = mp_settings.fps;\n    config.bitrate = mp_settings.bitrate;\n    config.codec = mp_settings.codec;\n    config.rate_control = \"cbr\";  // Motion Photo 使用 CBR\n    config.encoder_mode = \"auto\";\n    config.audio_source = mp_settings.audio_source;\n    config.audio_bitrate = mp_settings.audio_bitrate;\n  }\n\n  // Motion Photo 参数\n  config.motion_photo_duration = mp_settings.duration;\n  config.motion_photo_resolution = mp_settings.resolution;\n\n  // Instant Replay 参数\n  config.max_duration = rb_settings.duration;\n\n  return config;\n}\n\nauto ensure_buffering_started(Core::State::AppState& state) -> std::expected<void, std::string> {\n  if (!state.replay_buffer) {\n    return std::unexpected(\"ReplayBuffer state is not initialized\");\n  }\n\n  auto status = state.replay_buffer->status.load(std::memory_order_acquire);\n\n  // 已经在录制中\n  if (status == Features::ReplayBuffer::Types::ReplayBufferStatus::Buffering) {\n    return {};\n  }\n\n  // 检查 Recording 互斥\n  if (state.recording && state.recording->status.load(std::memory_order_acquire) ==\n                             Features::Recording::Types::RecordingStatus::Recording) {\n    return std::unexpected(\"Cannot start background capture while recording\");\n  }\n\n  // 查找目标窗口\n  std::wstring window_title = Utils::String::FromUtf8(state.settings->raw.window.target_title);\n  auto target = Features::WindowControl::find_target_window(window_title);\n  if (!target) {\n    return std::unexpected(\"Target window not found\");\n  }\n\n  auto config = build_config(state);\n  auto result = Features::ReplayBuffer::start_buffering(*state.replay_buffer, *target, config);\n  if (!result) {\n    return result;\n  }\n\n  Logger().info(\"Background capture started\");\n  return {};\n}\n\nauto ensure_buffering_stopped(Core::State::AppState& state) -> void {\n  if (!state.replay_buffer) {\n    return;\n  }\n\n  auto status = state.replay_buffer->status.load(std::memory_order_acquire);\n  if (status == Features::ReplayBuffer::Types::ReplayBufferStatus::Buffering) {\n    Features::ReplayBuffer::stop_buffering(*state.replay_buffer);\n    Logger().info(\"Background capture stopped\");\n  }\n}\n\nauto toggle_motion_photo(Core::State::AppState& state) -> std::expected<void, std::string> {\n  if (!state.replay_buffer) {\n    return std::unexpected(\"ReplayBuffer state is not initialized\");\n  }\n\n  // 1. 切换运行时状态（不持久化）\n  bool current = state.replay_buffer->motion_photo_enabled.load(std::memory_order_acquire);\n  bool new_enabled = !current;\n  state.replay_buffer->motion_photo_enabled.store(new_enabled, std::memory_order_release);\n\n  // 2. 根据新状态启动或停止后台录制\n  if (new_enabled) {\n    auto result = ensure_buffering_started(state);\n    if (!result) {\n      // 启动失败，回滚状态\n      state.replay_buffer->motion_photo_enabled.store(false, std::memory_order_release);\n      return result;\n    }\n    Logger().info(\"Motion Photo enabled\");\n  } else {\n    // 如果即时回放也关闭，停止后台录制\n    if (!is_buffering_needed(state)) {\n      ensure_buffering_stopped(state);\n    }\n    Logger().info(\"Motion Photo disabled\");\n  }\n\n  return {};\n}\n\nauto toggle_replay_buffer(Core::State::AppState& state) -> std::expected<void, std::string> {\n  if (!state.replay_buffer) {\n    return std::unexpected(\"ReplayBuffer state is not initialized\");\n  }\n\n  // 1. 切换运行时状态\n  bool current = state.replay_buffer->replay_enabled.load(std::memory_order_acquire);\n  bool new_enabled = !current;\n  state.replay_buffer->replay_enabled.store(new_enabled, std::memory_order_release);\n\n  // 2. 根据新状态启动或停止后台录制\n  if (new_enabled) {\n    auto result = ensure_buffering_started(state);\n    if (!result) {\n      // 启动失败，回滚状态\n      state.replay_buffer->replay_enabled.store(false, std::memory_order_release);\n      return result;\n    }\n    Logger().info(\"Instant Replay enabled\");\n  } else {\n    // 如果 Motion Photo 也关闭，停止后台录制\n    if (!is_buffering_needed(state)) {\n      ensure_buffering_stopped(state);\n    }\n    Logger().info(\"Instant Replay disabled\");\n  }\n\n  return {};\n}\n\nauto save_motion_photo(Core::State::AppState& state, const std::filesystem::path& jpeg_path)\n    -> std::expected<std::filesystem::path, std::string> {\n  if (!state.replay_buffer) {\n    return std::unexpected(\"ReplayBuffer state is not initialized\");\n  }\n\n  if (state.replay_buffer->status.load(std::memory_order_acquire) !=\n      Features::ReplayBuffer::Types::ReplayBufferStatus::Buffering) {\n    return std::unexpected(\"ReplayBuffer is not buffering\");\n  }\n\n  // 1. 先保存原始 MP4（从环形缓冲导出）\n  // 使用时间戳生成唯一文件名，避免多个并发任务冲突\n  double duration = static_cast<double>(state.replay_buffer->config.motion_photo_duration);\n  auto unique_suffix = std::chrono::steady_clock::now().time_since_epoch().count();\n  auto raw_mp4_path =\n      state.replay_buffer->cache_dir / std::format(\"motion_photo_raw_{}.mp4\", unique_suffix);\n\n  auto save_result =\n      Features::ReplayBuffer::save_replay(*state.replay_buffer, duration, raw_mp4_path);\n  if (!save_result) {\n    return std::unexpected(\"Failed to save raw video: \" + save_result.error());\n  }\n\n  // 2. 根据 motion_photo_resolution 决定是否缩放\n  const auto& mp_settings = state.settings->raw.features.motion_photo;\n  auto mp4_for_merge = raw_mp4_path;\n  std::filesystem::path scaled_mp4_path;\n\n  if (mp_settings.resolution > 0) {\n    scaled_mp4_path =\n        state.replay_buffer->cache_dir / std::format(\"motion_photo_scaled_{}.mp4\", unique_suffix);\n\n    // 根据设置选择码率控制模式\n    auto rate_control = mp_settings.rate_control == \"vbr\"\n                            ? Utils::Media::VideoScaler::RateControl::VBR\n                            : Utils::Media::VideoScaler::RateControl::CBR;\n\n    // 根据设置选择视频编码格式\n    auto video_codec = mp_settings.codec == \"h265\" ? Utils::Media::VideoScaler::VideoCodec::H265\n                                                   : Utils::Media::VideoScaler::VideoCodec::H264;\n\n    Utils::Media::VideoScaler::ScaleConfig scale_cfg{\n        .target_short_edge = mp_settings.resolution,\n        .bitrate = mp_settings.bitrate,\n        .fps = mp_settings.fps,\n        .rate_control = rate_control,\n        .quality = mp_settings.quality,\n        .codec = video_codec,\n        .audio_bitrate = mp_settings.audio_bitrate,\n    };\n\n    auto scale_result =\n        Utils::Media::VideoScaler::scale_video_file(raw_mp4_path, scaled_mp4_path, scale_cfg);\n    if (scale_result && scale_result->scaled) {\n      // 缩放成功，使用缩放后的文件\n      mp4_for_merge = scaled_mp4_path;\n    } else if (!scale_result) {\n      // 缩放失败，回退到原始 MP4\n      Logger().warn(\"Failed to scale video, using raw: {}\", scale_result.error());\n    }\n    // scale_result && !scale_result->scaled: 源分辨率已符合目标，使用原始 MP4\n  }\n\n  // 3. 生成输出路径：使用 output_dir（Videos/SpinningMomo 或用户配置），文件名 stem+MP+ext\n  auto output_dir_result =\n      Utils::Path::GetOutputDirectory(state.settings->raw.features.output_dir_path);\n  if (!output_dir_result) {\n    return std::unexpected(\"Failed to get output directory: \" + output_dir_result.error());\n  }\n  const auto& output_dir = output_dir_result.value();\n\n  auto output_path =\n      output_dir / (jpeg_path.stem().string() + \"MP\" + jpeg_path.extension().string());\n\n  // 4. 合成 Motion Photo\n  std::int64_t presentation_timestamp_us = static_cast<std::int64_t>(duration * 1'000'000);\n  auto mp_result = Features::ReplayBuffer::MotionPhoto::create_motion_photo(\n      jpeg_path, mp4_for_merge, output_path, presentation_timestamp_us);\n\n  // 5. 清理临时文件\n  std::error_code ec;\n  std::filesystem::remove(raw_mp4_path, ec);\n  if (!scaled_mp4_path.empty()) {\n    std::filesystem::remove(scaled_mp4_path, ec);\n  }\n  std::filesystem::remove(jpeg_path, ec);\n\n  if (!mp_result) {\n    return std::unexpected(\"Failed to create Motion Photo: \" + mp_result.error());\n  }\n\n  Logger().info(\"Motion Photo saved: {}\", output_path.string());\n  return output_path;\n}\n\nauto save_replay(Core::State::AppState& state)\n    -> std::expected<std::filesystem::path, std::string> {\n  if (!state.replay_buffer) {\n    return std::unexpected(\"ReplayBuffer state is not initialized\");\n  }\n\n  if (state.replay_buffer->status.load(std::memory_order_acquire) !=\n      Features::ReplayBuffer::Types::ReplayBufferStatus::Buffering) {\n    return std::unexpected(\"ReplayBuffer is not buffering\");\n  }\n\n  // 1. 生成输出路径\n  auto output_dir_result =\n      Utils::Path::GetOutputDirectory(state.settings->raw.features.output_dir_path);\n  if (!output_dir_result) {\n    return std::unexpected(\"Failed to get output directory: \" + output_dir_result.error());\n  }\n  const auto& output_dir = output_dir_result.value();\n\n  auto now = std::chrono::system_clock::now();\n  auto filename = std::format(\"replay_{:%Y%m%d_%H%M%S}.mp4\", now);\n  auto output_path = output_dir / filename;\n\n  // 2. 直接从环形缓冲导出（stream copy，无转码）\n  double duration = static_cast<double>(state.replay_buffer->config.max_duration);\n  auto save_result =\n      Features::ReplayBuffer::save_replay(*state.replay_buffer, duration, output_path);\n  if (!save_result) {\n    return std::unexpected(\"Failed to save replay: \" + save_result.error());\n  }\n\n  Logger().info(\"Replay saved: {}\", output_path.string());\n  return output_path;\n}\n\n}  // namespace Features::ReplayBuffer::UseCase\n"
  },
  {
    "path": "src/features/replay_buffer/usecase.ixx",
    "content": "module;\n\nexport module Features.ReplayBuffer.UseCase;\n\nimport std;\nimport Core.State;\n\nnamespace Features::ReplayBuffer::UseCase {\n\n// === 功能开关 ===\n\n// 切换动态照片模式（仅运行时状态）\nexport auto toggle_motion_photo(Core::State::AppState& state) -> std::expected<void, std::string>;\n\n// 切换即时回放模式（仅运行时状态）\nexport auto toggle_replay_buffer(Core::State::AppState& state) -> std::expected<void, std::string>;\n\n// === 保存操作 ===\n\n// 保存 Motion Photo（截图完成后调用）\n// jpeg_path: 已保存的 JPEG 截图路径\n// 返回: Motion Photo 文件路径\nexport auto save_motion_photo(Core::State::AppState& state, const std::filesystem::path& jpeg_path)\n    -> std::expected<std::filesystem::path, std::string>;\n\n// 保存即时回放\n// 返回: 回放视频文件路径\nexport auto save_replay(Core::State::AppState& state)\n    -> std::expected<std::filesystem::path, std::string>;\n\n// === 内部接口（用于 Initializer 自动恢复）===\n\n// 确保后台录制已启动（当任一功能需要时调用）\nexport auto ensure_buffering_started(Core::State::AppState& state)\n    -> std::expected<void, std::string>;\n\n// 确保后台录制已停止（当两个功能都关闭时调用）\nexport auto ensure_buffering_stopped(Core::State::AppState& state) -> void;\n\n}  // namespace Features::ReplayBuffer::UseCase\n"
  },
  {
    "path": "src/features/screenshot/screenshot.cpp",
    "content": "module;\n\n#include <wil/result.h>\n\nmodule Features.Screenshot;\n\nimport std;\nimport Core.State;\nimport Core.State.RuntimeInfo;\nimport Features.Screenshot.State;\nimport Features.Settings.State;\nimport Utils.Logger;\nimport Utils.Path;\nimport Utils.String;\nimport Utils.Graphics.Capture;\nimport Utils.Graphics.D3D;\nimport Utils.Image;\nimport <d3d11.h>;\nimport <wil/com.h>;\nimport <wincodec.h>;\nimport <windows.h>;\n\nnamespace Features::Screenshot {\n\n// WIC 编码保存纹理\nauto save_texture_with_wic(ID3D11Texture2D* texture, const std::wstring& file_path,\n                           Utils::Image::ImageFormat format = Utils::Image::ImageFormat::PNG,\n                           float jpeg_quality = 1.0f) -> std::expected<void, std::string> {\n  try {\n    if (!texture) {\n      return std::unexpected(\"Texture cannot be null\");\n    }\n\n    // 获取纹理描述\n    D3D11_TEXTURE2D_DESC desc;\n    texture->GetDesc(&desc);\n\n    // 获取设备和上下文\n    wil::com_ptr<ID3D11Device> device;\n    texture->GetDevice(device.put());\n    THROW_HR_IF_NULL(E_POINTER, device);\n\n    wil::com_ptr<ID3D11DeviceContext> context;\n    device->GetImmediateContext(context.put());\n    THROW_HR_IF_NULL(E_POINTER, context);\n\n    // 创建暂存纹理\n    D3D11_TEXTURE2D_DESC staging_desc = desc;\n    staging_desc.Usage = D3D11_USAGE_STAGING;\n    staging_desc.CPUAccessFlags = D3D11_CPU_ACCESS_READ;\n    staging_desc.BindFlags = 0;\n    staging_desc.MiscFlags = 0;\n    staging_desc.ArraySize = 1;\n    staging_desc.MipLevels = 1;\n\n    wil::com_ptr<ID3D11Texture2D> staging_texture;\n    THROW_IF_FAILED(device->CreateTexture2D(&staging_desc, nullptr, staging_texture.put()));\n\n    // 复制纹理数据\n    context->CopyResource(staging_texture.get(), texture);\n\n    // 映射纹理并写入像素数据\n    D3D11_MAPPED_SUBRESOURCE mapped{};\n    THROW_IF_FAILED(context->Map(staging_texture.get(), 0, D3D11_MAP_READ, 0, &mapped));\n\n    // 使用 RAII 确保纹理总是被正确解除映射\n    auto unmap_on_exit = wil::scope_exit([&] { context->Unmap(staging_texture.get(), 0); });\n\n    // 创建WIC工厂\n    auto wic_factory_result = Utils::Image::create_factory();\n    if (!wic_factory_result) {\n      return std::unexpected(\"Failed to create WIC imaging factory: \" + wic_factory_result.error());\n    }\n    auto wic_factory = wic_factory_result.value();\n\n    auto save_result = Utils::Image::save_pixel_data_to_file(\n        wic_factory.get(), static_cast<const uint8_t*>(mapped.pData), desc.Width, desc.Height,\n        mapped.RowPitch, file_path, format, jpeg_quality);\n\n    if (!save_result) {\n      return std::unexpected(save_result.error());\n    }\n\n    return {};\n  } catch (const wil::ResultException& e) {\n    return std::unexpected(std::format(\"WIC texture save failed: {}\", e.what()));\n  }\n}\n\n// 安全调用完成回调的辅助函数\nauto safe_call_completion_callback(const Features::Screenshot::State::ScreenshotRequest& request,\n                                   bool success) -> void {\n  if (!request.completion_callback) {\n    return;\n  }\n\n  try {\n    request.completion_callback(success, request.file_path);\n  } catch (...) {\n    Logger().error(\"Exception in completion callback\");\n  }\n}\n\n// 核心截图捕获逻辑\nauto do_screenshot_capture(const Features::Screenshot::State::ScreenshotRequest& request,\n                           Features::Screenshot::State::ScreenshotState& state)\n    -> std::expected<void, std::string> {\n  try {\n    // 最小化窗口不执行截图，避免创建无法完成的捕获会话\n    if (IsIconic(request.target_window)) {\n      return std::unexpected(\"Target window is minimized\");\n    }\n\n    // 获取窗口大小\n    RECT rect;\n    THROW_IF_WIN32_BOOL_FALSE(GetWindowRect(request.target_window, &rect));\n\n    int width = rect.right - rect.left;\n    int height = rect.bottom - rect.top;\n    if (width <= 0 || height <= 0) {\n      return std::unexpected(\"Invalid window size\");\n    }\n\n    // 生成唯一的会话ID\n    auto session_id = state.next_session_id.fetch_add(1);\n\n    // 创建帧回调，通过会话ID管理生命周期\n    auto frame_callback = [&state,\n                           session_id](Utils::Graphics::Capture::Direct3D11CaptureFrame frame) {\n      bool success = false;\n\n      // 查找对应的会话信息\n      auto it = state.active_sessions.find(session_id);\n      if (it == state.active_sessions.end()) {\n        Logger().error(\"Session {} not found in frame callback\", session_id);\n        return;\n      }\n\n      auto& session_info = it->second;\n\n      // 如果使用了手动隐藏光标，在这里恢复光标显示\n      if (session_info.session.need_hide_cursor) {\n        ShowCursor(TRUE);\n      }\n\n      if (frame) {\n        auto surface = frame.Surface();\n        if (surface) {\n          auto texture =\n              Utils::Graphics::Capture::get_dxgi_interface_from_object<ID3D11Texture2D>(surface);\n          if (texture) {\n            // 直接在回调中保存纹理\n            auto save_result = save_texture_with_wic(texture.get(), session_info.request.file_path,\n                                                     session_info.request.format,\n                                                     session_info.request.jpeg_quality);\n            if (save_result) {\n              success = true;\n              Logger().debug(\"Screenshot saved successfully for session {}\", session_id);\n            } else {\n              Logger().error(\"Failed to save screenshot for session {}: {}\", session_id,\n                             save_result.error());\n            }\n          }\n        }\n      } else {\n        Logger().error(\"Captured frame is null for session {}\", session_id);\n      }\n\n      // 停止并清理捕获会话\n      Utils::Graphics::Capture::stop_capture(session_info.session);\n      Utils::Graphics::Capture::cleanup_capture_session(session_info.session);\n\n      // 调用完成回调\n      safe_call_completion_callback(session_info.request, success);\n\n      // 从活跃会话中移除\n      state.active_sessions.erase(it);\n      Logger().debug(\"Session {} completed and removed\", session_id);\n    };\n\n    // 创建捕获会话\n    auto session_result = Utils::Graphics::Capture::create_capture_session(\n        request.target_window, state.winrt_device, width, height, frame_callback);\n    if (!session_result) {\n      return std::unexpected(\"Failed to create capture session: \" + session_result.error());\n    }\n\n    // 创建会话信息并存储到状态中\n    Features::Screenshot::State::SessionInfo session_info;\n    session_info.session = std::move(session_result.value());\n    session_info.request = request;\n\n    // 如果需要手动隐藏光标，则在开始捕获前隐藏光标\n    if (session_info.session.need_hide_cursor) {\n      ShowCursor(FALSE);\n    }\n\n    state.active_sessions[session_id] = std::move(session_info);\n\n    // 开始捕获 - 不等待，直接返回\n    auto start_result =\n        Utils::Graphics::Capture::start_capture(state.active_sessions[session_id].session);\n    if (!start_result) {\n      // 如果启动失败，清理会话，恢复光标显示\n      if (state.active_sessions[session_id].session.need_hide_cursor) {\n        ShowCursor(TRUE);\n      }\n      state.active_sessions.erase(session_id);\n      return std::unexpected(\"Failed to start capture: \" + start_result.error());\n    }\n\n    Logger().debug(\"Screenshot capture started for session {}\", session_id);\n    return {};\n  } catch (const wil::ResultException& e) {\n    return std::unexpected(std::format(\"Screenshot capture failed: {}\", e.what()));\n  }\n}\n\n// 处理单个截图请求\nauto process_single_request(const Features::Screenshot::State::ScreenshotRequest& request,\n                            Core::State::AppState& app_state) -> void {\n  auto& state = *app_state.screenshot;\n  Logger().debug(\"Processing screenshot request for window: {}\",\n                 reinterpret_cast<uintptr_t>(request.target_window));\n\n  try {\n    auto result = do_screenshot_capture(request, state);\n    if (result) {\n      Logger().debug(\"Screenshot capture started successfully\");\n    } else {\n      Logger().error(\"Failed to start screenshot capture: {}\", result.error());\n      safe_call_completion_callback(request, false);\n    }\n  } catch (...) {\n    Logger().error(\"Exception during screenshot capture\");\n    safe_call_completion_callback(request, false);\n  }\n}\n\n// 启动清理定时器\nauto start_cleanup_timer(Features::Screenshot::State::ScreenshotState& state) -> void {\n  if (!state.d3d_initialized) {\n    return;\n  }\n\n  if (!state.cleanup_timer) {\n    state.cleanup_timer.emplace();\n  }\n\n  if (state.cleanup_timer->is_pending()) {\n    state.cleanup_timer->cancel();\n  }\n\n  auto result = state.cleanup_timer->set_timeout(std::chrono::milliseconds(5000), [&state]() {\n    Logger().debug(\"Screenshot cleanup timer triggered\");\n    state.request_d3d_cleanup();  // 请求清理而不是直接清理\n  });\n\n  if (!result) {\n    Logger().error(\"Failed to set screenshot cleanup timer\");\n  } else {\n    Logger().debug(\"Screenshot cleanup timer started (5 seconds)\");\n  }\n}\n\n// 工作线程主函数\nauto worker_thread_proc(Core::State::AppState& app_state) -> void {\n  auto& state = *app_state.screenshot;\n  Logger().debug(\"Screenshot worker thread started\");\n\n  while (!state.should_stop) {\n    Features::Screenshot::State::ScreenshotRequest request;\n    bool has_request = false;\n\n    // 等待新请求或清理请求\n    {\n      std::unique_lock<std::mutex> lock(state.worker_mutex);\n      state.worker_cv.wait(lock, [&state]() {\n        std::lock_guard<std::mutex> req_lock(state.request_mutex);\n        return state.should_stop || !state.pending_requests.empty() ||\n               state.cleanup_requested.load();\n      });\n\n      if (state.should_stop) {\n        break;\n      }\n\n      // 优先处理清理请求\n      if (state.cleanup_requested.load()) {\n        // 确保没有活跃会话时才清理\n        if (state.active_sessions.empty()) {\n          Logger().debug(\"Processing D3D cleanup request\");\n          state.cleanup_d3d_resources();\n          state.cleanup_requested = false;\n          Logger().debug(\"D3D resources cleaned up by worker thread\");\n        } else {\n          Logger().debug(\"Cleanup requested but active sessions exist, deferring cleanup\");\n        }\n        continue;  // 继续下一轮循环\n      }\n\n      // 获取正常请求\n      std::lock_guard<std::mutex> req_lock(state.request_mutex);\n      if (!state.pending_requests.empty()) {\n        request = state.pending_requests.front();\n        state.pending_requests.pop();\n        has_request = true;\n      }\n    }\n\n    // 处理请求\n    if (has_request) {\n      process_single_request(request, app_state);\n\n      // 如果队列为空，启动清理定时器\n      {\n        std::lock_guard<std::mutex> req_lock(state.request_mutex);\n        if (state.pending_requests.empty()) {\n          start_cleanup_timer(state);\n        }\n      }\n    }\n  }\n\n  Logger().debug(\"Screenshot worker thread stopped\");\n}\n\n// 只初始化D3D资源（不创建工作线程）\nauto initialize_d3d_resources_only(Core::State::AppState& app_state)\n    -> std::expected<void, std::string> {\n  try {\n    auto& state = *app_state.screenshot;\n    Logger().debug(\"Initializing D3D resources only\");\n\n    // 检查系统支持\n    if (!app_state.runtime_info->is_capture_supported) {\n      return std::unexpected(\"Windows Graphics Capture is not supported\");\n    }\n\n    // 使用 WIL 的 RAII COM 初始化\n    // 这会在函数退出时自动调用 CoUninitialize，并正确处理 RPC_E_CHANGED_MODE\n    auto co_init = wil::CoInitializeEx(COINIT_APARTMENTTHREADED);\n\n    // 创建无头D3D设备（不需要窗口和交换链）\n    auto d3d_result = Utils::Graphics::D3D::create_headless_d3d_device();\n    if (!d3d_result) {\n      return std::unexpected(\"Failed to create headless D3D device: \" + d3d_result.error());\n    }\n\n    // 创建一个简化的D3DContext，只包含设备和上下文\n    Utils::Graphics::D3D::D3DContext context;\n    context.device = d3d_result->first;\n    context.context = d3d_result->second;\n    // 注意：swap_chain 和 render_target 保持为空，因为截图不需要它们\n\n    state.d3d_context = std::move(context);\n\n    // 创建WinRT设备\n    auto winrt_result =\n        Utils::Graphics::Capture::create_winrt_device(state.d3d_context->device.get());\n    if (!winrt_result) {\n      state.cleanup_d3d_resources();\n      return std::unexpected(\"Failed to create WinRT device: \" + winrt_result.error());\n    }\n\n    state.winrt_device = std::move(*winrt_result);\n    state.d3d_initialized = true;\n\n    Logger().debug(\"D3D resources initialized successfully\");\n    return {};\n  } catch (const wil::ResultException& e) {\n    return std::unexpected(std::format(\"D3D initialization failed: {}\", e.what()));\n  }\n}\n\n// 初始化完整系统\nauto initialize_system(Core::State::AppState& app_state) -> std::expected<void, std::string> {\n  auto& state = *app_state.screenshot;\n  Logger().debug(\"Initializing screenshot system\");\n\n  // 初始化D3D资源\n  auto d3d_result = initialize_d3d_resources_only(app_state);\n  if (!d3d_result) {\n    return d3d_result;\n  }\n\n  // 启动工作线程\n  state.should_stop = false;\n  state.worker_thread =\n      std::make_unique<std::jthread>([&app_state]() { worker_thread_proc(app_state); });\n\n  // 清空队列\n  std::lock_guard<std::mutex> lock(state.request_mutex);\n  while (!state.pending_requests.empty()) {\n    state.pending_requests.pop();\n  }\n\n  Logger().debug(\"Screenshot system initialized successfully\");\n  return {};\n}\n\nauto cleanup_system(Core::State::AppState& app_state) -> void {\n  auto& state = *app_state.screenshot;\n  Logger().debug(\"Cleaning up screenshot system\");\n\n  // 取消清理定时器\n  if (state.cleanup_timer && state.cleanup_timer->is_pending()) {\n    state.cleanup_timer->cancel();\n  }\n\n  // 停止工作线程\n  state.shutdown_worker();\n\n  // 清空待处理请求\n  {\n    std::lock_guard<std::mutex> lock(state.request_mutex);\n    while (!state.pending_requests.empty()) {\n      auto& request = state.pending_requests.front();\n      safe_call_completion_callback(request, false);\n      state.pending_requests.pop();\n    }\n  }\n\n  // 清理D3D资源\n  state.cleanup_d3d_resources();\n\n  Logger().debug(\"Screenshot system cleaned up\");\n}\n\nauto take_screenshot(\n    Core::State::AppState& app_state, HWND target_window,\n    std::function<void(bool success, const std::wstring& path)> completion_callback,\n    Utils::Image::ImageFormat format, float jpeg_quality,\n    std::optional<std::filesystem::path> output_dir_override) -> std::expected<void, std::string> {\n  auto& state = *app_state.screenshot;\n  if (!target_window || !IsWindow(target_window)) {\n    return std::unexpected(\"Invalid target window handle\");\n  }\n  if (IsIconic(target_window)) {\n    return std::unexpected(\"Target window is minimized\");\n  }\n\n  // 生成截图文件路径\n  std::filesystem::path screenshots_dir;\n\n  if (output_dir_override.has_value()) {\n    screenshots_dir = *output_dir_override;\n    auto ensure_result = Utils::Path::EnsureDirectoryExists(screenshots_dir);\n    if (!ensure_result) {\n      return std::unexpected(\"Failed to create output directory: \" + ensure_result.error());\n    }\n  } else {\n    auto output_dir_result =\n        Utils::Path::GetOutputDirectory(app_state.settings->raw.features.output_dir_path);\n    if (!output_dir_result) {\n      return std::unexpected(\"Failed to get output directory: \" + output_dir_result.error());\n    }\n    screenshots_dir = output_dir_result.value();\n  }\n\n  auto filename = Utils::String::FormatTimestamp(std::chrono::system_clock::now());\n  // Motion Photo 使用 JPEG 格式时替换扩展名\n  if (format == Utils::Image::ImageFormat::JPEG) {\n    auto dot_pos = filename.rfind('.');\n    if (dot_pos != std::string::npos) {\n      filename = filename.substr(0, dot_pos) + \".jpg\";\n    }\n  }\n  auto file_path = screenshots_dir / std::filesystem::path(filename);\n\n  // 自动初始化系统（如果尚未初始化）\n  if (!state.d3d_initialized || !state.worker_thread) {\n    // 取消任何待处理的清理请求\n    state.cleanup_requested = false;\n\n    Logger().debug(\"Screenshot system not initialized, initializing automatically\");\n\n    // 如果只是D3D资源被清理，但工作线程还在，只重新初始化D3D资源\n    if (!state.d3d_initialized && state.worker_thread && state.worker_thread->joinable()) {\n      Logger().debug(\"Worker thread exists, only reinitializing D3D resources\");\n      auto d3d_result = initialize_d3d_resources_only(app_state);\n      if (!d3d_result) {\n        return std::unexpected(\"Failed to reinitialize D3D resources: \" + d3d_result.error());\n      }\n    } else {\n      // 完全重新初始化系统\n      Logger().debug(\"Full system reinitialization required\");\n      auto init_result = initialize_system(app_state);\n      if (!init_result) {\n        return std::unexpected(\"Failed to initialize screenshot system: \" + init_result.error());\n      }\n    }\n    Logger().debug(\"Screenshot system initialized automatically\");\n  }\n\n  // 取消清理定时器和清理请求（新请求开始）\n  if (state.cleanup_timer && state.cleanup_timer->is_pending()) {\n    state.cleanup_timer->cancel();\n    Logger().debug(\"Cancelled screenshot cleanup timer due to new request\");\n  }\n  state.cleanup_requested = false;  // 取消任何待处理的清理请求\n\n  // 创建截图请求\n  Features::Screenshot::State::ScreenshotRequest request;\n  request.target_window = target_window;\n  request.file_path = file_path.wstring();\n  request.format = format;\n  request.jpeg_quality = jpeg_quality;\n  request.completion_callback = completion_callback;\n  request.timestamp = std::chrono::steady_clock::now();\n\n  // 添加到队列并唤醒工作线程\n  {\n    std::lock_guard<std::mutex> lock(state.request_mutex);\n    state.pending_requests.push(request);\n  }\n\n  // 唤醒工作线程\n  state.worker_cv.notify_one();\n\n  return {};\n}\n\n}  // namespace Features::Screenshot\n"
  },
  {
    "path": "src/features/screenshot/screenshot.ixx",
    "content": "module;\n\nexport module Features.Screenshot;\n\nimport std;\nimport Core.State;\nimport Utils.Image;\nimport <windows.h>;\n\nnamespace Features::Screenshot {\n\n// 主要API：异步截图\n// output_dir_override: 指定时使用该目录，否则使用 output_dir_path 或 Videos/SpinningMomo\nexport auto take_screenshot(\n    Core::State::AppState& state, HWND target_window,\n    std::function<void(bool success, const std::wstring& path)> completion_callback = nullptr,\n    Utils::Image::ImageFormat format = Utils::Image::ImageFormat::PNG, float jpeg_quality = 1.0f,\n    std::optional<std::filesystem::path> output_dir_override = std::nullopt)\n    -> std::expected<void, std::string>;\n\n// 系统管理函数\nexport auto cleanup_system(Core::State::AppState& state) -> void;\n\n}  // namespace Features::Screenshot"
  },
  {
    "path": "src/features/screenshot/state.ixx",
    "content": "module;\r\n\r\nexport module Features.Screenshot.State;\r\n\r\nimport std;\r\nimport Utils.Timeout;\r\nimport Utils.Image;\r\nimport Utils.Graphics.D3D;\r\nimport Utils.Graphics.Capture;\r\nimport <d3d11.h>;\r\nimport <windows.h>;\r\n\r\nexport namespace Features::Screenshot::State {\r\n\r\n// 截图请求结构体\r\nstruct ScreenshotRequest {\r\n  HWND target_window = nullptr;\r\n  std::wstring file_path;\r\n  Utils::Image::ImageFormat format = Utils::Image::ImageFormat::PNG;\r\n  float jpeg_quality = 1.0f;\r\n  std::function<void(bool success, const std::wstring& path)> completion_callback;\r\n  std::chrono::steady_clock::time_point timestamp = std::chrono::steady_clock::now();\r\n};\r\n\r\n// 会话信息结构体\r\nstruct SessionInfo {\r\n  Utils::Graphics::Capture::CaptureSession session;\r\n  ScreenshotRequest request;\r\n  std::chrono::steady_clock::time_point created_time = std::chrono::steady_clock::now();\r\n};\r\n\r\n// 截图系统状态\r\nstruct ScreenshotState {\r\n  // D3D资源\r\n  std::optional<Utils::Graphics::D3D::D3DContext> d3d_context;\r\n  winrt::Windows::Graphics::DirectX::Direct3D11::IDirect3DDevice winrt_device{nullptr};\r\n  bool d3d_initialized = false;\r\n\r\n  // 工作线程管理\r\n  std::unique_ptr<std::jthread> worker_thread;\r\n  std::condition_variable worker_cv;\r\n  std::mutex worker_mutex;\r\n  std::atomic<bool> should_stop{false};\r\n  std::atomic<bool> cleanup_requested{false};\r\n\r\n  // 请求队列\r\n  std::queue<ScreenshotRequest> pending_requests;\r\n  std::mutex request_mutex;\r\n\r\n  // 活跃的捕获会话管理\r\n  std::unordered_map<size_t, SessionInfo> active_sessions;\r\n  std::atomic<size_t> next_session_id{1};\r\n\r\n  // 清理定时器\r\n  std::optional<Utils::Timeout::Timeout> cleanup_timer;\r\n\r\n  // 请求D3D资源清理（线程安全）\r\n  auto request_d3d_cleanup() -> void {\r\n    cleanup_requested = true;\r\n    worker_cv.notify_one();  // 唤醒工作线程处理清理\r\n  }\r\n\r\n  // 清理活跃的捕获会话\r\n  auto cleanup_active_sessions() -> void {\r\n    for (auto& [session_id, session_info] : active_sessions) {\r\n      Utils::Graphics::Capture::stop_capture(session_info.session);\r\n      Utils::Graphics::Capture::cleanup_capture_session(session_info.session);\r\n\r\n      // 通知调用者会话被取消\r\n      if (session_info.request.completion_callback) {\r\n        session_info.request.completion_callback(false, session_info.request.file_path);\r\n      }\r\n    }\r\n    active_sessions.clear();\r\n  }\r\n\r\n  // 清理D3D资源（仅在工作线程中调用）\r\n  auto cleanup_d3d_resources() -> void {\r\n    cleanup_active_sessions();\r\n    winrt_device = nullptr;\r\n    if (d3d_context) {\r\n      Utils::Graphics::D3D::cleanup_d3d_context(*d3d_context);\r\n      d3d_context.reset();\r\n    }\r\n    d3d_initialized = false;\r\n  }\r\n\r\n  // 停止工作线程\r\n  auto shutdown_worker() -> void {\r\n    should_stop = true;\r\n    worker_cv.notify_all();\r\n    if (worker_thread && worker_thread->joinable()) {\r\n      worker_thread->join();\r\n    }\r\n    worker_thread.reset();\r\n  }\r\n};\r\n\r\n}  // namespace Features::Screenshot::State"
  },
  {
    "path": "src/features/screenshot/usecase.cpp",
    "content": "module;\n\nmodule Features.Screenshot.UseCase;\n\nimport std;\nimport Core.State;\nimport Core.Events;\nimport Core.I18n.State;\nimport Core.WorkerPool;\nimport UI.FloatingWindow.Events;\nimport Features.Screenshot;\nimport Features.Settings.State;\nimport Features.ReplayBuffer.UseCase;\nimport Features.ReplayBuffer.Types;\nimport Features.ReplayBuffer.State;\nimport Features.WindowControl;\nimport Features.Notifications;\nimport Utils.Image;\nimport Utils.Logger;\nimport Utils.Path;\nimport Utils.String;\n\nnamespace Features::Screenshot::UseCase {\n\n// 截图\nauto capture(Core::State::AppState& state) -> void {\n  std::wstring window_title = Utils::String::FromUtf8(state.settings->raw.window.target_title);\n  auto target_window = Features::WindowControl::find_target_window(window_title);\n  if (!target_window) {\n    Features::Notifications::show_notification(state, state.i18n->texts[\"label.app_name\"],\n                                               state.i18n->texts[\"message.window_not_found\"]);\n    return;\n  }\n\n  // 检查是否需要生成 Motion Photo\n  bool motion_photo_enabled =\n      state.replay_buffer &&\n      state.replay_buffer->motion_photo_enabled.load(std::memory_order_acquire) &&\n      state.replay_buffer->status.load(std::memory_order_acquire) ==\n          Features::ReplayBuffer::Types::ReplayBufferStatus::Buffering;\n\n  // 创建截图完成回调\n  // 注意：这个回调在截图工作线程的帧回调中执行，必须快速返回\n  // Motion Photo 处理提交到 WorkerPool 异步执行，通知通过事件系统发送到 UI 线程\n  auto completion_callback = [&state, motion_photo_enabled](bool success,\n                                                            const std::wstring& path) {\n    if (success) {\n      std::string path_str(path.begin(), path.end());\n\n      // 如果启用了 Motion Photo，提交到 WorkerPool 异步处理\n      if (motion_photo_enabled) {\n        // 捕获必要的数据（避免捕获引用导致生命周期问题）\n        std::filesystem::path jpeg_path_copy(path);\n        std::string app_name = state.i18n->texts[\"label.app_name\"];\n        std::string success_msg = state.i18n->texts[\"message.screenshot_success\"];\n\n        bool submitted = Core::WorkerPool::submit_task(\n            *state.worker_pool,\n            [&state, jpeg_path_copy = std::move(jpeg_path_copy), app_name = std::move(app_name),\n             success_msg = std::move(success_msg)]() {\n              auto mp_result =\n                  Features::ReplayBuffer::UseCase::save_motion_photo(state, jpeg_path_copy);\n\n              // 通过事件系统发送通知（跨线程安全）\n              if (mp_result) {\n                auto mp_path_str = mp_result->string();\n                Core::Events::post(*state.events,\n                                   UI::FloatingWindow::Events::NotificationEvent{\n                                       .title = app_name, .message = success_msg + mp_path_str});\n                Logger().debug(\"Motion Photo saved: {}\", mp_path_str);\n              } else {\n                Logger().error(\"Motion Photo creation failed: {}\", mp_result.error());\n                // 回退到普通截图通知（显示原始 JPEG 路径）\n                Core::Events::post(\n                    *state.events,\n                    UI::FloatingWindow::Events::NotificationEvent{\n                        .title = app_name, .message = success_msg + jpeg_path_copy.string()});\n              }\n            });\n\n        if (!submitted) {\n          Logger().error(\"Failed to submit Motion Photo task to worker pool\");\n          // WorkerPool 提交失败，回退到普通截图通知\n          Core::Events::post(\n              *state.events,\n              UI::FloatingWindow::Events::NotificationEvent{\n                  .title = state.i18n->texts[\"label.app_name\"],\n                  .message = state.i18n->texts[\"message.screenshot_success\"] + path_str});\n        }\n      } else {\n        // 普通截图模式：通过事件系统发送通知\n        Core::Events::post(\n            *state.events,\n            UI::FloatingWindow::Events::NotificationEvent{\n                .title = state.i18n->texts[\"label.app_name\"],\n                .message = state.i18n->texts[\"message.screenshot_success\"] + path_str});\n        Logger().debug(\"Screenshot saved successfully: {}\", path_str);\n      }\n    } else {\n      // 截图失败通知\n      Core::Events::post(*state.events,\n                         UI::FloatingWindow::Events::NotificationEvent{\n                             .title = state.i18n->texts[\"label.app_name\"],\n                             .message = state.i18n->texts[\"message.screenshot_failed\"]});\n      Logger().error(\"Screenshot capture failed\");\n    }\n  };\n\n  // 执行截图（Motion Photo 模式使用 JPEG 格式）\n  auto image_format =\n      motion_photo_enabled ? Utils::Image::ImageFormat::JPEG : Utils::Image::ImageFormat::PNG;\n  float jpeg_quality = 1.0f;  // 视觉无损\n\n  std::optional<std::filesystem::path> output_dir_override;\n  if (motion_photo_enabled) {\n    auto cache_root_result = Utils::Path::GetAppDataSubdirectory(\"cache\");\n    if (cache_root_result) {\n      auto temp_dir = cache_root_result.value() / \"motion_photo_temp\";\n      auto ensure_result = Utils::Path::EnsureDirectoryExists(temp_dir);\n      if (ensure_result) {\n        output_dir_override = temp_dir;\n      }\n    }\n  }\n\n  auto result = Features::Screenshot::take_screenshot(\n      state, *target_window, completion_callback, image_format, jpeg_quality, output_dir_override);\n  if (!result) {\n    Features::Notifications::show_notification(\n        state, state.i18n->texts[\"label.app_name\"],\n        state.i18n->texts[\"message.screenshot_failed\"] + \": \" + result.error());\n    Logger().error(\"Failed to start screenshot: {}\", result.error());\n  } else {\n    Logger().debug(\"Screenshot capture started successfully\");\n  }\n}\n\n// 处理截图事件（Event版本，用于热键系统）\nauto handle_capture_event(Core::State::AppState& state,\n                          const UI::FloatingWindow::Events::CaptureEvent& event) -> void {\n  capture(state);\n}\n\n}  // namespace Features::Screenshot::UseCase\n"
  },
  {
    "path": "src/features/screenshot/usecase.ixx",
    "content": "module;\n\nexport module Features.Screenshot.UseCase;\n\nimport Core.State;\nimport UI.FloatingWindow.Events;\n\nnamespace Features::Screenshot::UseCase {\n\n// 截图（推荐使用）\nexport auto capture(Core::State::AppState& state) -> void;\n\n// 处理截图事件（Event版本，用于热键系统）\nexport auto handle_capture_event(Core::State::AppState& state,\n                                 const UI::FloatingWindow::Events::CaptureEvent& event) -> void;\n\n}  // namespace Features::Screenshot::UseCase\n"
  },
  {
    "path": "src/features/settings/background.cpp",
    "content": "module;\n\n#include <dkm.hpp>\n\nmodule Features.Settings.Background;\n\nimport std;\nimport Core.State;\nimport Core.HttpServer.Static;\nimport Core.HttpServer.Types;\nimport Core.WebView.State;\nimport Core.WebView.Static;\nimport Core.WebView.Types;\nimport Features.Settings.Types;\nimport Utils.Image;\nimport Utils.Logger;\nimport Utils.Path;\n\nnamespace Features::Settings::Background {\n\nstruct RgbColor {\n  std::uint8_t r = 0;\n  std::uint8_t g = 0;\n  std::uint8_t b = 0;\n};\n\nstruct HslColor {\n  double h = 0.0;\n  double s = 0.0;\n  double l = 0.0;\n};\n\n// 分析前将图像短边缩放至此像素数，兼顾速度与精度\nconstexpr std::uint32_t kAnalysisSampleShortEdge = 320;\n// 亮度计算的最大采样点数，避免对大图逐像素遍历\nconstexpr std::uint32_t kMaxBrightnessSamples = 14'000;\n// 每个局部区域颜色分析的最大采样点数\nconstexpr std::uint32_t kMaxRegionSamples = 3'200;\n// 全图主色估算的最大采样点数\nconstexpr std::uint32_t kMaxPrimarySamples = 8'000;\n// 每个采样区域的边长占图像对应边长的比例（约 1/4）\nconstexpr float kRegionSizeRatio = 0.26f;\n// 相对亮度达到或超过此阈值时判定为浅色主题\nconstexpr double kLightThemeThreshold = 0.48;\n// K-Means 聚类数，用于提取区域主色\nconstexpr std::size_t kRegionClusterCount = 5;\nconstexpr std::string_view kBackgroundWebPrefix = \"/static/backgrounds/\";\nconstexpr std::wstring_view kBackgroundWebPrefixW = L\"/static/backgrounds/\";\nconstexpr std::string_view kBackgroundFileName = \"background\";\n\n// 将外部传入的路径统一成模块内部使用的格式：\n// 1. 统一分隔符为 '/'\n// 2. 去掉 query / fragment\n// 3. 去掉首尾空白\n// 这样后续逻辑只需处理一种规范形态。\nauto normalize_wallpaper_input_path(std::string_view raw_path) -> std::string {\n  std::string normalized(raw_path);\n  for (auto& ch : normalized) {\n    if (ch == '\\\\') ch = '/';\n  }\n\n  auto query_pos = normalized.find_first_of(\"?#\");\n  if (query_pos != std::string::npos) {\n    normalized = normalized.substr(0, query_pos);\n  }\n\n  auto trim = [](std::string& value) {\n    auto is_space = [](unsigned char ch) { return std::isspace(ch) != 0; };\n    while (!value.empty() && is_space(value.front())) {\n      value.erase(value.begin());\n    }\n    while (!value.empty() && is_space(value.back())) {\n      value.pop_back();\n    }\n  };\n  trim(normalized);\n  return normalized;\n}\n\n// 标准化路径用于比较，统一为小写和通用分隔符\nauto normalize_compare_path(const std::filesystem::path& path) -> std::wstring {\n  auto value = path.generic_wstring();\n  std::transform(value.begin(), value.end(), value.begin(),\n                 [](wchar_t ch) { return static_cast<wchar_t>(std::towlower(ch)); });\n  return value;\n}\n\n// 检查目标路径是否在基目录范围内，防止路径逃逸\nauto is_path_within_base(const std::filesystem::path& target, const std::filesystem::path& base)\n    -> bool {\n  auto normalized_base = normalize_compare_path(base.lexically_normal());\n  auto normalized_target = normalize_compare_path(target.lexically_normal());\n  return normalized_target == normalized_base ||\n         (normalized_target.size() > normalized_base.size() &&\n          normalized_target.starts_with(normalized_base) &&\n          normalized_target[normalized_base.size()] == L'/');\n}\n\n// 检查文件扩展名是否为支持的背景图片格式\nauto is_supported_background_extension(std::string extension) -> bool {\n  std::transform(extension.begin(), extension.end(), extension.begin(),\n                 [](unsigned char ch) { return static_cast<char>(std::tolower(ch)); });\n  return extension == \".jpg\" || extension == \".jpeg\" || extension == \".png\" ||\n         extension == \".bmp\" || extension == \".gif\" || extension == \".webp\";\n}\n\n// 获取标准化的背景图片扩展名，不支持的默认为 .jpg\nauto normalize_background_extension(const std::filesystem::path& path) -> std::string {\n  auto extension = path.extension().generic_string();\n  return is_supported_background_extension(extension) ? extension : \".jpg\";\n}\n\n// 从 URL 中提取相对路径（泛型版本）\ntemplate <typename CharT>\nauto extract_relative_path_generic(std::basic_string_view<CharT> url,\n                                   std::basic_string_view<CharT> prefix)\n    -> std::optional<std::basic_string<CharT>> {\n  if (!url.starts_with(prefix)) {\n    return std::nullopt;\n  }\n\n  auto relative = url.substr(prefix.size());\n  auto end_pos =\n      std::min(relative.find(static_cast<CharT>('?')), relative.find(static_cast<CharT>('#')));\n  if (end_pos != std::basic_string_view<CharT>::npos) {\n    relative = relative.substr(0, end_pos);\n  }\n\n  if (relative.empty()) {\n    return std::nullopt;\n  }\n\n  return std::basic_string<CharT>(relative);\n}\n\n// 从完整 URL 中提取路径部分\nauto extract_path_from_url(std::string_view url) -> std::optional<std::string> {\n  auto scheme_pos = url.find(\"://\");\n  if (scheme_pos == std::string_view::npos) {\n    return std::nullopt;\n  }\n\n  auto path_pos = url.find('/', scheme_pos + 3);\n  if (path_pos == std::string_view::npos) {\n    return std::string(\"/\");\n  }\n\n  return std::string(url.substr(path_pos));\n}\n\n// 获取应用数据目录下的 backgrounds 文件夹路径\nauto get_backgrounds_directory() -> std::expected<std::filesystem::path, std::string> {\n  return Utils::Path::GetAppDataSubdirectory(\"backgrounds\");\n}\n\n// 解析托管背景文件的绝对路径，进行安全校验\nauto resolve_managed_background_file(const std::filesystem::path& file_name)\n    -> std::expected<std::filesystem::path, std::string> {\n  // 设置里只保存文件名，不保存路径；因此这里拒绝任何目录信息。\n  if (file_name.empty() || file_name.is_absolute() || file_name.has_parent_path()) {\n    return std::unexpected(\"Invalid managed background file name\");\n  }\n\n  auto backgrounds_dir_result = get_backgrounds_directory();\n  if (!backgrounds_dir_result) {\n    return std::unexpected(\"Failed to get backgrounds directory: \" +\n                           backgrounds_dir_result.error());\n  }\n\n  auto resolved_path_result =\n      Utils::Path::NormalizePath(backgrounds_dir_result.value() / file_name);\n  if (!resolved_path_result) {\n    return std::unexpected(\"Failed to resolve managed background file path: \" +\n                           resolved_path_result.error());\n  }\n\n  auto resolved_path = resolved_path_result.value();\n  if (!is_path_within_base(resolved_path, backgrounds_dir_result.value())) {\n    return std::unexpected(\"Managed background file escapes storage directory\");\n  }\n\n  return resolved_path;\n}\n\n// 从原始路径解析托管背景文件路径\nauto resolve_managed_background_file_name(std::string_view raw_file_name)\n    -> std::expected<std::filesystem::path, std::string> {\n  auto normalized = normalize_wallpaper_input_path(raw_file_name);\n  if (normalized.empty()) {\n    return std::unexpected(\"Managed background file name is empty\");\n  }\n  return resolve_managed_background_file(std::filesystem::path(normalized));\n}\n\n// 确保背景文件存在且是普通文件\nauto ensure_background_file_exists(const std::filesystem::path& path)\n    -> std::expected<std::filesystem::path, std::string> {\n  if (!std::filesystem::exists(path) || !std::filesystem::is_regular_file(path)) {\n    return std::unexpected(\"Background file not found\");\n  }\n  return path;\n}\n\n// 解析并确保托管背景文件存在\nauto resolve_existing_managed_background_file(const std::filesystem::path& relative_path)\n    -> std::expected<std::filesystem::path, std::string> {\n  auto path_result = resolve_managed_background_file(relative_path);\n  if (!path_result) {\n    return std::unexpected(path_result.error());\n  }\n  return ensure_background_file_exists(path_result.value());\n}\n\n// 生成唯一的背景文件名（时间戳 + 随机数）\nauto generate_background_filename(const std::filesystem::path& source_path) -> std::string {\n  auto timestamp = std::chrono::duration_cast<std::chrono::milliseconds>(\n                       std::chrono::system_clock::now().time_since_epoch())\n                       .count();\n  std::mt19937 random_engine(static_cast<std::mt19937::result_type>(std::random_device{}()));\n  auto random_value = std::uniform_int_distribution<std::uint32_t>{}(random_engine);\n  return std::format(\"{}-{:x}-{:08x}{}\", kBackgroundFileName, timestamp, random_value,\n                     normalize_background_extension(source_path));\n}\n\n// 将 RGB 颜色转换为十六进制字符串格式\nauto rgb_to_hex(const RgbColor& color) -> std::string {\n  return std::format(\"#{:02X}{:02X}{:02X}\", color.r, color.g, color.b);\n}\n\n// IEC 61966-2-1 标准：将 sRGB 分量（0-255）转换为线性光强度\n// 低值段用线性近似，高值段用伽马曲线还原\nauto srgb_channel_to_linear(double channel) -> double {\n  double normalized = channel / 255.0;\n  if (normalized <= 0.04045) {\n    return normalized / 12.92;\n  }\n  return std::pow((normalized + 0.055) / 1.055, 2.4);\n}\n\n// WCAG 2.x 相对亮度公式，系数来自 BT.709 标准（人眼对绿色最敏感）\nauto relative_luminance(const RgbColor& color) -> double {\n  double r = srgb_channel_to_linear(static_cast<double>(color.r));\n  double g = srgb_channel_to_linear(static_cast<double>(color.g));\n  double b = srgb_channel_to_linear(static_cast<double>(color.b));\n  return 0.2126 * r + 0.7152 * g + 0.0722 * b;\n}\n\n// 将 RGB 颜色转换为 HSL 颜色空间\nauto rgb_to_hsl(const RgbColor& color) -> HslColor {\n  double r = static_cast<double>(color.r) / 255.0;\n  double g = static_cast<double>(color.g) / 255.0;\n  double b = static_cast<double>(color.b) / 255.0;\n\n  double max_value = std::max({r, g, b});\n  double min_value = std::min({r, g, b});\n  double delta = max_value - min_value;\n\n  double h = 0.0;\n  double l = (max_value + min_value) * 0.5;\n  double s = 0.0;\n\n  if (delta > 0.0) {\n    s = delta / (1.0 - std::abs(2.0 * l - 1.0));\n\n    if (max_value == r) {\n      h = std::fmod((g - b) / delta, 6.0);\n    } else if (max_value == g) {\n      h = ((b - r) / delta) + 2.0;\n    } else {\n      h = ((r - g) / delta) + 4.0;\n    }\n\n    h *= 60.0;\n    if (h < 0.0) {\n      h += 360.0;\n    }\n  }\n\n  return HslColor{\n      .h = h,\n      .s = s * 100.0,\n      .l = l * 100.0,\n  };\n}\n\n// 将 HSL 颜色转换回 RGB 颜色空间\nauto hsl_to_rgb(const HslColor& hsl) -> RgbColor {\n  double saturation = std::clamp(hsl.s, 0.0, 100.0) / 100.0;\n  double lightness = std::clamp(hsl.l, 0.0, 100.0) / 100.0;\n  double chroma = (1.0 - std::abs(2.0 * lightness - 1.0)) * saturation;\n  double hue_prime = std::fmod(hsl.h, 360.0) / 60.0;\n  if (hue_prime < 0.0) hue_prime += 6.0;\n  double x = chroma * (1.0 - std::abs(std::fmod(hue_prime, 2.0) - 1.0));\n\n  double r = 0.0;\n  double g = 0.0;\n  double b = 0.0;\n\n  if (hue_prime < 1.0) {\n    r = chroma;\n    g = x;\n  } else if (hue_prime < 2.0) {\n    r = x;\n    g = chroma;\n  } else if (hue_prime < 3.0) {\n    g = chroma;\n    b = x;\n  } else if (hue_prime < 4.0) {\n    g = x;\n    b = chroma;\n  } else if (hue_prime < 5.0) {\n    r = x;\n    b = chroma;\n  } else {\n    r = chroma;\n    b = x;\n  }\n\n  double m = lightness - chroma * 0.5;\n  auto to_channel = [](double value) -> std::uint8_t {\n    long rounded = std::lround(value * 255.0);\n    return static_cast<std::uint8_t>(std::clamp(rounded, 0l, 255l));\n  };\n\n  return RgbColor{\n      .r = to_channel(r + m),\n      .g = to_channel(g + m),\n      .b = to_channel(b + m),\n  };\n}\n\n// 根据目标亮度计算饱和度上限（倒 U 形曲线）。\n// 在 L≈55% 时峰值约 85%，两端降至约 35%，使极亮和极暗颜色自然淡雅。\nauto saturation_cap_for_lightness(double l) -> double {\n  double t = (l - 55.0) / 55.0;\n  return 35.0 + 50.0 * (1.0 - t * t);\n}\n\n// 将输入颜色的亮度钳制到目标区间，饱和度由目标亮度决定上限（只降不升）。\n// 低饱和输入（S<12）视为灰色系，保持低饱和不推高色调。\nauto compensate_for_theme(const RgbColor& color, std::string_view theme_mode, bool primary)\n    -> RgbColor {\n  auto hsl = rgb_to_hsl(color);\n\n  double l_min, l_max;\n  if (theme_mode == \"light\") {\n    l_min = primary ? 35.0 : 80.0;\n    l_max = primary ? 55.0 : 92.0;\n  } else {\n    // 暗色 UI 上 accent 需更亮一些，否则从壁纸提取的主色容易偏闷\n    l_min = primary ? 78.0 : 6.0;\n    l_max = primary ? 90.0 : 18.0;\n  }\n\n  hsl.l = std::clamp(hsl.l, l_min, l_max);\n\n  if (hsl.s < 12.0) {\n    hsl.s = std::min(hsl.s, 20.0);\n  } else {\n    double s_max = saturation_cap_for_lightness(hsl.l);\n    if (!primary) s_max *= 0.5;\n    hsl.s = std::min(hsl.s, s_max);\n  }\n\n  return hsl_to_rgb(hsl);\n}\n\n// 解析壁纸路径，支持本地绝对路径和托管路径两种格式\nauto resolve_background_file(std::string_view raw_file_name)\n    -> std::expected<std::filesystem::path, std::string> {\n  auto managed_file_result = resolve_managed_background_file_name(raw_file_name);\n  if (!managed_file_result) {\n    return std::unexpected(managed_file_result.error());\n  }\n\n  auto existing_path_result = ensure_background_file_exists(managed_file_result.value());\n  if (!existing_path_result) {\n    return std::unexpected(\"Background file does not exist: \" +\n                           managed_file_result.value().string());\n  }\n\n  return existing_path_result.value();\n}\n\n// 加载壁纸位图并缩放到分析尺寸，转换为 BGRA 格式\nauto load_wallpaper_bitmap(const std::filesystem::path& path)\n    -> std::expected<Utils::Image::BGRABitmapData, std::string> {\n  auto wic_result = Utils::Image::get_thread_wic_factory();\n  if (!wic_result) {\n    return std::unexpected(\"Failed to initialize WIC factory: \" + wic_result.error());\n  }\n\n  auto frame_result = Utils::Image::load_bitmap_frame(wic_result->get(), path);\n  if (!frame_result) {\n    return std::unexpected(frame_result.error());\n  }\n\n  auto scaled_result =\n      Utils::Image::scale_bitmap(wic_result->get(), frame_result->get(), kAnalysisSampleShortEdge);\n  if (!scaled_result) {\n    return std::unexpected(scaled_result.error());\n  }\n\n  auto bgra_result = Utils::Image::convert_to_bgra_bitmap(wic_result->get(), scaled_result->get());\n  if (!bgra_result) {\n    return std::unexpected(bgra_result.error());\n  }\n\n  auto bitmap_data_result = Utils::Image::copy_bgra_bitmap_data(bgra_result->get());\n  if (!bitmap_data_result) {\n    return std::unexpected(bitmap_data_result.error());\n  }\n\n  return bitmap_data_result.value();\n}\n\n// 从矩形区域内均匀采样像素点（RGB 三元组）。\n// pixel_step 由区域面积与最大采样数的比值开方得出，保证空间覆盖均匀且总量不超限。\n// alpha < 16 的近透明像素被跳过，避免影响颜色统计。\nauto collect_points_in_rect(const Utils::Image::BGRABitmapData& bitmap, int x0, int y0, int x1,\n                            int y1, std::uint32_t max_samples)\n    -> std::vector<std::array<float, 3>> {\n  std::vector<std::array<float, 3>> points;\n  if (x0 >= x1 || y0 >= y1) {\n    return points;\n  }\n\n  std::uint64_t area = static_cast<std::uint64_t>(x1 - x0) * static_cast<std::uint64_t>(y1 - y0);\n  int pixel_step =\n      std::max(1, static_cast<int>(std::sqrt(static_cast<double>(area) / max_samples)));\n\n  points.reserve(static_cast<std::size_t>(std::min<std::uint64_t>(area, max_samples)));\n  for (int y = y0; y < y1; y += pixel_step) {\n    for (int x = x0; x < x1; x += pixel_step) {\n      std::uint64_t offset =\n          static_cast<std::uint64_t>(y) * bitmap.stride + static_cast<std::uint64_t>(x) * 4;\n      std::uint8_t b = bitmap.pixels[offset + 0];\n      std::uint8_t g = bitmap.pixels[offset + 1];\n      std::uint8_t r = bitmap.pixels[offset + 2];\n      std::uint8_t a = bitmap.pixels[offset + 3];\n      if (a < 16) continue;\n\n      points.push_back({static_cast<float>(r), static_cast<float>(g), static_cast<float>(b)});\n    }\n  }\n\n  return points;\n}\n\n// 从像素点集合计算平均颜色\nauto average_color_from_points(const std::vector<std::array<float, 3>>& points) -> RgbColor {\n  if (points.empty()) {\n    return RgbColor{};\n  }\n\n  double sum_r = 0.0;\n  double sum_g = 0.0;\n  double sum_b = 0.0;\n  for (const auto& point : points) {\n    sum_r += point[0];\n    sum_g += point[1];\n    sum_b += point[2];\n  }\n\n  auto to_channel = [count = static_cast<double>(points.size())](double value) -> std::uint8_t {\n    long rounded = std::lround(value / count);\n    return static_cast<std::uint8_t>(std::clamp(rounded, 0l, 255l));\n  };\n\n  return RgbColor{\n      .r = to_channel(sum_r),\n      .g = to_channel(sum_g),\n      .b = to_channel(sum_b),\n  };\n}\n\n// 使用 K-Means（Lloyd 算法）对像素点聚类，返回像素数最多的簇的中心色作为主色。\n// 当点集过少无法聚类，或 K-Means 抛出异常时，回退到简单算术平均色。\nauto dominant_color_from_points(const std::vector<std::array<float, 3>>& points)\n    -> std::expected<RgbColor, std::string> {\n  if (points.empty()) {\n    return std::unexpected(\"No valid pixels found in analysis region\");\n  }\n\n  std::size_t cluster_count = std::clamp(kRegionClusterCount, std::size_t{1}, points.size());\n  try {\n    auto [means, labels] = dkm::kmeans_lloyd(points, cluster_count);\n    std::vector<std::size_t> counts(means.size(), 0);\n    for (const auto& label : labels) {\n      counts[static_cast<std::size_t>(label)] += 1;\n    }\n\n    auto max_it = std::ranges::max_element(counts);\n    std::size_t dominant_index = static_cast<std::size_t>(std::distance(counts.begin(), max_it));\n    auto mean = means[dominant_index];\n\n    return RgbColor{\n        .r = static_cast<std::uint8_t>(std::clamp(std::lround(mean[0]), 0l, 255l)),\n        .g = static_cast<std::uint8_t>(std::clamp(std::lround(mean[1]), 0l, 255l)),\n        .b = static_cast<std::uint8_t>(std::clamp(std::lround(mean[2]), 0l, 255l)),\n    };\n  } catch (...) {\n    return average_color_from_points(points);\n  }\n}\n\n// 在图像指定相对坐标位置采样区域主色\nauto sample_region_color(const Utils::Image::BGRABitmapData& bitmap, float x_ratio, float y_ratio)\n    -> std::expected<RgbColor, std::string> {\n  int width = static_cast<int>(bitmap.width);\n  int height = static_cast<int>(bitmap.height);\n  if (width <= 0 || height <= 0) {\n    return std::unexpected(\"Wallpaper bitmap is empty\");\n  }\n\n  int region_width = std::max(8, static_cast<int>(std::lround(bitmap.width * kRegionSizeRatio)));\n  int region_height = std::max(8, static_cast<int>(std::lround(bitmap.height * kRegionSizeRatio)));\n  int center_x = static_cast<int>(std::lround(bitmap.width * x_ratio));\n  int center_y = static_cast<int>(std::lround(bitmap.height * y_ratio));\n\n  int x0 = std::clamp(center_x - region_width / 2, 0, width - 1);\n  int y0 = std::clamp(center_y - region_height / 2, 0, height - 1);\n  int x1 = std::clamp(x0 + region_width, 1, width);\n  int y1 = std::clamp(y0 + region_height, 1, height);\n\n  auto points = collect_points_in_rect(bitmap, x0, y0, x1, y1, kMaxRegionSamples);\n  return dominant_color_from_points(points);\n}\n\n// 估算全图主色\nauto estimate_primary_color(const Utils::Image::BGRABitmapData& bitmap)\n    -> std::expected<RgbColor, std::string> {\n  auto points = collect_points_in_rect(bitmap, 0, 0, static_cast<int>(bitmap.width),\n                                       static_cast<int>(bitmap.height), kMaxPrimarySamples);\n  return dominant_color_from_points(points);\n}\n\n// 计算壁纸的平均相对亮度，以 alpha 通道作为加权系数，忽略完全透明像素。\n// 使用固定步长均匀采样，将计算量控制在 kMaxBrightnessSamples 以内。\nauto compute_wallpaper_brightness(const Utils::Image::BGRABitmapData& bitmap) -> double {\n  std::uint64_t total_pixels = static_cast<std::uint64_t>(bitmap.width) * bitmap.height;\n  if (total_pixels == 0) {\n    return 0.0;\n  }\n\n  std::uint64_t step = std::max<std::uint64_t>(1, total_pixels / kMaxBrightnessSamples);\n  double total = 0.0;\n  double alpha_weight = 0.0;\n\n  for (std::uint64_t index = 0; index < total_pixels; index += step) {\n    std::uint32_t y = static_cast<std::uint32_t>(index / bitmap.width);\n    std::uint32_t x = static_cast<std::uint32_t>(index % bitmap.width);\n    std::uint64_t offset =\n        static_cast<std::uint64_t>(y) * bitmap.stride + static_cast<std::uint64_t>(x) * 4;\n    double alpha = static_cast<double>(bitmap.pixels[offset + 3]) / 255.0;\n    if (alpha <= 0.0) continue;\n\n    RgbColor color{\n        .r = bitmap.pixels[offset + 2],\n        .g = bitmap.pixels[offset + 1],\n        .b = bitmap.pixels[offset + 0],\n    };\n    total += relative_luminance(color) * alpha;\n    alpha_weight += alpha;\n  }\n\n  if (alpha_weight <= 0.0) {\n    return 0.0;\n  }\n\n  return std::clamp(total / alpha_weight, 0.0, 1.0);\n}\n\n// 根据亮度值判断主题模式（亮/暗）\nauto resolve_theme_mode(double brightness) -> std::string {\n  return brightness >= kLightThemeThreshold ? \"light\" : \"dark\";\n}\n\n// 返回叠加层颜色的采样锚点（图像相对坐标），坐标以图像宽高百分比表示。\n// mode 1：仅取中心；mode 2：对角两点；mode 3：对角线三点；mode 4（默认）：四角均匀分布。\nauto overlay_sample_points(int mode) -> std::vector<std::pair<float, float>> {\n  switch (mode) {\n    case 1:\n      return {{0.5f, 0.5f}};\n    case 2:\n      return {\n          {0.2f, 0.2f},\n          {0.8f, 0.8f},\n      };\n    case 3:\n      return {\n          {0.18f, 0.2f},\n          {0.5f, 0.5f},\n          {0.82f, 0.8f},\n      };\n    case 4:\n    default:\n      return {\n          {0.18f, 0.2f},\n          {0.82f, 0.2f},\n          {0.82f, 0.8f},\n          {0.18f, 0.8f},\n      };\n  }\n}\n\n// 壁纸分析主入口：解析路径 → 加载并缩放位图 → 计算亮度/主题 → 提取各锚点叠加色和全图主色，\n// 所有颜色均经过主题补偿后以十六进制字符串返回。\nauto analyze_background(const Types::BackgroundAnalysisParams& params)\n    -> std::expected<Types::BackgroundAnalysisResult, std::string> {\n  auto path_result = resolve_background_file(params.image_file_name);\n  if (!path_result) {\n    return std::unexpected(path_result.error());\n  }\n\n  auto bitmap_result = load_wallpaper_bitmap(path_result.value());\n  if (!bitmap_result) {\n    return std::unexpected(\"Failed to load wallpaper bitmap: \" + bitmap_result.error());\n  }\n  auto bitmap = std::move(bitmap_result.value());\n\n  double brightness = compute_wallpaper_brightness(bitmap);\n  auto theme_mode = resolve_theme_mode(brightness);\n\n  int overlay_mode = std::clamp(params.overlay_mode, 1, 4);\n  auto sample_points = overlay_sample_points(overlay_mode);\n\n  std::vector<std::string> overlay_colors;\n  overlay_colors.reserve(sample_points.size());\n  for (const auto& [x_ratio, y_ratio] : sample_points) {\n    auto region_color = sample_region_color(bitmap, x_ratio, y_ratio);\n    if (!region_color) {\n      return std::unexpected(region_color.error());\n    }\n    auto compensated = compensate_for_theme(region_color.value(), theme_mode, false);\n    overlay_colors.push_back(rgb_to_hex(compensated));\n  }\n\n  auto primary_color = estimate_primary_color(bitmap);\n  if (!primary_color) {\n    return std::unexpected(primary_color.error());\n  }\n\n  auto compensated_primary = compensate_for_theme(primary_color.value(), theme_mode, true);\n\n  return Types::BackgroundAnalysisResult{\n      .theme_mode = theme_mode,\n      .primary_color = rgb_to_hex(compensated_primary),\n      .overlay_colors = std::move(overlay_colors),\n      .brightness = brightness,\n  };\n}\n\n// 导入背景图片到应用托管目录\nauto import_background_image(const Types::BackgroundImportParams& params)\n    -> std::expected<Types::BackgroundImportResult, std::string> {\n  try {\n    // 导入阶段把用户原始文件复制到 app data 托管目录，\n    // 设置里只保存逻辑路径，不直接保存物理文件系统路径。\n    auto source_path_result = Utils::Path::NormalizePath(std::filesystem::path(params.source_path));\n    if (!source_path_result) {\n      return std::unexpected(\"Failed to resolve source background path: \" +\n                             source_path_result.error());\n    }\n\n    auto source_path = source_path_result.value();\n    if (!std::filesystem::exists(source_path) || !std::filesystem::is_regular_file(source_path)) {\n      return std::unexpected(\"Background source file does not exist: \" + source_path.string());\n    }\n\n    auto backgrounds_dir_result = get_backgrounds_directory();\n    if (!backgrounds_dir_result) {\n      return std::unexpected(\"Failed to get backgrounds directory: \" +\n                             backgrounds_dir_result.error());\n    }\n\n    auto destination_path =\n        backgrounds_dir_result.value() / generate_background_filename(source_path);\n    std::filesystem::copy_file(source_path, destination_path,\n                               std::filesystem::copy_options::overwrite_existing);\n\n    return Types::BackgroundImportResult{\n        .image_file_name = destination_path.filename().generic_string(),\n    };\n  } catch (const std::exception& e) {\n    return std::unexpected(\"Failed to import background image: \" + std::string(e.what()));\n  }\n}\n\n// 删除托管的背景图片\nauto remove_background_image(const Types::BackgroundRemoveParams& params)\n    -> std::expected<Types::BackgroundRemoveResult, std::string> {\n  try {\n    // 删除只接受托管路径，避免误删任意本地文件。\n    auto resolved_path_result = resolve_managed_background_file_name(params.image_file_name);\n    if (!resolved_path_result) {\n      return std::unexpected(resolved_path_result.error());\n    }\n\n    bool removed = false;\n    if (std::filesystem::exists(resolved_path_result.value())) {\n      removed = std::filesystem::remove(resolved_path_result.value());\n    }\n\n    return Types::BackgroundRemoveResult{.removed = removed};\n  } catch (const std::exception& e) {\n    return std::unexpected(\"Failed to remove background image: \" + std::string(e.what()));\n  }\n}\n\n// 注册背景图片的静态资源解析器（HTTP 和 WebView 两条链路）\nauto register_static_resolvers(Core::State::AppState& app_state) -> void {\n  // 开发环境下浏览器通过 HTTP 访问 /static/backgrounds/*，\n  // 生产环境下 WebView 通过 static host 访问同一组逻辑路径。\n  // 两条链路最终都落到 app data 的背景目录，不再依赖 resources/web。\n  Core::HttpServer::Static::register_path_resolver(\n      app_state, std::string(kBackgroundWebPrefix),\n      [](std::string_view url) -> Core::HttpServer::Types::PathResolution {\n        auto relative_path = extract_relative_path_generic(url, kBackgroundWebPrefix);\n        if (!relative_path) {\n          return std::unexpected(\"Invalid managed background URL\");\n        }\n\n        auto background_path_result =\n            resolve_existing_managed_background_file(std::filesystem::path(*relative_path));\n        if (!background_path_result) {\n          return std::unexpected(background_path_result.error());\n        }\n\n        return Core::HttpServer::Types::PathResolutionData{\n            .file_path = background_path_result.value(),\n            .cache_duration = std::chrono::hours(1),\n        };\n      });\n\n  if (!app_state.webview) {\n    return;\n  }\n\n  auto web_prefix = L\"https://\" + app_state.webview->config.static_host_name +\n                    std::wstring(kBackgroundWebPrefixW);\n  Core::WebView::Static::register_web_resource_resolver(\n      app_state, web_prefix,\n      [web_prefix](std::wstring_view url) -> Core::WebView::Types::WebResourceResolution {\n        auto relative_path = extract_relative_path_generic(url, std::wstring_view(web_prefix));\n        if (!relative_path) {\n          return {.success = false,\n                  .file_path = {},\n                  .error_message = \"Invalid managed background URL\",\n                  .content_type = std::nullopt,\n                  .status_code = 404};\n        }\n\n        auto background_path_result =\n            resolve_existing_managed_background_file(std::filesystem::path(*relative_path));\n        if (!background_path_result) {\n          return {.success = false,\n                  .file_path = {},\n                  .error_message = background_path_result.error(),\n                  .content_type = std::nullopt,\n                  .status_code = 404};\n        }\n\n        return {.success = true,\n                .file_path = background_path_result.value(),\n                .error_message = {},\n                .content_type = std::nullopt,\n                .status_code = 200};\n      });\n\n  Logger().info(\"Registered background static resolvers for {}\", std::string(kBackgroundWebPrefix));\n}\n\n}  // namespace Features::Settings::Background\n"
  },
  {
    "path": "src/features/settings/background.ixx",
    "content": "module;\n\nexport module Features.Settings.Background;\n\nimport std;\nimport Core.State;\nimport Features.Settings.Types;\n\nnamespace Features::Settings::Background {\n\nexport auto analyze_background(const Types::BackgroundAnalysisParams& params)\n    -> std::expected<Types::BackgroundAnalysisResult, std::string>;\n\nexport auto import_background_image(const Types::BackgroundImportParams& params)\n    -> std::expected<Types::BackgroundImportResult, std::string>;\n\nexport auto remove_background_image(const Types::BackgroundRemoveParams& params)\n    -> std::expected<Types::BackgroundRemoveResult, std::string>;\n\nexport auto register_static_resolvers(Core::State::AppState& app_state) -> void;\n\n}  // namespace Features::Settings::Background\n"
  },
  {
    "path": "src/features/settings/compute.cpp",
    "content": "module;\r\n\r\nmodule Features.Settings.Compute;\r\n\r\nimport std;\r\nimport Core.State;\r\nimport Core.I18n.Types;\r\nimport Core.I18n.State;\r\nimport Features.Settings.Menu;\r\nimport Features.Settings.Types;\r\nimport Features.Settings.State;\r\nimport Features.Settings.Registry;\r\nimport Utils.String;\r\nimport Utils.Logger;\r\n\r\nnamespace Features::Settings::Compute {\r\n\r\nauto compute_presets_from_config(const Types::AppSettings& config,\r\n                                 const Core::I18n::Types::TextData& texts)\r\n    -> State::ComputedPresets {\r\n  State::ComputedPresets computed;\r\n\r\n  // 处理比例预设\r\n  for (const auto& ratio_id : config.ui.app_menu.aspect_ratios) {\r\n    if (auto ratio = Registry::parse_aspect_ratio(ratio_id)) {\r\n      std::wstring name(ratio_id.begin(), ratio_id.end());\r\n      computed.aspect_ratios.emplace_back(name, *ratio);\r\n    } else {\r\n      Logger().warn(\"Invalid aspect ratio in settings: '{}', skipping\", ratio_id);\r\n    }\r\n  }\r\n\r\n  // 处理分辨率预设\r\n  for (const auto& resolution_id : config.ui.app_menu.resolutions) {\r\n    if (auto resolution = Registry::parse_resolution(resolution_id)) {\r\n      std::wstring name(resolution_id.begin(), resolution_id.end());\r\n      auto [w, h] = *resolution;\r\n      computed.resolutions.emplace_back(name, w, h);\r\n    } else {\r\n      Logger().warn(\"Invalid resolution in settings: '{}', skipping\", resolution_id);\r\n    }\r\n  }\r\n\r\n  return computed;\r\n}\r\n\r\nauto trigger_compute(Core::State::AppState& app_state) -> bool {\r\n  app_state.settings->computed =\r\n      compute_presets_from_config(app_state.settings->raw, app_state.i18n->texts);\r\n  return true;\r\n}\r\n\r\n}  // namespace Features::Settings::Compute"
  },
  {
    "path": "src/features/settings/compute.ixx",
    "content": "module;\r\n\r\nexport module Features.Settings.Compute;\r\n\r\nimport Core.State;\r\n\r\nnamespace Features::Settings::Compute {\r\n\r\n// 更新状态的计算部分\r\n// 触发计算状态更新 (Reactivity Trigger)\r\nexport auto trigger_compute(Core::State::AppState& app_state) -> bool;\r\n\r\n}  // namespace Features::Settings::Compute"
  },
  {
    "path": "src/features/settings/events.ixx",
    "content": "module;\n\nexport module Features.Settings.Events;\n\nimport std;\nimport Features.Settings.Types;\n\nnamespace Features::Settings::Events {\n\n// 设置变更事件\nexport struct SettingsChangeEvent {\n  Features::Settings::Types::SettingsChangeData data;\n  \n  std::chrono::steady_clock::time_point timestamp = std::chrono::steady_clock::now();\n};\n\n}  // namespace Features::Settings::Events"
  },
  {
    "path": "src/features/settings/menu.cpp",
    "content": "module;\n\nmodule Features.Settings.Menu;\n\nimport std;\nimport Features.Settings.State;\n\nnamespace Features::Settings::Menu {\n\nauto get_ratios(const Features::Settings::State::SettingsState& state)\n    -> const std::vector<RatioPreset>& {\n  return state.computed.aspect_ratios;\n}\n\nauto get_resolutions(const Features::Settings::State::SettingsState& state)\n    -> const std::vector<ResolutionPreset>& {\n  return state.computed.resolutions;\n}\n\n}  // namespace Features::Settings::Menu\n"
  },
  {
    "path": "src/features/settings/menu.ixx",
    "content": "module;\n\nexport module Features.Settings.Menu;\n\nimport std;\n\nnamespace Features::Settings::State {\nexport struct SettingsState;\n}\n\nnamespace Features::Settings::Menu {\n\n// === Types (Merged from menu_data/types.ixx) ===\n\n// 比例预设\nexport struct RatioPreset {\n  std::wstring name;  // 比例名称\n  double ratio;       // 宽高比值\n\n  constexpr RatioPreset(const std::wstring& n, double r) noexcept : name(n), ratio(r) {}\n};\n\n// 分辨率预设\nexport struct ResolutionPreset {\n  std::wstring name;           // 显示名称（如 \"4K\"）\n  std::uint64_t total_pixels;  // 总像素数\n  int base_width;              // 基准宽度\n  int base_height;             // 基准高度\n\n  constexpr ResolutionPreset(const std::wstring& n, int w, int h) noexcept\n      : name(n), total_pixels(static_cast<std::uint64_t>(w) * h), base_width(w), base_height(h) {}\n};\n\n// === Getters Interface ===\n\n// 获取当前的比例预设数据\nexport auto get_ratios(const Features::Settings::State::SettingsState& state)\n    -> const std::vector<RatioPreset>&;\n\n// 获取当前的分辨率预设数据\nexport auto get_resolutions(const Features::Settings::State::SettingsState& state)\n    -> const std::vector<ResolutionPreset>&;\n\n}  // namespace Features::Settings::Menu\n"
  },
  {
    "path": "src/features/settings/migration.cpp",
    "content": "module;\n\n#include <rfl/json.hpp>\n\nmodule Features.Settings.Migration;\n\nimport std;\nimport Utils.Logger;\nimport Features.Settings.Types;\n\nnamespace Features::Settings::Migration {\n\n// 未来的迁移函数在此添加\n// 示例：\n// auto migrate_v1_to_v2(rfl::Generic::Object& settings)\n//     -> std::expected<rfl::Generic::Object, std::string> {\n//   settings[\"version\"] = 2;\n//   // 迁移逻辑\n//   return settings;\n// }\n\nauto get_all_migration_functions() -> const std::unordered_map<int, MigrationFunction>& {\n  static const std::unordered_map<int, MigrationFunction> functions = {\n      // 未来可以添加：{1, migrate_v1_to_v2}, ...\n  };\n  return functions;\n}\n\nauto migrate_settings(const rfl::Generic::Object& settings, int source_version, int target_version)\n    -> std::expected<rfl::Generic::Object, std::string> {\n  // 如果没有指定目标版本，默认迁移到最新版本\n  int actual_target_version =\n      (target_version == -1) ? Types::CURRENT_SETTINGS_VERSION : target_version;\n\n  // 如果已经达到或超过目标版本，直接返回\n  if (source_version >= actual_target_version) {\n    Logger().info(\"Settings already at version {}, no migration needed\", source_version);\n    return settings;\n  }\n\n  rfl::Generic::Object current_settings = settings;\n  int current_version = source_version;\n\n  Logger().info(\"Migrating settings from version {} to {}\", source_version, actual_target_version);\n\n  const auto& migration_functions = get_all_migration_functions();\n\n  // 逐步升级到目标版本\n  while (current_version < actual_target_version) {\n    // 根据当前版本选择相应的迁移函数\n    auto it = migration_functions.find(current_version);\n    if (it != migration_functions.end()) {\n      auto result = it->second(current_settings);\n      if (!result) {\n        return std::unexpected(\"Migration failed from v\" + std::to_string(current_version) +\n                               \" to v\" + std::to_string(current_version + 1) + \": \" +\n                               result.error());\n      }\n      current_settings = result.value();\n      current_version++;\n    } else {\n      // 如果没有找到迁移函数，检查是否已经到达目标版本\n      if (current_version >= actual_target_version) {\n        break;\n      }\n      return std::unexpected(\"No migration function found for version \" +\n                             std::to_string(current_version));\n    }\n  }\n\n  Logger().info(\"Settings migration completed: {} -> {}\", source_version, actual_target_version);\n  return current_settings;\n}\n}  // namespace Features::Settings::Migration"
  },
  {
    "path": "src/features/settings/migration.ixx",
    "content": "module;\n\n#include <rfl.hpp>\n\nexport module Features.Settings.Migration;\n\nimport std;\n\nnamespace Features::Settings::Migration {\n\nexport using MigrationFunction =\n    std::function<std::expected<rfl::Generic::Object, std::string>(rfl::Generic::Object&)>;\n\nexport auto migrate_settings(const rfl::Generic::Object& settings, int source_version,\n                             int target_version = -1)\n    -> std::expected<rfl::Generic::Object, std::string>;\n\n}  // namespace Features::Settings::Migration\n"
  },
  {
    "path": "src/features/settings/registry.ixx",
    "content": "module;\n\nexport module Features.Settings.Registry;\n\nimport std;\n\nnamespace Features::Settings::Registry {\n\n// === 比例预设注册表 ===\n// 内置的比例预设映射（用于快速查找，但用户可以添加任意 W:H 格式）\nexport inline const std::map<std::string_view, double> ASPECT_RATIO_PRESETS = {\n    {\"32:9\", 32.0 / 9.0}, {\"21:9\", 21.0 / 9.0}, {\"16:9\", 16.0 / 9.0}, {\"3:2\", 3.0 / 2.0},\n    {\"1:1\", 1.0},         {\"3:4\", 3.0 / 4.0},   {\"2:3\", 2.0 / 3.0},   {\"9:16\", 9.0 / 16.0},\n};\n\n// === 分辨率预设注册表 ===\n// 内置的分辨率别名（用户也可以使用 WxH 格式）\nexport inline const std::map<std::string_view, std::pair<int, int>> RESOLUTION_ALIASES = {\n    {\"Default\", {0, 0}},  {\"480P\", {720, 480}},   {\"720P\", {1280, 720}},  {\"1080P\", {1920, 1080}},\n    {\"2K\", {2560, 1440}}, {\"4K\", {3840, 2160}},   {\"5K\", {5120, 2880}},   {\"6K\", {5760, 3240}},\n    {\"8K\", {7680, 4320}}, {\"10K\", {10240, 4320}}, {\"12K\", {11520, 6480}}, {\"16K\", {15360, 8640}},\n};\n\n// === 解析函数 ===\n\n// 解析比例 ID (如 \"16:9\" 或 \"4:3\")\nexport auto parse_aspect_ratio(std::string_view id) -> std::optional<double> {\n  // 先查预设\n  if (ASPECT_RATIO_PRESETS.contains(id)) {\n    return ASPECT_RATIO_PRESETS.at(id);\n  }\n\n  // 解析自定义格式 \"W:H\"\n  std::string id_str{id};\n  auto pos = id_str.find(':');\n  if (pos != std::string::npos) {\n    try {\n      double w = std::stod(id_str.substr(0, pos));\n      double h = std::stod(id_str.substr(pos + 1));\n      if (h > 0) {\n        return w / h;\n      }\n    } catch (...) {\n      // 解析失败\n    }\n  }\n\n  return std::nullopt;\n}\n\n// 解析分辨率 ID (如 \"4K\" 或 \"1920x1080\")\nexport auto parse_resolution(std::string_view id) -> std::optional<std::pair<int, int>> {\n  // 先查别名\n  if (RESOLUTION_ALIASES.contains(id)) {\n    return RESOLUTION_ALIASES.at(id);\n  }\n\n  // 解析自定义格式 \"WxH\"\n  std::string id_str{id};\n  auto pos = id_str.find('x');\n  if (pos != std::string::npos) {\n    try {\n      int w = std::stoi(id_str.substr(0, pos));\n      int h = std::stoi(id_str.substr(pos + 1));\n      if (w > 0 && h > 0) {\n        return std::make_pair(w, h);\n      }\n    } catch (...) {\n      // 解析失败\n    }\n  }\n\n  return std::nullopt;\n}\n\n}  // namespace Features::Settings::Registry\n"
  },
  {
    "path": "src/features/settings/settings.cpp",
    "content": "module;\n\nmodule Features.Settings;\n\nimport std;\nimport Core.State;\nimport Core.Events;\nimport Features.Settings.Events;\nimport Features.Settings.Types;\nimport Features.Settings.State;\nimport Features.Settings.Compute;\nimport Features.Settings.Migration;\nimport Features.Settings.Menu;\nimport Features.Settings.Background;\nimport Utils.Path;\nimport Utils.Logger;\nimport Vendor.Windows;\nimport <rfl/json.hpp>;\n\nnamespace Features::Settings {\n\nnamespace Detail {\n\n// 启动期只关心 app 下的极少数字段；\n// 单独声明一个最小映射结构，避免在完整初始化前引入更多不必要的耦合。\nstruct StartupLoggerSettings {\n  std::optional<std::string> level;\n};\n\nstruct StartupAppSettings {\n  bool always_run_as_admin = true;\n  StartupLoggerSettings logger;\n};\n\nstruct StartupSettingsFile {\n  StartupAppSettings app;\n};\n\n}  // namespace Detail\n\nauto detect_default_locale() -> std::string {\n  constexpr Vendor::Windows::LANGID kPrimaryLanguageMask = 0x03ff;\n  constexpr Vendor::Windows::LANGID kChinesePrimaryLanguage = 0x0004;\n\n  auto language_id = Vendor::Windows::GetUserDefaultUILanguage();\n  auto primary_language = static_cast<Vendor::Windows::LANGID>(language_id & kPrimaryLanguageMask);\n\n  if (primary_language == kChinesePrimaryLanguage) {\n    return \"zh-CN\";\n  }\n\n  return \"en-US\";\n}\n\nauto get_settings_path() -> std::expected<std::filesystem::path, std::string> {\n  return Utils::Path::GetAppDataFilePath(\"settings.json\");\n}\n\nauto should_show_onboarding(const Types::AppSettings& settings) -> bool {\n  if (!settings.app.onboarding.completed) {\n    return true;\n  }\n\n  return settings.app.onboarding.flow_version < Types::CURRENT_ONBOARDING_FLOW_VERSION;\n}\n\n// Migration专用：迁移settings文件到指定版本\nauto migrate_settings_file(const std::filesystem::path& file_path, int target_version)\n    -> std::expected<void, std::string> {\n  try {\n    if (!std::filesystem::exists(file_path)) {\n      return std::unexpected(\"Settings file does not exist: \" + file_path.string());\n    }\n\n    // 读取原始JSON\n    std::ifstream file(file_path);\n    if (!file) {\n      return std::unexpected(\"Failed to open settings file: \" + file_path.string());\n    }\n\n    std::string json_str((std::istreambuf_iterator<char>(file)), std::istreambuf_iterator<char>());\n    file.close();\n\n    // 使用rfl::Generic解析JSON以获取版本号\n    auto generic_result = rfl::json::read<rfl::Generic::Object>(json_str);\n    if (!generic_result) {\n      return std::unexpected(\"Failed to parse settings as generic JSON: \" +\n                             generic_result.error().what());\n    }\n\n    auto generic_settings = generic_result.value();\n\n    // 提取当前版本号\n    int current_version = 1;  // 默认版本\n    auto version_result = generic_settings.get(\"version\").and_then(rfl::to_int);\n    if (version_result) {\n      current_version = version_result.value();\n    }\n\n    // 如果已经是目标版本，无需迁移\n    if (current_version >= target_version) {\n      Logger().info(\"Settings already at version {}, no migration needed\", current_version);\n      return {};\n    }\n\n    Logger().info(\"Migrating settings from version {} to {}\", current_version, target_version);\n\n    // 执行迁移\n    auto migration_result = Migration::migrate_settings(generic_settings, current_version);\n    if (!migration_result) {\n      return std::unexpected(\"Settings migration failed: \" + migration_result.error());\n    }\n\n    // 转换为AppSettings以验证结构\n    auto app_settings_result = rfl::from_generic<Types::AppSettings>(migration_result.value());\n    if (!app_settings_result) {\n      return std::unexpected(\"Failed to convert migrated generic JSON to AppSettings: \" +\n                             app_settings_result.error().what());\n    }\n\n    // 保存迁移后的设置\n    auto save_result = save_settings_to_file(file_path, app_settings_result.value());\n    if (!save_result) {\n      return std::unexpected(\"Failed to save migrated settings: \" + save_result.error());\n    }\n\n    Logger().info(\"Settings migration completed successfully\");\n    return {};\n\n  } catch (const std::exception& e) {\n    return std::unexpected(\"Error during settings migration: \" + std::string(e.what()));\n  }\n}\n\nauto initialize(Core::State::AppState& app_state) -> std::expected<void, std::string> {\n  try {\n    auto settings_path = get_settings_path();\n    if (!settings_path) {\n      return std::unexpected(settings_path.error());\n    }\n\n    // 情况1: 文件不存在 → 创建最新默认配置\n    if (!std::filesystem::exists(settings_path.value())) {\n      Logger().info(\"Settings file not found, creating default configuration\");\n\n      auto default_state = State::create_default_settings_state();\n      default_state.raw.app.language.current = detect_default_locale();\n      // 新安装用户首次启动应进入欢迎流程\n      default_state.raw.app.onboarding.completed = false;\n      default_state.raw.app.onboarding.flow_version = Types::CURRENT_ONBOARDING_FLOW_VERSION;\n      default_state.raw.extensions.infinity_nikki.enable = false;\n\n      auto json_str = rfl::json::write(default_state.raw, rfl::json::pretty);\n      std::ofstream file(settings_path.value());\n      if (!file) {\n        return std::unexpected(\"Failed to create settings file\");\n      }\n      file << json_str;\n\n      // 计算预设并初始化内存状态\n      *app_state.settings = default_state;\n      Compute::trigger_compute(app_state);\n      app_state.settings->is_initialized = true;\n      Background::register_static_resolvers(app_state);\n\n      Logger().info(\"Default settings created successfully\");\n      return {};\n    }\n\n    // 情况2: 文件存在 → 直接读取（Migration已保证版本正确）\n    std::ifstream file(settings_path.value());\n    if (!file) {\n      return std::unexpected(\"Failed to open settings file\");\n    }\n\n    std::string json_str((std::istreambuf_iterator<char>(file)), std::istreambuf_iterator<char>());\n\n    auto config_result = rfl::json::read<Types::AppSettings, rfl::DefaultIfMissing>(json_str);\n    if (!config_result) {\n      return std::unexpected(\"Failed to parse settings: \" + config_result.error().what());\n    }\n\n    auto config = config_result.value();\n\n    // 创建完整状态\n    State::SettingsState state;\n    state.raw = config;\n\n    // 先设置到app_state，然后计算预设\n    *app_state.settings = state;\n    Compute::trigger_compute(app_state);\n    app_state.settings->is_initialized = true;\n    Background::register_static_resolvers(app_state);\n\n    Logger().info(\"Settings loaded successfully (version {})\", config.version);\n    return {};\n  } catch (const std::exception& e) {\n    return std::unexpected(\"Failed to initialize settings: \" + std::string(e.what()));\n  }\n}\n\nauto get_settings(const Types::GetSettingsParams& params)\n    -> std::expected<Types::GetSettingsResult, std::string> {\n  try {\n    auto settings_path = get_settings_path();\n    if (!settings_path) {\n      return std::unexpected(settings_path.error());\n    }\n\n    if (!std::filesystem::exists(settings_path.value())) {\n      return std::unexpected(\"Settings file does not exist\");\n    }\n\n    std::ifstream file(settings_path.value());\n    if (!file) {\n      return std::unexpected(\"Failed to open settings file\");\n    }\n\n    std::string json_str((std::istreambuf_iterator<char>(file)), std::istreambuf_iterator<char>());\n\n    auto result = rfl::json::read<Types::AppSettings, rfl::DefaultIfMissing>(json_str);\n    if (!result) {\n      return std::unexpected(\"Failed to parse settings: \" + result.error().what());\n    }\n\n    return result.value();\n  } catch (const std::exception& e) {\n    return std::unexpected(\"Failed to read settings: \" + std::string(e.what()));\n  }\n}\n\nauto notify_settings_changed(Core::State::AppState& app_state,\n                             const Types::AppSettings& old_settings,\n                             std::string_view change_description) -> void {\n  if (!app_state.events || !app_state.settings) {\n    return;\n  }\n\n  Types::SettingsChangeData change_data{\n      .old_settings = old_settings,\n      .new_settings = app_state.settings->raw,\n      .change_description = std::string(change_description),\n  };\n  Core::Events::post(*app_state.events,\n                     Features::Settings::Events::SettingsChangeEvent{change_data});\n}\n\nauto merge_patch_object(rfl::Generic::Object& target, const rfl::Generic::Object& patch) -> void {\n  for (const auto& [key, patch_value] : patch) {\n    auto patch_object = patch_value.to_object();\n    if (!patch_object) {\n      target[key] = patch_value;\n      continue;\n    }\n\n    auto target_object =\n        target.get(key).and_then([](const rfl::Generic& value) { return value.to_object(); });\n    if (target_object) {\n      auto merged_child = target_object.value();\n      merge_patch_object(merged_child, patch_object.value());\n      target[key] = merged_child;\n      continue;\n    }\n\n    target[key] = patch_value;\n  }\n}\n\nauto apply_settings_and_persist(Core::State::AppState& app_state,\n                                const Types::AppSettings& next_settings,\n                                std::string_view change_description)\n    -> std::expected<Types::UpdateSettingsResult, std::string> {\n  auto settings_path = get_settings_path();\n  if (!settings_path) {\n    return std::unexpected(settings_path.error());\n  }\n\n  if (!app_state.settings) {\n    return std::unexpected(\"Settings not initialized\");\n  }\n\n  Types::AppSettings old_settings = app_state.settings->raw;\n  app_state.settings->raw = next_settings;\n  Compute::trigger_compute(app_state);\n\n  auto save_result = save_settings_to_file(settings_path.value(), app_state.settings->raw);\n  if (!save_result) {\n    app_state.settings->raw = old_settings;\n    Compute::trigger_compute(app_state);\n    return std::unexpected(save_result.error());\n  }\n\n  notify_settings_changed(app_state, old_settings, change_description);\n\n  return Types::UpdateSettingsResult{\n      .success = true,\n      .message = \"Settings updated successfully\",\n  };\n}\n\nauto update_settings(Core::State::AppState& app_state, const Types::UpdateSettingsParams& params)\n    -> std::expected<Types::UpdateSettingsResult, std::string> {\n  try {\n    return apply_settings_and_persist(app_state, params, \"Settings updated via RPC\");\n  } catch (const std::exception& e) {\n    return std::unexpected(\"Failed to save settings: \" + std::string(e.what()));\n  }\n}\n\nauto patch_settings(Core::State::AppState& app_state, const Types::PatchSettingsParams& params)\n    -> std::expected<Types::PatchSettingsResult, std::string> {\n  try {\n    if (params.patch.empty()) {\n      return Types::PatchSettingsResult{\n          .success = true,\n          .message = \"No settings changes\",\n      };\n    }\n\n    if (!app_state.settings) {\n      return std::unexpected(\"Settings not initialized\");\n    }\n\n    auto current_generic = rfl::to_generic<rfl::SnakeCaseToCamelCase>(app_state.settings->raw);\n    auto current_object = current_generic.to_object();\n    if (!current_object) {\n      return std::unexpected(\"Current settings is not an object\");\n    }\n\n    auto merged_object = current_object.value();\n    merge_patch_object(merged_object, params.patch);\n\n    // 经 JSON 再反序列化，与「从文件读设置」同路径，避免 from_generic 对 double 字段\n    // 不接受整数（如 100% → 1）导致的解析失败\n    auto merged_json = rfl::json::write(merged_object);\n    auto merged_settings_result =\n        rfl::json::read<Types::AppSettings, rfl::DefaultIfMissing, rfl::SnakeCaseToCamelCase>(\n            merged_json);\n    if (!merged_settings_result) {\n      return std::unexpected(\"Invalid settings patch: \" + merged_settings_result.error().what());\n    }\n\n    return apply_settings_and_persist(app_state, merged_settings_result.value(),\n                                      \"Settings patched via RPC\");\n  } catch (const std::exception& e) {\n    return std::unexpected(\"Failed to patch settings: \" + std::string(e.what()));\n  }\n}\n\nauto save_settings_to_file(const std::filesystem::path& settings_path,\n                           const Types::AppSettings& config) -> std::expected<void, std::string> {\n  try {\n    // 序列化配置到格式化的 JSON\n    auto json_str = rfl::json::write(config, rfl::json::pretty);\n\n    // 写入文件\n    std::ofstream file(settings_path);\n    if (!file) {\n      return std::unexpected(\"Failed to open settings file for writing\");\n    }\n\n    file << json_str;\n    return {};\n  } catch (const std::exception& e) {\n    return std::unexpected(\"Failed to save settings: \" + std::string(e.what()));\n  }\n}\n\nauto load_startup_settings() noexcept -> StartupSettings {\n  StartupSettings startup_settings;\n\n  try {\n    // 启动早期允许 settings.json 不存在；此时直接使用默认值继续启动。\n    auto settings_path = get_settings_path();\n    if (!settings_path || !std::filesystem::exists(settings_path.value())) {\n      return startup_settings;\n    }\n\n    std::ifstream file(settings_path.value());\n    if (!file) {\n      return startup_settings;\n    }\n\n    std::string json_str((std::istreambuf_iterator<char>(file)), std::istreambuf_iterator<char>());\n\n    // 这里只解析启动阶段真正需要的字段：\n    // - app.always_run_as_admin\n    // - app.logger.level\n    // 任何解析失败都应静默回退到默认值，不能阻塞应用启动。\n    auto startup_result =\n        rfl::json::read<Detail::StartupSettingsFile, rfl::DefaultIfMissing>(json_str);\n    if (!startup_result) {\n      return startup_settings;\n    }\n\n    startup_settings.always_run_as_admin = startup_result->app.always_run_as_admin;\n    if (startup_result->app.logger.level.has_value() &&\n        !startup_result->app.logger.level->empty()) {\n      startup_settings.logger_level = startup_result->app.logger.level;\n    }\n\n    return startup_settings;\n  } catch (...) {\n    // 启动早期不向上抛异常，统一退回默认行为。\n    return startup_settings;\n  }\n}\n\n}  // namespace Features::Settings\n"
  },
  {
    "path": "src/features/settings/settings.ixx",
    "content": "module;\n\nexport module Features.Settings;\n\nimport std;\nimport Core.State;\nimport Features.Settings.Types;\n\nnamespace Features::Settings {\n\n// 启动期仅依赖的最小设置子集。\n// 用于在完整 AppState 初始化之前，先决定提权策略和初始日志级别。\nexport struct StartupSettings {\n  bool always_run_as_admin = true;\n  std::optional<std::string> logger_level;\n};\n\nexport auto initialize(Core::State::AppState& app_state) -> std::expected<void, std::string>;\n\nexport auto get_settings(const Types::GetSettingsParams& params)\n    -> std::expected<Types::GetSettingsResult, std::string>;\n\nexport auto update_settings(Core::State::AppState& app_state,\n                            const Types::UpdateSettingsParams& params)\n    -> std::expected<Types::UpdateSettingsResult, std::string>;\n\nexport auto patch_settings(Core::State::AppState& app_state,\n                           const Types::PatchSettingsParams& params)\n    -> std::expected<Types::PatchSettingsResult, std::string>;\n\n// 发布 settings 变更事件（new_settings 使用 app_state.settings->raw）\nexport auto notify_settings_changed(Core::State::AppState& app_state,\n                                    const Types::AppSettings& old_settings,\n                                    std::string_view change_description) -> void;\n\nexport auto get_settings_path() -> std::expected<std::filesystem::path, std::string>;\n\nexport auto save_settings_to_file(const std::filesystem::path& settings_path,\n                                  const Types::AppSettings& config)\n    -> std::expected<void, std::string>;\n\n// 判断当前配置是否需要显示首次引导页\nexport auto should_show_onboarding(const Types::AppSettings& settings) -> bool;\n\n// Migration专用接口：迁移settings文件到指定版本\nexport auto migrate_settings_file(const std::filesystem::path& file_path, int target_version)\n    -> std::expected<void, std::string>;\n\n// 轻量级预读取：仅解析启动早期需要的少量字段。\n// 设计目标：\n// 1. 避免为了提权判断和早期日志初始化而拉起完整设置模块；\n// 2. 即使 settings.json 缺失、损坏或字段不完整，也能稳定回退到默认值继续启动。\nexport auto load_startup_settings() noexcept -> StartupSettings;\n\n}  // namespace Features::Settings\n"
  },
  {
    "path": "src/features/settings/state.ixx",
    "content": "module;\r\n\r\nexport module Features.Settings.State;\r\n\r\nimport std;\r\nimport Features.Settings.Types;\r\nimport Features.Settings.Menu;\r\n\r\nnamespace Features::Settings::State {\r\n\r\n// 计算后的预设状态\r\nexport struct ComputedPresets {\r\n  std::vector<Features::Settings::Menu::RatioPreset> aspect_ratios;\r\n  std::vector<Features::Settings::Menu::ResolutionPreset> resolutions;\r\n};\r\n\r\n// Settings 模块的完整运行时状态 (Vue/Pinia Style)\r\nexport struct SettingsState {\r\n  // [Raw State] 原始配置数据 (Source of Truth)\r\n  Types::AppSettings raw;\r\n\r\n  // [Computed State] 计算后的缓存 (Derived State)\r\n  ComputedPresets computed;\r\n\r\n  bool is_initialized = false;\r\n};\r\n\r\n// === 状态管理函数 ===\r\n\r\n// 创建默认的设置状态\r\nexport auto create_default_settings_state() -> SettingsState {\r\n  SettingsState state;\r\n  state.raw = Types::AppSettings{};\r\n  state.is_initialized = false;\r\n  return state;\r\n}\r\n\r\n}  // namespace Features::Settings::State\r\n"
  },
  {
    "path": "src/features/settings/types.ixx",
    "content": "module;\n\nexport module Features.Settings.Types;\n\nimport std;\nimport <rfl.hpp>;\n\nnamespace Features::Settings::Types {\n\n// 当前设置版本\nexport constexpr int CURRENT_SETTINGS_VERSION = 1;\n// 当前欢迎流程版本（用于控制是否需要重新引导）\nexport constexpr int CURRENT_ONBOARDING_FLOW_VERSION = 1;\n\n// Web 主题设置\nexport struct WebThemeSettings {\n  std::string mode = \"light\";  // \"light\" | \"dark\"（历史配置可能仍为 system，前端按亮色解析）\n  std::string custom_css = \"\";\n};\n\n// Web 背景设置\nexport struct WebBackgroundSettings {\n  std::string type = \"none\";  // \"none\" | \"image\"\n  std::string image_file_name = \"\";\n  int background_blur_amount = 0;   // 0 - 100\n  double background_opacity = 1.0;  // 0.0 - 1.0\n  std::vector<std::string> overlay_colors = {\n      \"#F8F0E3\"};  // 与前端浅色首个叠加预设（peach）一致，1-4 色\n  std::string primary_color = \"#F59E0B\";\n  double overlay_opacity = 0.8;  // 0.0 - 1.0\n  double surface_opacity = 1.0;  // 0.0 - 1.0\n};\n\nexport struct BackgroundAnalysisParams {\n  std::string image_file_name;\n  std::int32_t overlay_mode = 2;  // 1 - 4\n};\n\nexport struct BackgroundAnalysisResult {\n  std::string theme_mode = \"dark\";  // \"light\" | \"dark\"\n  std::string primary_color = \"#FBBF24\";\n  std::vector<std::string> overlay_colors;\n  double brightness = 0.0;  // 0 - 1\n};\n\nexport struct BackgroundImportParams {\n  std::string source_path;\n};\n\nexport struct BackgroundImportResult {\n  std::string image_file_name;\n};\n\nexport struct BackgroundRemoveParams {\n  std::string image_file_name;\n};\n\nexport struct BackgroundRemoveResult {\n  bool removed = false;\n};\n\n// 完整的应用设置（重构后的结构）\nexport struct AppSettings {\n  int version = CURRENT_SETTINGS_VERSION;\n\n  // app 分组 - 应用核心设置\n  struct App {\n    bool always_run_as_admin = true;  // 始终以管理员权限运行\n\n    // 首次引导设置\n    struct Onboarding {\n      // 默认为 true，避免老用户升级时被强制进入引导；首次创建配置时会改写为 false\n      bool completed = true;\n      int flow_version = CURRENT_ONBOARDING_FLOW_VERSION;\n    } onboarding;\n\n    // 快捷键设置\n    // 修饰键值: MOD_ALT=1, MOD_CONTROL=2, MOD_SHIFT=4, MOD_WIN=8\n    struct Hotkey {\n      struct FloatingWindow {\n        int modifiers = 2;  // MOD_CONTROL (Ctrl)\n        int key = 192;      // VK_OEM_3 (`)\n      } floating_window;\n\n      struct Screenshot {\n        int modifiers = 0;  // 无修饰键\n        int key = 0x7A;     // VK_F11 (F11) - PrintScreen(0x2C) 可能被系统截图功能拦截\n      } screenshot;\n\n      struct Recording {\n        int modifiers = 0;  // 无修饰键\n        int key = 0x77;     // VK_F8 (F8)\n      } recording;\n    } hotkey;\n\n    // 语言设置\n    struct Language {\n      std::string current = \"zh-CN\";  // zh-CN, en-US\n    } language;\n\n    // 日志设置\n    struct Logger {\n      std::string level = \"INFO\";  // DEBUG, INFO, ERROR\n    } logger;\n  } app;\n\n  // window 分组 - 窗口相关设置\n  struct Window {\n    std::string target_title = \"\";                   // 目标窗口标题\n    bool center_lock_cursor = false;                 // 锁鼠时强制居中\n    bool enable_layered_capture_workaround = false;  // 超屏时临时启用 layered 捕获兼容方案\n\n    // 重置窗口尺寸偏好（0 表示跟随屏幕）\n    struct ResetResolution {\n      int width = 0;\n      int height = 0;\n    } reset_resolution;\n  } window;\n\n  // features 分组 - 功能特性设置\n  struct Features {\n    std::string output_dir_path = \"\";      // 统一输出目录（截图+录制），空=默认 Videos/SpinningMomo\n    std::string external_album_path = \"\";  // 外部游戏相册目录路径（为空时回退到输出目录）\n\n    // 黑边模式设置\n    struct Letterbox {\n      bool enabled = false;  // 是否启用黑边模式\n    } letterbox;\n\n    // 动态照片设置\n    struct MotionPhoto {\n      std::uint32_t duration = 3;             // 视频时长（秒）\n      std::uint32_t resolution = 0;           // 短边分辨率: 0=原始不缩放, 720/1080/1440/2160\n      std::uint32_t fps = 30;                 // 帧率\n      std::uint32_t bitrate = 10'000'000;     // 比特率 (10Mbps)，CBR 模式使用\n      std::uint32_t quality = 80;             // 质量值 (0-100)，VBR 模式使用\n      std::string rate_control = \"vbr\";       // 码率控制模式: \"cbr\" | \"vbr\"\n      std::string codec = \"h264\";             // 编码格式: \"h264\" | \"h265\"\n      std::string audio_source = \"system\";    // 音频源: \"none\" | \"system\" | \"game_only\"\n      std::uint32_t audio_bitrate = 192'000;  // 音频码率 (192kbps)\n    } motion_photo;\n\n    // 即时回放设置（录制参数继承自 recording）\n    struct ReplayBuffer {\n      std::uint32_t duration = 30;  // 回放时长（秒）\n      // enabled 不持久化，仅运行时状态\n      // 其他参数从 recording 继承\n    } replay_buffer;\n\n    // 录制功能设置\n    struct Recording {\n      std::uint32_t fps = 60;              // 帧率\n      std::uint32_t bitrate = 80'000'000;  // 比特率 (bps)，默认 80Mbps，CBR 模式使用\n      std::uint32_t quality = 80;          // 质量值 (0-100)，VBR 模式使用\n      std::uint32_t qp = 23;               // 量化参数 (0-51)，ManualQP 模式使用\n      std::string rate_control = \"vbr\";    // 码率控制模式: \"cbr\" | \"vbr\" | \"manual_qp\"\n      std::string encoder_mode = \"auto\";   // 编码器模式: \"auto\" | \"gpu\" | \"cpu\"\n      std::string codec = \"h264\";          // 视频编码格式: \"h264\" | \"h265\"\n      bool capture_client_area = true;     // 是否只捕获客户区（无边框）\n      bool capture_cursor = false;         // 是否捕获鼠标指针\n      bool auto_restart_on_resize = true;  // 尺寸变化时是否自动切段重启录制\n\n      // 音频配置\n      std::string audio_source = \"system\";    // 音频源: \"none\" | \"system\" | \"game_only\"\n      std::uint32_t audio_bitrate = 320'000;  // 音频码率 (bps)，默认 320kbps\n    } recording;\n  } features;\n\n  // update 分组 - 更新设置\n  struct Update {\n    bool auto_check = true;            // 是否自动检查更新\n    bool auto_update_on_exit = false;  // 是否在退出时自动更新\n\n    // 版本检查URL（Cloudflare Pages）\n    std::string version_url = \"https://spin.infinitymomo.com/version.txt\";\n\n    // 下载源配置（按优先级排序）\n    struct DownloadSource {\n      std::string name;          // 源名称\n      std::string url_template;  // URL模板，支持 {version} 和 {filename} 占位符\n    };\n\n    std::vector<DownloadSource> download_sources = {\n        {\"GitHub\", \"https://github.com/ChanIok/SpinningMomo/releases/download/v{0}/{1}\"},\n        {\"Mirror\", \"https://r2.infinitymomo.com/releases/v{0}/{1}\"}};\n  } update;\n\n  // ui 分组 - UI界面设置\n  struct UI {\n    // 应用菜单配置\n    struct AppMenu {\n      // 启用的功能项（有则启用，顺序即菜单显示顺序）\n      std::vector<std::string> features = {\n          \"screenshot.capture\", \"recording.toggle\",   \"preview.toggle\",\n          \"overlay.toggle\",     \"window.reset\",       \"app.main\",\n          \"app.exit\",           \"output.open_folder\", \"external_album.open_folder\",\n          \"letterbox.toggle\"};\n      // 启用的比例列表（顺序即为菜单显示顺序）\n      std::vector<std::string> aspect_ratios = {\"21:9\", \"16:9\", \"3:2\", \"1:1\", \"3:4\", \"2:3\", \"9:16\"};\n      // 启用的分辨率列表（顺序即为菜单显示顺序）\n      std::vector<std::string> resolutions = {\"Default\", \"1080P\", \"2K\", \"4K\", \"6K\", \"8K\", \"12K\"};\n    } app_menu;\n\n    // 浮窗布局配置\n    struct FloatingWindowLayout {\n      int base_item_height = 24;\n      int base_title_height = 26;\n      int base_separator_height = 0;\n      int base_font_size = 12;\n      int base_text_padding = 12;\n      int base_indicator_width = 3;\n      int base_ratio_indicator_width = 4;\n      int base_ratio_column_width = 60;\n      int base_resolution_column_width = 70;\n      int base_settings_column_width = 80;\n      int base_scroll_indicator_width = 3;  // 滚动条宽度\n      int max_visible_rows = 7;             // 每列可见行数，下限 1\n    } floating_window_layout;\n\n    // 浮窗颜色配置\n    struct FloatingWindowColors {\n      std::string background = \"#1f1f1fB3\";\n      std::string separator = \"#333333B3\";\n      std::string text = \"#D8D8D8FF\";\n      std::string indicator = \"#FBBF24FF\";\n      std::string hover = \"#505050CC\";\n      std::string title_bar = \"#1f1f1fB3\";\n      std::string scroll_indicator = \"#808080CC\";  // 滚动条颜色\n    } floating_window_colors;\n\n    // 浮窗主题模式\n    std::string floating_window_theme_mode = \"dark\";\n\n    // WebView 主窗口尺寸和位置（持久化）\n    // x/y 为 -1 表示未保存过，首次启动时居中\n    struct WebViewWindow {\n      int width = 900;\n      int height = 600;\n      int x = -1;\n      int y = -1;\n      bool enable_transparent_background = false;\n    } webview_window;\n\n    // Web UI 设置\n    WebThemeSettings web_theme;\n    WebBackgroundSettings background;\n  } ui;\n\n  // 拓展配置\n  struct Extensions {\n    struct InfinityNikki {\n      bool enable = false;\n      std::string game_dir = \"\";\n      bool gallery_guide_seen = false;\n      bool allow_online_photo_metadata_extract = false;\n      bool manage_screenshot_hardlinks = false;\n    } infinity_nikki;\n  } extensions;\n};\n\n// 设置变更事件数据\nexport struct SettingsChangeData {\n  AppSettings old_settings;\n  AppSettings new_settings;\n  std::string change_description;\n};\n\n// 获取设置\nexport struct GetSettingsParams {\n  // 空结构体，未来可扩展\n};\n\nexport using GetSettingsResult = AppSettings;\n\nexport using UpdateSettingsParams = AppSettings;\n\nexport struct UpdateSettingsResult {\n  bool success;\n  std::string message;\n};\n\n// 局部更新设置（JSON Merge Patch 风格，不支持字段删除）\nexport struct PatchSettingsParams {\n  rfl::Generic::Object patch;\n};\n\nexport using PatchSettingsResult = UpdateSettingsResult;\n\n}  // namespace Features::Settings::Types\n"
  },
  {
    "path": "src/features/update/state.ixx",
    "content": "module;\n\nexport module Features.Update.State;\n\nimport std;\nimport Features.Update.Types;\n\nexport namespace Features::Update::State {\n\nstruct UpdateState {\n  // 运行时状态\n  bool is_checking = false;        // 是否正在检查更新\n  bool update_available = false;   // 是否有可用更新\n  std::string latest_version;      // 最新版本号\n  std::string downloaded_version;  // 已下载完成的版本号\n  std::string error_message;       // 错误信息\n\n  std::filesystem::path update_script_path;  // 更新脚本路径\n  bool pending_update = false;               // 是否有待处理的更新\n\n  // 安装类型\n  bool is_portable = true;  // 是否为便携版（通过 portable 标记文件检测）\n\n  // 初始化状态\n  bool is_initialized = false;\n\n  UpdateState() = default;\n};\n\n// 创建默认的更新状态\ninline auto create_default_update_state() -> UpdateState {\n  UpdateState state;\n  state.is_initialized = false;\n  return state;\n}\n\n}  // namespace Features::Update::State\n"
  },
  {
    "path": "src/features/update/types.ixx",
    "content": "module;\n\nexport module Features.Update.Types;\n\nimport std;\n\nexport namespace Features::Update::Types {\n\n// === 响应类型定义 ===\n// Update模块的公共API响应类型\n\n// 检查更新响应结果\nstruct CheckUpdateResult {\n  bool has_update;              // 是否有可用更新\n  std::string latest_version;   // 最新版本\n  std::string current_version;  // 当前版本\n};\n\n// 启动后台下载更新任务响应结果\nstruct StartDownloadUpdateResult {\n  std::string task_id;  // 后台任务ID\n  std::string status;   // started | already_running\n};\n\n// 安装更新请求参数\nstruct InstallUpdateParams {\n  bool restart = true;  // 安装后是否重启程序\n};\n\n// 安装更新响应结果\nstruct InstallUpdateResult {\n  std::string message;  // 结果消息\n};\n\n}  // namespace Features::Update::Types\n"
  },
  {
    "path": "src/features/update/update.cpp",
    "content": "module;\n\n#include <asio.hpp>\n\nmodule Features.Update;\n\nimport std;\nimport Core.Events;\nimport Core.Async;\nimport Core.Tasks;\nimport UI.FloatingWindow.Events;\nimport Core.State;\nimport Core.I18n.State;\nimport Core.HttpClient;\nimport Core.HttpClient.Types;\nimport Features.Update.State;\nimport Features.Update.Types;\nimport Features.Settings.State;\nimport Utils.Crypto;\nimport Utils.Logger;\nimport Utils.Path;\nimport Utils.String;\nimport Utils.Throttle;\nimport Vendor.ShellApi;\nimport Vendor.Version;\nimport Vendor.Windows;\n\nnamespace Features::Update {\n\nauto post_update_notification(Core::State::AppState& app_state, const std::string& message)\n    -> void {\n  if (!app_state.events || !app_state.i18n) {\n    Logger().warn(\"Skip update notification: state is not ready\");\n    return;\n  }\n\n  auto app_name_it = app_state.i18n->texts.find(\"label.app_name\");\n  if (app_name_it == app_state.i18n->texts.end()) {\n    Logger().warn(\"Skip update notification: app name text is missing\");\n    return;\n  }\n\n  Core::Events::post(*app_state.events, UI::FloatingWindow::Events::NotificationEvent{\n                                            .title = app_name_it->second,\n                                            .message = message,\n                                        });\n}\n\nauto is_update_needed(const std::string& current_version, const std::string& latest_version)\n    -> bool {\n  // 简单的版本号比较，格式为 \"x.y.z.w\"\n  auto split_version = [](const std::string& version) -> std::vector<int> {\n    std::vector<int> parts;\n    std::stringstream ss(version);\n    std::string part;\n\n    while (std::getline(ss, part, '.')) {\n      try {\n        parts.push_back(std::stoi(part));\n      } catch (...) {\n        parts.push_back(0);\n      }\n    }\n\n    // 确保有4个部分\n    while (parts.size() < 4) {\n      parts.push_back(0);\n    }\n\n    return parts;\n  };\n\n  auto v1_parts = split_version(latest_version);\n  auto v2_parts = split_version(current_version);\n\n  for (size_t i = 0; i < 4; ++i) {\n    if (v1_parts[i] > v2_parts[i]) {\n      return true;\n    } else if (v1_parts[i] < v2_parts[i]) {\n      return false;\n    }\n  }\n\n  return false;  // 版本相同\n}\n\nauto http_get(Core::State::AppState& app_state, const std::string& url)\n    -> asio::awaitable<std::expected<std::string, std::string>> {\n  Core::HttpClient::Types::Request request{\n      .method = \"GET\",\n      .url = url,\n  };\n\n  auto response_result = co_await Core::HttpClient::fetch(app_state, request);\n  if (!response_result) {\n    co_return std::unexpected(\"Failed to send HTTP request: \" + response_result.error());\n  }\n\n  if (response_result->status_code != 200) {\n    co_return std::unexpected(\"HTTP error: \" + std::to_string(response_result->status_code));\n  }\n\n  co_return response_result->body;\n}\n\nauto get_temp_directory() -> std::expected<std::filesystem::path, std::string> {\n  return Utils::Path::GetAppDataSubdirectory(\"temp\");\n}\n\nauto format_download_url(const std::string& url_template, const std::string& version,\n                         const std::string& filename) -> std::expected<std::string, std::string> {\n  try {\n    return std::vformat(url_template, std::make_format_args(version, filename));\n  } catch (const std::exception& e) {\n    return std::unexpected(\"Invalid download URL template: \" + std::string(e.what()));\n  }\n}\n\nauto parse_sha256sum_for_filename(const std::string& checksums_content, const std::string& filename)\n    -> std::expected<std::string, std::string> {\n  std::istringstream stream(checksums_content);\n  std::string line;\n  while (std::getline(stream, line)) {\n    auto trimmed_line = Utils::String::TrimAscii(line);\n    if (trimmed_line.empty()) {\n      continue;\n    }\n\n    size_t hash_end = 0;\n    while (hash_end < trimmed_line.size() &&\n           !std::isspace(static_cast<unsigned char>(trimmed_line[hash_end]))) {\n      hash_end++;\n    }\n    if (hash_end == 0 || hash_end >= trimmed_line.size()) {\n      continue;\n    }\n\n    auto hash = trimmed_line.substr(0, hash_end);\n    if (hash.size() != 64 || !std::all_of(hash.begin(), hash.end(), [](unsigned char ch) {\n          return std::isxdigit(ch) != 0;\n        })) {\n      continue;\n    }\n\n    while (hash_end < trimmed_line.size() &&\n           std::isspace(static_cast<unsigned char>(trimmed_line[hash_end]))) {\n      hash_end++;\n    }\n\n    if (hash_end < trimmed_line.size() && trimmed_line[hash_end] == '*') {\n      hash_end++;\n    }\n\n    auto file_part = Utils::String::TrimAscii(trimmed_line.substr(hash_end));\n    if (file_part == filename) {\n      return Utils::String::ToLowerAscii(hash);\n    }\n  }\n\n  return std::unexpected(\"SHA256SUMS does not contain checksum for \" + filename);\n}\n\nauto verify_downloaded_file_sha256(const std::filesystem::path& file_path,\n                                   const std::string& expected_sha256)\n    -> std::expected<void, std::string> {\n  auto actual_sha256 = Utils::Crypto::sha256_file(file_path);\n  if (!actual_sha256) {\n    return std::unexpected(\"Failed to calculate downloaded file hash: \" + actual_sha256.error());\n  }\n\n  auto expected = Utils::String::ToLowerAscii(Utils::String::TrimAscii(expected_sha256));\n  if (actual_sha256.value() != expected) {\n    return std::unexpected(\"SHA256 mismatch\");\n  }\n\n  return {};\n}\n\n// 根据安装类型获取更新文件名\nauto get_update_filename(const std::string& version, bool is_portable) -> std::string {\n  if (is_portable) {\n    return \"SpinningMomo-\" + version + \"-x64-Portable.zip\";\n  } else {\n    return \"SpinningMomo-\" + version + \"-x64-Setup.exe\";\n  }\n}\n\n// 检测是否为便携版安装（exe同目录下存在portable标记文件）\nauto detect_portable() -> bool {\n  return Utils::Path::GetAppMode() == Utils::Path::AppMode::Portable;\n}\n\nconstexpr auto kUpdateDownloadTaskType = \"update.download\";\n\nauto make_task_progress(std::string stage, std::optional<std::string> message = std::nullopt,\n                        std::optional<double> percent = std::nullopt) -> Core::Tasks::TaskProgress {\n  return Core::Tasks::TaskProgress{\n      .stage = std::move(stage),\n      .current = 0,\n      .total = 0,\n      .percent = percent,\n      .message = std::move(message),\n  };\n}\n\nauto format_byte_size(std::uint64_t bytes) -> std::string {\n  constexpr std::array<const char*, 5> kUnits = {\"B\", \"KB\", \"MB\", \"GB\", \"TB\"};\n  auto value = static_cast<double>(bytes);\n  std::size_t unit_index = 0;\n  while (value >= 1024.0 && unit_index + 1 < kUnits.size()) {\n    value /= 1024.0;\n    ++unit_index;\n  }\n\n  if (unit_index == 0) {\n    return std::format(\"{} {}\", bytes, kUnits[unit_index]);\n  }\n\n  return std::format(\"{:.1f} {}\", value, kUnits[unit_index]);\n}\n\nauto make_download_task_progress(const std::string& source_name,\n                                 const Core::HttpClient::Types::DownloadProgress& progress)\n    -> Core::Tasks::TaskProgress {\n  std::optional<double> percent = std::nullopt;\n  std::optional<std::string> message = std::nullopt;\n\n  if (progress.total_bytes.has_value() && progress.total_bytes.value() > 0) {\n    percent = std::clamp(static_cast<double>(progress.downloaded_bytes) * 100.0 /\n                             static_cast<double>(progress.total_bytes.value()),\n                         0.0, 100.0);\n    message = std::format(\"{}: {} / {}\", source_name, format_byte_size(progress.downloaded_bytes),\n                          format_byte_size(progress.total_bytes.value()));\n  } else {\n    message = std::format(\"{}: {}\", source_name, format_byte_size(progress.downloaded_bytes));\n  }\n\n  return Core::Tasks::TaskProgress{\n      .stage = \"download\",\n      .current = static_cast<std::int64_t>(progress.downloaded_bytes),\n      .total = static_cast<std::int64_t>(progress.total_bytes.value_or(0)),\n      .percent = percent,\n      .message = std::move(message),\n  };\n}\n\n// 从版本检查URL获取最新版本号\nauto fetch_latest_version(Core::State::AppState& app_state, const std::string& version_url)\n    -> asio::awaitable<std::expected<std::string, std::string>> {\n  auto response = co_await http_get(app_state, version_url);\n  if (!response) {\n    co_return std::unexpected(\"Failed to fetch version info: \" + response.error());\n  }\n\n  auto version = Utils::String::TrimAscii(response.value());\n  if (version.empty()) {\n    co_return std::unexpected(\"Empty version response\");\n  }\n\n  co_return version;\n}\n\nauto download_file(Core::State::AppState& app_state, const std::string& url,\n                   const std::filesystem::path& save_path,\n                   Core::HttpClient::Types::DownloadProgressCallback progress_callback = nullptr)\n    -> asio::awaitable<std::expected<void, std::string>> {\n  try {\n    Core::HttpClient::Types::Request request{\n        .method = \"GET\",\n        .url = url,\n    };\n\n    auto result = co_await Core::HttpClient::download_to_file(app_state, request, save_path,\n                                                              progress_callback);\n    if (!result) {\n      co_return std::unexpected(\"Download failed: \" + result.error());\n    }\n\n    co_return std::expected<void, std::string>{};\n\n  } catch (const std::exception& e) {\n    co_return std::unexpected(\"Download failed: \" + std::string(e.what()));\n  }\n}\n\nauto create_update_script(const std::filesystem::path& update_package_path, bool is_portable,\n                          Vendor::Windows::DWORD target_pid, bool restart = true)\n    -> std::expected<std::filesystem::path, std::string> {\n  try {\n    auto temp_dir = get_temp_directory();\n    if (!temp_dir) {\n      return std::unexpected(\"Failed to get temporary directory: \" + temp_dir.error());\n    }\n\n    auto script_path = temp_dir.value() / std::filesystem::path(\"update.bat\");\n\n    std::ofstream script(script_path);\n    if (!script) {\n      return std::unexpected(\"Failed to create update script\");\n    }\n\n    // 获取当前程序目录\n    auto current_dir_result = Utils::Path::GetExecutableDirectory();\n    if (!current_dir_result) {\n      return std::unexpected(\"Failed to get executable directory: \" + current_dir_result.error());\n    }\n    auto current_dir = current_dir_result.value();\n\n    // 写入批处理脚本\n    script << \"@echo off\\n\";\n    script << \"echo Starting update process...\\n\";\n    script << \"echo Waiting for application to exit...\\n\";\n    script << \"powershell -NoProfile -Command \\\"$pidToWait = \" << target_pid\n           << \"; $timeoutMs = 15000; $process = Get-Process -Id $pidToWait -ErrorAction \"\n              \"SilentlyContinue; if ($process) { if (-not $process.WaitForExit($timeoutMs)) \"\n              \"{ Stop-Process -Id $pidToWait -Force -ErrorAction SilentlyContinue; \"\n              \"Start-Sleep -Seconds 1 } }\\\"\\n\";\n\n    if (is_portable) {\n      // 便携版：解压zip覆盖当前目录\n      script << \"echo Extracting update package...\\n\";\n      script << \"powershell -Command \\\"Expand-Archive -Path '\" << update_package_path.string()\n             << \"' -DestinationPath '\" << current_dir.string() << \"' -Force\\\"\\n\";\n    } else {\n      // 安装版：以最小界面执行安装，显示进度条并记录日志\n      auto install_log_path =\n          temp_dir.value() / std::filesystem::path(\"SpinningMomo-Update-Install.log\");\n      script << \"echo Running installer...\\n\";\n      script << \"\\\"\" << update_package_path.string() << \"\\\" /passive /norestart /log \\\"\"\n             << install_log_path.string() << \"\\\"\\n\";\n    }\n\n    if (restart) {\n      script << \"echo Update completed, restarting application...\\n\";\n      script << \"start \\\"\\\" \\\"\"\n             << (current_dir / std::filesystem::path(\"SpinningMomo.exe\")).string() << \"\\\"\\n\";\n    } else {\n      script << \"echo Update completed successfully.\\n\";\n    }\n\n    script << \"echo Cleaning up temporary files...\\n\";\n    script << \"timeout /t 3 /nobreak >nul\\n\";\n    script << \"del \\\"\" << update_package_path.string() << \"\\\"\\n\";\n    script << \"del \\\"%~f0\\\"\\n\";  // 删除脚本自身\n\n    script.close();\n\n    return script_path;\n\n  } catch (const std::exception& e) {\n    return std::unexpected(\"Failed to create update script: \" + std::string(e.what()));\n  }\n}\n\nauto initialize(Core::State::AppState& app_state) -> std::expected<void, std::string> {\n  try {\n    if (!app_state.update) {\n      return std::unexpected(\"Update state not created\");\n    }\n\n    auto default_state = State::create_default_update_state();\n    *app_state.update = std::move(default_state);\n\n    // 检测安装类型\n    app_state.update->is_portable = detect_portable();\n    app_state.update->is_initialized = true;\n\n    Logger().info(\"Update initialized successfully (portable: {})\", app_state.update->is_portable);\n    return {};\n  } catch (const std::exception& e) {\n    return std::unexpected(\"Failed to initialize update: \" + std::string(e.what()));\n  }\n}\n\nauto run_download_update_task(Core::State::AppState& app_state, const std::string& task_id,\n                              const std::string& version, bool prepare_install_on_exit)\n    -> asio::awaitable<void> {\n  try {\n    if (!app_state.update || !app_state.settings) {\n      Core::Tasks::complete_task_failed(app_state, task_id, \"Update state is not ready\");\n      co_return;\n    }\n\n    Core::Tasks::mark_task_running(app_state, task_id);\n    // 下载开始时使旧的已下载版本标记失效，避免下载期间 install_update 误用旧文件\n    app_state.update->downloaded_version.clear();\n\n    const auto& download_sources = app_state.settings->raw.update.download_sources;\n    if (download_sources.empty()) {\n      Core::Tasks::complete_task_failed(app_state, task_id, \"No download sources configured\");\n      co_return;\n    }\n\n    auto filename = get_update_filename(version, app_state.update->is_portable);\n    auto temp_dir = get_temp_directory();\n    if (!temp_dir) {\n      auto error_message = \"Failed to get temporary directory: \" + temp_dir.error();\n      Core::Tasks::complete_task_failed(app_state, task_id, error_message);\n      co_return;\n    }\n\n    std::filesystem::path save_path = *temp_dir / filename;\n\n    Core::Tasks::update_task_progress(\n        app_state, task_id,\n        make_task_progress(\"prepare\", std::format(\"Preparing update package for {}\", version),\n                           0.0));\n\n    // 按优先级依次尝试各下载源，任意一个成功即返回，全部失败才报错\n    for (const auto& source : download_sources) {\n      auto package_url_result = format_download_url(source.url_template, version, filename);\n      if (!package_url_result) {\n        Logger().warn(\"Skipped source {} due to invalid package URL template: {}\", source.name,\n                      package_url_result.error());\n        continue;\n      }\n\n      auto checksums_url_result =\n          format_download_url(source.url_template, version, \"SHA256SUMS.txt\");\n      if (!checksums_url_result) {\n        Logger().warn(\"Skipped source {} due to invalid checksum URL template: {}\", source.name,\n                      checksums_url_result.error());\n        continue;\n      }\n\n      Core::Tasks::update_task_progress(\n          app_state, task_id,\n          make_task_progress(\"fetchChecksums\",\n                             std::format(\"Fetching checksums from {}\", source.name), 5.0));\n\n      auto checksums_content_result = co_await http_get(app_state, checksums_url_result.value());\n      if (!checksums_content_result) {\n        Logger().warn(\"Failed to fetch SHA256SUMS from {}: {}\", source.name,\n                      checksums_content_result.error());\n        continue;\n      }\n\n      auto expected_sha256_result =\n          parse_sha256sum_for_filename(checksums_content_result.value(), filename);\n      if (!expected_sha256_result) {\n        Logger().warn(\"Failed to parse SHA256SUMS from {}: {}\", source.name,\n                      expected_sha256_result.error());\n        continue;\n      }\n\n      Core::Tasks::update_task_progress(\n          app_state, task_id,\n          make_task_progress(\"download\", std::format(\"Trying download source: {}\", source.name),\n                             15.0));\n      Logger().info(\"Trying download source: {} ({})\", source.name, package_url_result.value());\n\n      auto progress_throttle = Utils::Throttle::create<Core::HttpClient::Types::DownloadProgress>(\n          std::chrono::milliseconds(250));\n      auto emit_progress = [&app_state, &task_id,\n                            &source](const Core::HttpClient::Types::DownloadProgress& progress) {\n        Core::Tasks::update_task_progress(app_state, task_id,\n                                          make_download_task_progress(source.name, progress));\n      };\n\n      auto download_result = co_await download_file(\n          app_state, package_url_result.value(), save_path,\n          [&progress_throttle,\n           &emit_progress](const Core::HttpClient::Types::DownloadProgress& progress) {\n            Utils::Throttle::call(*progress_throttle, emit_progress, progress);\n          });\n      Utils::Throttle::flush(*progress_throttle, emit_progress);\n      if (!download_result) {\n        Logger().warn(\"Download failed from {}: {}\", source.name, download_result.error());\n        continue;\n      }\n\n      Core::Tasks::update_task_progress(\n          app_state, task_id,\n          make_task_progress(\n              \"verify\", std::format(\"Verifying downloaded package from {}\", source.name), 85.0));\n\n      auto verify_result = verify_downloaded_file_sha256(save_path, expected_sha256_result.value());\n      if (!verify_result) {\n        std::error_code remove_error;\n        std::filesystem::remove(save_path, remove_error);\n        Logger().warn(\"SHA256 verification failed from {}: {}\", source.name, verify_result.error());\n        continue;\n      }\n\n      // SHA256 校验通过后才标记下载完成，确保 install_update 只使用已验证的文件\n      app_state.update->downloaded_version = version;\n\n      if (prepare_install_on_exit) {\n        Core::Tasks::update_task_progress(\n            app_state, task_id,\n            make_task_progress(\n                \"prepareInstall\",\n                std::format(\"Downloaded from {}. Preparing install on exit\", source.name), 95.0));\n\n        Types::InstallUpdateParams install_params;\n        install_params.restart = false;\n        auto install_result = install_update(app_state, install_params);\n        if (!install_result) {\n          auto error_message = \"Failed to prepare downloaded update: \" + install_result.error();\n          Logger().warn(\"Startup auto update prepare failed: {}\", install_result.error());\n          Core::Tasks::complete_task_failed(app_state, task_id, error_message);\n          co_return;\n        }\n      }\n\n      Core::Tasks::update_task_progress(\n          app_state, task_id,\n          make_task_progress(\n              \"completed\",\n              prepare_install_on_exit\n                  ? std::format(\"Downloaded from {} and scheduled for install on exit\", source.name)\n                  : std::format(\"Download completed from {}\", source.name),\n              100.0));\n\n      Logger().info(\"Download completed from {}: {}\", source.name, save_path.string());\n      Core::Tasks::complete_task_success(app_state, task_id);\n      co_return;\n    }\n\n    Core::Tasks::complete_task_failed(app_state, task_id, \"All download sources failed\");\n  } catch (const std::exception& e) {\n    Core::Tasks::complete_task_failed(app_state, task_id, std::string(e.what()));\n  }\n}\n\nauto start_download_update_task(Core::State::AppState& app_state, bool prepare_install_on_exit)\n    -> asio::awaitable<std::expected<Types::StartDownloadUpdateResult, std::string>> {\n  if (!app_state.update) {\n    co_return std::unexpected(\"Update not initialized\");\n  }\n\n  if (app_state.update->latest_version.empty()) {\n    co_return std::unexpected(\"No version info available. Please check for updates first.\");\n  }\n\n  if (!app_state.settings) {\n    co_return std::unexpected(\"Settings not initialized\");\n  }\n\n  if (!app_state.async) {\n    co_return std::unexpected(\"Async state is not initialized\");\n  }\n\n  // 同一时刻只允许一个下载任务，重复调用直接返回已有任务 ID\n  if (auto active_task = Core::Tasks::find_active_task_of_type(app_state, kUpdateDownloadTaskType);\n      active_task.has_value()) {\n    co_return Types::StartDownloadUpdateResult{\n        .task_id = active_task->task_id,\n        .status = \"already_running\",\n    };\n  }\n\n  auto* io_context = Core::Async::get_io_context(*app_state.async);\n  if (!io_context) {\n    co_return std::unexpected(\"Async runtime is not available\");\n  }\n\n  auto version = app_state.update->latest_version;\n  auto task_id = Core::Tasks::create_task(app_state, kUpdateDownloadTaskType, version);\n  if (task_id.empty()) {\n    co_return std::unexpected(\"Failed to create update download task\");\n  }\n\n  // co_await asio::post 将实际下载推迟到下一个事件循环周期，使本函数先返回给调用方\n  asio::co_spawn(\n      *io_context,\n      [&app_state, task_id, version, prepare_install_on_exit]() -> asio::awaitable<void> {\n        co_await asio::post(asio::use_awaitable);\n        co_await run_download_update_task(app_state, task_id, version, prepare_install_on_exit);\n      },\n      asio::detached);\n\n  co_return Types::StartDownloadUpdateResult{\n      .task_id = task_id,\n      .status = \"started\",\n  };\n}\n\nauto schedule_startup_auto_update_check(Core::State::AppState& app_state) -> void {\n  if (!app_state.settings || !app_state.update || !app_state.async) {\n    Logger().warn(\"Skip startup auto update check: state is not ready\");\n    return;\n  }\n\n  if (!app_state.settings->raw.update.auto_check) {\n    Logger().info(\"Skip startup auto update check: auto_check is disabled\");\n    return;\n  }\n\n  auto* io_context = Core::Async::get_io_context(*app_state.async);\n  if (!io_context) {\n    Logger().warn(\"Skip startup auto update check: async runtime is not ready\");\n    return;\n  }\n\n  asio::co_spawn(\n      *io_context,\n      [&app_state]() -> asio::awaitable<void> {\n        co_await asio::post(asio::use_awaitable);\n        Logger().info(\"Startup auto update check started\");\n\n        auto check_result = co_await check_for_update(app_state);\n        if (!check_result) {\n          Logger().warn(\"Startup auto update check failed: {}\", check_result.error());\n          co_return;\n        }\n\n        if (check_result->has_update) {\n          Logger().info(\"Startup auto update check found update: current={}, latest={}\",\n                        check_result->current_version, check_result->latest_version);\n\n          if (!app_state.settings || !app_state.update) {\n            Logger().warn(\"Skip startup auto update prepare: state is not ready\");\n            co_return;\n          }\n\n          if (!app_state.settings->raw.update.auto_update_on_exit) {\n            Logger().info(\"Skip startup auto update prepare: auto_update_on_exit is disabled\");\n            if (app_state.i18n) {\n              auto text_it = app_state.i18n->texts.find(\"message.update_available_about_prefix\");\n              if (text_it != app_state.i18n->texts.end()) {\n                post_update_notification(app_state, text_it->second + check_result->latest_version);\n              } else {\n                Logger().warn(\"Skip update available notification: i18n text is missing\");\n              }\n            }\n            co_return;\n          }\n\n          if (app_state.update->pending_update) {\n            Logger().info(\"Skip startup auto update prepare: pending update already exists\");\n            co_return;\n          }\n\n          auto download_task_result = co_await start_download_update_task(app_state, true);\n          if (!download_task_result) {\n            Logger().warn(\"Startup auto update download task failed: {}\",\n                          download_task_result.error());\n            co_return;\n          }\n\n          Logger().info(\"Startup auto update background download {}: task_id={}\",\n                        download_task_result->status, download_task_result->task_id);\n          co_return;\n        }\n\n        Logger().info(\"Startup auto update check completed: current version is up-to-date ({})\",\n                      check_result->current_version);\n      },\n      asio::detached);\n}\n\nauto check_for_update(Core::State::AppState& app_state)\n    -> asio::awaitable<std::expected<Types::CheckUpdateResult, std::string>> {\n  try {\n    if (!app_state.update) {\n      co_return std::unexpected(\"Update not initialized\");\n    }\n\n    app_state.update->is_checking = true;\n    app_state.update->error_message.clear();\n\n    if (!app_state.settings) {\n      app_state.update->is_checking = false;\n      co_return std::unexpected(\"Settings not initialized\");\n    }\n\n    // 从Cloudflare Pages获取最新版本号\n    const auto& version_url = app_state.settings->raw.update.version_url;\n    auto latest = co_await fetch_latest_version(app_state, version_url);\n    if (!latest) {\n      app_state.update->is_checking = false;\n      app_state.update->error_message = latest.error();\n      co_return std::unexpected(latest.error());\n    }\n\n    auto current_version = Vendor::Version::get_app_version();\n\n    Types::CheckUpdateResult result;\n    result.latest_version = latest.value();\n    result.current_version = current_version;\n    result.has_update = is_update_needed(current_version, result.latest_version);\n\n    // 更新状态\n    app_state.update->is_checking = false;\n    app_state.update->update_available = result.has_update;\n    app_state.update->latest_version = result.latest_version;\n    // 已下载的版本与最新版本不符时清除，避免安装过期文件\n    if (result.has_update && !app_state.update->downloaded_version.empty() &&\n        app_state.update->downloaded_version != result.latest_version) {\n      app_state.update->downloaded_version.clear();\n    }\n\n    Logger().info(\"Check for update: current={}, latest={}, has_update={}\", current_version,\n                  result.latest_version, result.has_update);\n\n    co_return result;\n\n  } catch (const std::exception& e) {\n    if (app_state.update) {\n      app_state.update->is_checking = false;\n      app_state.update->error_message = e.what();\n    }\n    co_return std::unexpected(std::string(e.what()));\n  }\n}\n\nauto execute_pending_update(Core::State::AppState& app_state) -> void {\n  if (!app_state.update || !app_state.update->pending_update) {\n    return;\n  }\n\n  const auto script_path = app_state.update->update_script_path;\n\n  Logger().info(\"Executing pending update script: {}\", script_path.string());\n\n  const auto script_directory = script_path.parent_path();\n  const auto command_parameters = std::format(L\"/d /c \\\"{}\\\"\", script_path.wstring());\n\n  // 启动更新脚本\n  Vendor::ShellApi::SHELLEXECUTEINFOW sei = {sizeof(sei)};\n  sei.fMask = Vendor::ShellApi::kSEE_MASK_NOASYNC;\n  sei.hwnd = nullptr;\n  sei.lpVerb = L\"open\";\n  sei.lpFile = L\"cmd.exe\";\n  sei.lpParameters = command_parameters.c_str();\n  sei.lpDirectory = script_directory.empty() ? nullptr : script_directory.c_str();\n  sei.nShow = Vendor::ShellApi::kSW_HIDE;\n  sei.hInstApp = nullptr;\n\n  const bool shell_execute_ok = Vendor::ShellApi::ShellExecuteExW(&sei) != FALSE;\n\n  if (shell_execute_ok) {\n    Logger().info(\"Update script launch accepted by shell\");\n  } else {\n    Logger().error(\"Failed to execute update script: last_error={}, script_path={}\",\n                   Vendor::Windows::GetLastError(), script_path.string());\n  }\n\n  // 清除待处理更新标志\n  app_state.update->pending_update = false;\n}\n\nauto install_update(Core::State::AppState& app_state, const Types::InstallUpdateParams& params)\n    -> std::expected<Types::InstallUpdateResult, std::string> {\n  try {\n    if (!app_state.update) {\n      return std::unexpected(\"Update not initialized\");\n    }\n\n    if (app_state.update->downloaded_version.empty()) {\n      return std::unexpected(\"No downloaded update available\");\n    }\n\n    if (!app_state.update->latest_version.empty() &&\n        app_state.update->downloaded_version != app_state.update->latest_version &&\n        app_state.update->update_available) {\n      return std::unexpected(\n          \"Downloaded update is outdated. Please download the latest version again.\");\n    }\n\n    // 根据安装类型确定更新包路径\n    auto filename =\n        get_update_filename(app_state.update->downloaded_version, app_state.update->is_portable);\n    auto temp_dir = get_temp_directory();\n    if (!temp_dir) {\n      return std::unexpected(\"Failed to get temporary directory: \" + temp_dir.error());\n    }\n    std::filesystem::path update_package_path = *temp_dir / filename;\n\n    if (!std::filesystem::exists(update_package_path)) {\n      return std::unexpected(\"Update package does not exist: \" + update_package_path.string());\n    }\n\n    Logger().info(\"Preparing update with package: {} (portable: {})\", update_package_path.string(),\n                  app_state.update->is_portable);\n\n    auto script_result =\n        create_update_script(update_package_path, app_state.update->is_portable,\n                             Vendor::Windows::GetCurrentProcessId(), params.restart);\n    if (!script_result) {\n      return std::unexpected(\"Failed to create update script: \" + script_result.error());\n    }\n\n    app_state.update->update_script_path = script_result.value();\n    app_state.update->pending_update = true;\n\n    Types::InstallUpdateResult result;\n\n    if (params.restart) {\n      Logger().info(\"Sending exit event for immediate update\");\n      Core::Events::post(*app_state.events, UI::FloatingWindow::Events::ExitEvent{});\n      result.message = \"Update will start immediately after application exits\";\n    } else {\n      Logger().info(\"Update scheduled for program exit\");\n      result.message = \"Update will be applied when the program exits\";\n    }\n\n    return result;\n\n  } catch (const std::exception& e) {\n    return std::unexpected(std::string(e.what()));\n  }\n}\n\n}  // namespace Features::Update\n"
  },
  {
    "path": "src/features/update/update.ixx",
    "content": "module;\n\n#include <asio.hpp>\n\nexport module Features.Update;\n\nimport std;\nimport Core.State;\nimport Features.Update.State;\nimport Features.Update.Types;\n\nnamespace Features::Update {\n\n// 初始化Update模块\nexport auto initialize(Core::State::AppState& app_state) -> std::expected<void, std::string>;\n\n// 启动时自动更新流程（按 settings 决定是否检查/下载/准备退出更新）\nexport auto schedule_startup_auto_update_check(Core::State::AppState& app_state) -> void;\n\n// 检查更新\nexport auto check_for_update(Core::State::AppState& app_state)\n    -> asio::awaitable<std::expected<Types::CheckUpdateResult, std::string>>;\n\n// 启动后台下载更新任务\nexport auto start_download_update_task(Core::State::AppState& app_state,\n                                       bool prepare_install_on_exit = false)\n    -> asio::awaitable<std::expected<Types::StartDownloadUpdateResult, std::string>>;\n\n// 安装更新\nexport auto install_update(Core::State::AppState& app_state,\n                           const Types::InstallUpdateParams& params)\n    -> std::expected<Types::InstallUpdateResult, std::string>;\n\n// 执行待处理的更新\nexport auto execute_pending_update(Core::State::AppState& app_state) -> void;\n\n}  // namespace Features::Update\n"
  },
  {
    "path": "src/features/window_control/state.ixx",
    "content": "module;\n\nexport module Features.WindowControl.State;\n\nimport std;\nimport Vendor.Windows;\n\nnamespace Features::WindowControl::State {\n\nexport struct WindowControlState {\n  std::jthread center_lock_monitor_thread;\n  // 退出时用于立即唤醒监控线程，避免 join() 额外等待一个轮询周期。\n  std::mutex center_lock_monitor_mutex;\n  std::condition_variable_any center_lock_monitor_cv;\n  // 仅在当前 clip 区域仍然是本模块上次写入的小矩形时才负责释放，\n  // 避免误清掉游戏后续自己重新设置的 ClipCursor 状态。\n  bool center_lock_owned{false};\n  Vendor::Windows::RECT last_center_lock_rect{};\n\n  // 仅在本模块为捕获稳定性 workaround 临时添加了 WS_EX_LAYERED 时负责恢复。\n  bool layered_capture_workaround_owned{false};\n  Vendor::Windows::HWND layered_capture_workaround_hwnd{nullptr};\n};\n\nexport constexpr auto kCenterLockPollInterval = std::chrono::milliseconds{50};\nexport constexpr int kCenterLockSize = 1;\nexport constexpr int kClipTolerance = 10;\n\n}  // namespace Features::WindowControl::State\n"
  },
  {
    "path": "src/features/window_control/usecase.cpp",
    "content": "module;\n\nmodule Features.WindowControl.UseCase;\n\nimport std;\nimport Features.Settings.Menu;\nimport Core.State;\nimport Core.Async.UiAwaitable;\nimport Core.I18n.State;\nimport UI.FloatingWindow;\nimport UI.FloatingWindow.Events;\nimport UI.FloatingWindow.State;\nimport Features.Settings;\nimport Features.Settings.State;\nimport Features.Letterbox;\nimport Features.Letterbox.State;\nimport Features.WindowControl;\nimport Features.Notifications;\nimport Features.Overlay;\nimport Features.Overlay.State;\nimport Features.Preview;\nimport Features.Preview.State;\nimport Features.Overlay.Geometry;\nimport Features.Overlay.Interaction;\nimport Utils.Logger;\nimport Utils.String;\nimport Vendor.Windows;\n\nnamespace Features::WindowControl::UseCase {\n\n// 获取当前比例\nauto get_current_ratio(const Core::State::AppState& state) -> double {\n  const auto& ratios = Features::Settings::Menu::get_ratios(*state.settings);\n  if (state.floating_window->ui.current_ratio_index < ratios.size()) {\n    return ratios[state.floating_window->ui.current_ratio_index].ratio;\n  }\n\n  // 默认使用屏幕比例\n  int screen_width = Vendor::Windows::GetScreenWidth();\n  int screen_height = Vendor::Windows::GetScreenHeight();\n  return static_cast<double>(screen_width) / screen_height;\n}\n\n// 获取当前总像素数\nauto get_current_total_pixels(const Core::State::AppState& state) -> std::uint64_t {\n  const auto& resolutions = Features::Settings::Menu::get_resolutions(*state.settings);\n  if (state.floating_window->ui.current_resolution_index < resolutions.size()) {\n    return resolutions[state.floating_window->ui.current_resolution_index].total_pixels;\n  }\n  return 0;  // 表示使用屏幕尺寸\n}\n\n// 变换前的准备\n// 返回值：是否需要等待 overlay 首帧\nauto prepare_transform_actions(Core::State::AppState& state, Vendor::Windows::HWND target_window,\n                               int target_width, int target_height) -> bool {\n  if (!state.overlay->enabled) {\n    return false;\n  }\n\n  auto [screen_w, screen_h] = Features::Overlay::Geometry::get_screen_dimensions();\n  bool will_need_overlay = Features::Overlay::Geometry::should_use_overlay(\n      target_width, target_height, screen_w, screen_h);\n\n  if (state.overlay->running.load(std::memory_order_acquire)) {\n    // overlay 已运行，冻结当前帧\n    state.overlay->is_transforming.store(true, std::memory_order_release);\n    Features::Overlay::freeze_overlay(state);\n    return false;  // 不需要等待首帧\n  } else if (will_need_overlay) {\n    // overlay 未运行，但目标尺寸需要 overlay，启动并在首帧后自动冻结\n    state.overlay->is_transforming.store(true, std::memory_order_release);\n    auto overlay_result = Features::Overlay::start_overlay(state, target_window, true);\n    if (overlay_result) {\n      return true;  // 需要等待首帧\n    } else {\n      Logger().error(\"Failed to start overlay before window transform: {}\", overlay_result.error());\n      state.overlay->is_transforming.store(false, std::memory_order_release);\n      return false;\n    }\n  }\n\n  // overlay 未运行，目标也不需要，什么都不做\n  return false;\n}\n\n// 变换后的后续处理\nauto post_transform_actions(Core::State::AppState& state, Vendor::Windows::HWND target_window)\n    -> void {\n  if (state.overlay->is_transforming.load(std::memory_order_acquire)) {\n    auto dimensions = Features::Overlay::Geometry::get_window_dimensions(target_window);\n    auto [screen_w, screen_h] = Features::Overlay::Geometry::get_screen_dimensions();\n    bool still_needs_overlay =\n        dimensions && Features::Overlay::Geometry::should_use_overlay(\n                          dimensions->first, dimensions->second, screen_w, screen_h);\n\n    // 先结束 transform 状态，再解冻 overlay。\n    // 这样解冻后到达的第一帧新尺寸会被正常消费，而不会再走“变换中忽略 resize”的分支。\n    state.overlay->is_transforming.store(false, std::memory_order_release);\n\n    if (still_needs_overlay) {\n      // 仍需 overlay：解冻继续\n      Features::Overlay::unfreeze_overlay(state);\n      Features::Overlay::Interaction::suppress_taskbar_redraw(state);\n    } else {\n      // 不需要 overlay：停止\n      Features::Overlay::stop_overlay(state);\n    }\n  }\n\n  // 重启 letterbox\n  if (!state.overlay->running.load(std::memory_order_acquire) && state.letterbox->enabled) {\n    auto letterbox_result = Features::Letterbox::show(state, target_window);\n    if (!letterbox_result) {\n      Logger().error(\"Failed to restart letterbox after window transform: {}\",\n                     letterbox_result.error());\n    }\n  }\n}\n\n// 比例变换的完整协程流程\nauto transform_ratio_async(Core::State::AppState& state, size_t ratio_index, double ratio_value)\n    -> Core::Async::ui_task {\n  Logger().debug(\"[Coroutine] Transforming ratio to index {}, ratio: {}\", ratio_index, ratio_value);\n\n  // 查找目标窗口\n  std::wstring window_title = Utils::String::FromUtf8(state.settings->raw.window.target_title);\n  auto target_window = Features::WindowControl::find_target_window(window_title);\n  if (!target_window) {\n    Features::Notifications::show_notification(state, state.i18n->texts[\"label.app_name\"],\n                                               state.i18n->texts[\"message.window_not_found\"]);\n    co_return;\n  }\n\n  // 计算目标分辨率\n  auto total_pixels = get_current_total_pixels(state);\n  Features::WindowControl::Resolution new_resolution;\n\n  if (total_pixels == 0) {\n    new_resolution = Features::WindowControl::calculate_resolution_by_screen(ratio_value);\n  } else {\n    new_resolution = Features::WindowControl::calculate_resolution(ratio_value, total_pixels);\n  }\n\n  // 准备变换\n  bool needs_wait_first_frame =\n      prepare_transform_actions(state, *target_window, new_resolution.width, new_resolution.height);\n\n  // 如果需要等待 overlay 首帧（最多 500ms）\n  if (needs_wait_first_frame) {\n    for (int i = 0; i < 50 && !state.overlay->freeze_rendering.load(std::memory_order_acquire);\n         ++i) {\n      co_await Core::Async::ui_delay{std::chrono::milliseconds(10)};\n    }\n  }\n\n  // 应用窗口变换\n  Features::WindowControl::TransformOptions options{.activate_window = true};\n\n  auto result = Features::WindowControl::apply_window_transform(state, *target_window,\n                                                                new_resolution, options);\n  if (!result) {\n    state.overlay->is_transforming.store(false, std::memory_order_release);\n    Features::Notifications::show_notification(\n        state, state.i18n->texts[\"label.app_name\"],\n        state.i18n->texts[\"message.window_adjust_failed\"] + \": \" + result.error());\n    co_return;\n  }\n\n  // 后续处理：等待窗口稳定后决定 overlay 状态\n  if (state.overlay->is_transforming.load(std::memory_order_acquire)) {\n    co_await Core::Async::ui_delay{std::chrono::milliseconds(400)};\n    post_transform_actions(state, *target_window);\n  }\n\n  // 更新当前比例索引\n  const auto& ratios = Features::Settings::Menu::get_ratios(*state.settings);\n  if (ratio_index < ratios.size() || ratio_index == std::numeric_limits<size_t>::max()) {\n    state.floating_window->ui.current_ratio_index = ratio_index;\n  }\n\n  // 请求重绘悬浮窗\n  UI::FloatingWindow::request_repaint(state);\n}\n\n// 处理比例改变事件（启动协程）\nauto handle_ratio_changed(Core::State::AppState& state,\n                          const UI::FloatingWindow::Events::RatioChangeEvent& event) -> void {\n  // 直接调用协程函数（ui_task 使用 suspend_never，立即开始执行）\n  transform_ratio_async(state, event.index, event.ratio_value);\n}\n\n// 分辨率变换的完整协程流程\nauto transform_resolution_async(Core::State::AppState& state, size_t resolution_index,\n                                std::uint64_t total_pixels) -> Core::Async::ui_task {\n  Logger().debug(\"[Coroutine] Transforming resolution to index {}, pixels: {}\", resolution_index,\n                 total_pixels);\n\n  // 查找目标窗口\n  std::wstring window_title = Utils::String::FromUtf8(state.settings->raw.window.target_title);\n  auto target_window = Features::WindowControl::find_target_window(window_title);\n  if (!target_window) {\n    Features::Notifications::show_notification(state, state.i18n->texts[\"label.app_name\"],\n                                               state.i18n->texts[\"message.window_not_found\"]);\n    co_return;\n  }\n\n  // 计算目标分辨率\n  double current_ratio = get_current_ratio(state);\n  Features::WindowControl::Resolution new_resolution;\n\n  if (total_pixels == 0) {\n    new_resolution = Features::WindowControl::calculate_resolution_by_screen(current_ratio);\n  } else {\n    new_resolution = Features::WindowControl::calculate_resolution(current_ratio, total_pixels);\n  }\n\n  // 准备变换\n  bool needs_wait_first_frame =\n      prepare_transform_actions(state, *target_window, new_resolution.width, new_resolution.height);\n\n  // 如果需要等待 overlay 首帧（最多 500ms）\n  if (needs_wait_first_frame) {\n    for (int i = 0; i < 50 && !state.overlay->freeze_rendering.load(std::memory_order_acquire);\n         ++i) {\n      co_await Core::Async::ui_delay{std::chrono::milliseconds(10)};\n    }\n  }\n\n  // 应用窗口变换\n  Features::WindowControl::TransformOptions options{.activate_window = true};\n\n  auto result = Features::WindowControl::apply_window_transform(state, *target_window,\n                                                                new_resolution, options);\n  if (!result) {\n    state.overlay->is_transforming.store(false, std::memory_order_release);\n    Features::Notifications::show_notification(\n        state, state.i18n->texts[\"label.app_name\"],\n        state.i18n->texts[\"message.window_adjust_failed\"] + \": \" + result.error());\n    co_return;\n  }\n\n  // 后续处理：等待窗口稳定后决定 overlay 状态\n  if (state.overlay->is_transforming.load(std::memory_order_acquire)) {\n    co_await Core::Async::ui_delay{std::chrono::milliseconds(400)};\n    post_transform_actions(state, *target_window);\n  }\n\n  // 更新当前分辨率索引\n  const auto& resolutions = Features::Settings::Menu::get_resolutions(*state.settings);\n  if (resolution_index < resolutions.size()) {\n    state.floating_window->ui.current_resolution_index = resolution_index;\n  }\n\n  // 请求重绘悬浮窗\n  UI::FloatingWindow::request_repaint(state);\n}\n\n// 处理分辨率改变事件（启动协程）\nauto handle_resolution_changed(Core::State::AppState& state,\n                               const UI::FloatingWindow::Events::ResolutionChangeEvent& event)\n    -> void {\n  // 直接调用协程函数（ui_task 使用 suspend_never，立即开始执行）\n  transform_resolution_async(state, event.index, event.total_pixels);\n}\n\n// 处理窗口选择事件\nauto handle_window_selected(Core::State::AppState& state,\n                            const UI::FloatingWindow::Events::WindowSelectionEvent& event) -> void {\n  Logger().info(\"Window selected: {}\", Utils::String::ToUtf8(event.window_title));\n  auto old_settings = state.settings->raw;\n\n  // 更新设置状态中的目标窗口标题\n  state.settings->raw.window.target_title = Utils::String::ToUtf8(event.window_title);\n\n  // 保存设置到文件\n  bool did_persist_settings = false;\n  auto settings_path = Features::Settings::get_settings_path();\n  if (settings_path) {\n    auto save_result =\n        Features::Settings::save_settings_to_file(settings_path.value(), state.settings->raw);\n    if (!save_result) {\n      Logger().error(\"Failed to save settings: {}\", save_result.error());\n      // 可能需要通知用户保存失败\n    } else {\n      did_persist_settings = true;\n    }\n  } else {\n    Logger().error(\"Failed to get settings path: {}\", settings_path.error());\n  }\n\n  if (did_persist_settings) {\n    Features::Settings::notify_settings_changed(state, old_settings,\n                                                \"Settings updated via window selection\");\n  }\n\n  auto target_window = Features::WindowControl::find_target_window(event.window_title);\n  if (!target_window) {\n    Features::Notifications::show_notification(state, state.i18n->texts[\"label.app_name\"],\n                                               state.i18n->texts[\"message.window_not_found\"]);\n    return;\n  }\n  post_transform_actions(state, target_window.value());\n\n  // 发送通知给用户\n  Features::Notifications::show_notification(\n      state, state.i18n->texts[\"label.app_name\"],\n      std::format(\"{}: {}\", state.i18n->texts[\"message.window_selected\"],\n                  Utils::String::ToUtf8(event.window_title)));\n}\n\n// 重置窗口变换（直接调用版本）\nauto reset_window_transform(Core::State::AppState& state) -> void {\n  std::wstring window_title = Utils::String::FromUtf8(state.settings->raw.window.target_title);\n  auto target_window = Features::WindowControl::find_target_window(window_title);\n  if (!target_window) {\n    Logger().error(\"Failed to find target window\");\n    return;\n  }\n\n  Features::WindowControl::TransformOptions options{.activate_window = true};\n\n  const auto& reset_resolution = state.settings->raw.window.reset_resolution;\n\n  std::expected<void, std::string> result = std::unexpected(\"Unknown reset mode\");\n  if (reset_resolution.width > 0 && reset_resolution.height > 0) {\n    Features::WindowControl::Resolution resolution{\n        .width = reset_resolution.width,\n        .height = reset_resolution.height,\n    };\n    result =\n        Features::WindowControl::apply_window_transform(state, *target_window, resolution, options);\n  } else {\n    result = Features::WindowControl::reset_window_to_screen(state, *target_window, options);\n  }\n\n  if (!result) {\n    Logger().error(\"Failed to reset window: {}\", result.error());\n    return;\n  }\n\n  // 重置后恢复浮窗选中状态：比例清空，分辨率回到 Default\n  state.floating_window->ui.current_ratio_index = std::numeric_limits<size_t>::max();\n  state.floating_window->ui.current_resolution_index = 0;\n  UI::FloatingWindow::request_repaint(state);\n}\n\n}  // namespace Features::WindowControl::UseCase\n"
  },
  {
    "path": "src/features/window_control/usecase.ixx",
    "content": "module;\n\nexport module Features.WindowControl.UseCase;\n\nimport Core.State;\nimport UI.FloatingWindow.Events;\n\nnamespace Features::WindowControl::UseCase {\n\n// 处理比例改变事件\nexport auto handle_ratio_changed(Core::State::AppState& state,\n                                 const UI::FloatingWindow::Events::RatioChangeEvent& event) -> void;\n\n// 处理分辨率改变事件\nexport auto handle_resolution_changed(\n    Core::State::AppState& state, const UI::FloatingWindow::Events::ResolutionChangeEvent& event)\n    -> void;\n\n// 处理窗口选择事件\nexport auto handle_window_selected(Core::State::AppState& state,\n                                   const UI::FloatingWindow::Events::WindowSelectionEvent& event)\n    -> void;\n\n// 重置窗口变换（直接调用版本）\nexport auto reset_window_transform(Core::State::AppState& state) -> void;\n\n}  // namespace Features::WindowControl::UseCase\n"
  },
  {
    "path": "src/features/window_control/window_control.cpp",
    "content": "module;\n\nmodule Features.WindowControl;\n\nimport std;\nimport Core.State;\nimport Features.Settings.State;\nimport Features.WindowControl.State;\nimport Utils.Logger;\nimport Utils.String;\nimport <windows.h>;\n\nnamespace Features::WindowControl {\n\nauto get_virtual_screen_rect() -> RECT {\n  return RECT{\n      .left = GetSystemMetrics(SM_XVIRTUALSCREEN),\n      .top = GetSystemMetrics(SM_YVIRTUALSCREEN),\n      .right = GetSystemMetrics(SM_XVIRTUALSCREEN) + GetSystemMetrics(SM_CXVIRTUALSCREEN),\n      .bottom = GetSystemMetrics(SM_YVIRTUALSCREEN) + GetSystemMetrics(SM_CYVIRTUALSCREEN),\n  };\n}\n\nauto are_rects_equal(const RECT& lhs, const RECT& rhs) -> bool {\n  return lhs.left == rhs.left && lhs.top == rhs.top && lhs.right == rhs.right &&\n         lhs.bottom == rhs.bottom;\n}\n\nauto is_rect_similar(const RECT& lhs, const RECT& rhs, int tolerance) -> bool {\n  return std::abs(lhs.left - rhs.left) <= tolerance && std::abs(lhs.top - rhs.top) <= tolerance &&\n         std::abs(lhs.right - rhs.right) <= tolerance &&\n         std::abs(lhs.bottom - rhs.bottom) <= tolerance;\n}\n\nauto reset_center_lock_tracking(State::WindowControlState& window_control) -> void {\n  window_control.center_lock_owned = false;\n  window_control.last_center_lock_rect = RECT{};\n}\n\nauto reset_layered_capture_workaround_tracking(State::WindowControlState& window_control) -> void {\n  window_control.layered_capture_workaround_owned = false;\n  window_control.layered_capture_workaround_hwnd = nullptr;\n}\n\n// GetWindowLong：返回值为 0 时可能是合法样式，需用 LastError 区分失败。\nauto get_window_long_checked(HWND hwnd, int index, const char* field_label)\n    -> std::expected<LONG, std::string> {\n  SetLastError(0);\n  const LONG value = GetWindowLong(hwnd, index);\n  if (value == 0 && GetLastError() != 0) {\n    return std::unexpected{std::format(\"Failed to get window {} (GetWindowLong).\", field_label)};\n  }\n  return value;\n}\n\n// SetWindowLong：成功时返回先前值，先前值也可能为 0。\nauto set_window_long_checked(HWND hwnd, int index, LONG new_value, std::string error_message)\n    -> std::expected<void, std::string> {\n  SetLastError(0);\n  if (SetWindowLong(hwnd, index, new_value) == 0 && GetLastError() != 0) {\n    return std::unexpected{std::move(error_message)};\n  }\n  return {};\n}\n\nauto set_layered_window_style(HWND hwnd, DWORD ex_style, bool enabled)\n    -> std::expected<DWORD, std::string> {\n  DWORD next_style = enabled ? (ex_style | WS_EX_LAYERED) : (ex_style & ~WS_EX_LAYERED);\n  if (next_style == ex_style) {\n    return ex_style;\n  }\n\n  SetLastError(0);\n  if (SetWindowLong(hwnd, GWL_EXSTYLE, next_style) == 0 && GetLastError() != 0) {\n    return std::unexpected{enabled ? \"Failed to enable layered window style (SetWindowLong).\"\n                                   : \"Failed to disable layered window style (SetWindowLong).\"};\n  }\n\n  return next_style;\n}\n\nauto release_layered_capture_workaround_if_owned(State::WindowControlState& window_control,\n                                                 HWND hwnd) -> std::expected<void, std::string> {\n  if (!window_control.layered_capture_workaround_owned ||\n      window_control.layered_capture_workaround_hwnd != hwnd) {\n    return {};\n  }\n\n  if (!hwnd || !IsWindow(hwnd)) {\n    reset_layered_capture_workaround_tracking(window_control);\n    return {};\n  }\n\n  auto ex_style_long = get_window_long_checked(hwnd, GWL_EXSTYLE, \"ex style\");\n  if (!ex_style_long) {\n    return std::unexpected{ex_style_long.error()};\n  }\n  const DWORD ex_style = static_cast<DWORD>(*ex_style_long);\n\n  auto release_result = set_layered_window_style(hwnd, ex_style, false);\n  if (!release_result) {\n    return std::unexpected{release_result.error()};\n  }\n\n  reset_layered_capture_workaround_tracking(window_control);\n  return {};\n}\n\nauto apply_layered_capture_workaround(Core::State::AppState& state, HWND hwnd, int width,\n                                      int height, DWORD ex_style)\n    -> std::expected<DWORD, std::string> {\n  auto* window_control = state.window_control.get();\n  if (!window_control) {\n    return ex_style;\n  }\n\n  const int screen_w = GetSystemMetrics(SM_CXSCREEN);\n  const int screen_h = GetSystemMetrics(SM_CYSCREEN);\n  const bool oversized = width > screen_w || height > screen_h;\n  const bool workaround_enabled =\n      state.settings && state.settings->raw.window.enable_layered_capture_workaround;\n\n  if (window_control->layered_capture_workaround_owned &&\n      window_control->layered_capture_workaround_hwnd != hwnd) {\n    if (auto release_result = release_layered_capture_workaround_if_owned(\n            *window_control, window_control->layered_capture_workaround_hwnd);\n        !release_result) {\n      return std::unexpected{release_result.error()};\n    }\n  }\n\n  if (workaround_enabled && oversized) {\n    const bool already_layered = (ex_style & WS_EX_LAYERED) != 0;\n    auto apply_result = set_layered_window_style(hwnd, ex_style, true);\n    if (!apply_result) {\n      return std::unexpected{apply_result.error()};\n    }\n\n    if (!already_layered) {\n      window_control->layered_capture_workaround_owned = true;\n      window_control->layered_capture_workaround_hwnd = hwnd;\n    } else if (!window_control->layered_capture_workaround_owned ||\n               window_control->layered_capture_workaround_hwnd != hwnd) {\n      reset_layered_capture_workaround_tracking(*window_control);\n    }\n\n    return apply_result.value();\n  }\n\n  if (auto release_result = release_layered_capture_workaround_if_owned(*window_control, hwnd);\n      !release_result) {\n    return std::unexpected{release_result.error()};\n  }\n\n  auto refreshed = get_window_long_checked(hwnd, GWL_EXSTYLE, \"ex style\");\n  if (!refreshed) {\n    return std::unexpected{refreshed.error()};\n  }\n  return static_cast<DWORD>(*refreshed);\n}\n\nauto get_clip_rect() -> std::optional<RECT> {\n  RECT clip_rect{};\n  if (!GetClipCursor(&clip_rect)) {\n    return std::nullopt;\n  }\n\n  return clip_rect;\n}\n\nauto is_clip_cursor_active(const RECT& clip_rect) -> bool {\n  // 仅在 clip 与虚拟屏“完全一致”时才视为未激活。\n  // 全屏/无边框全屏场景下常见 1px 内缩（例如 [1,1,w-1,h-1]），\n  // 不能再被判成 inactive，否则中心锁逻辑永远不触发。\n  return !are_rects_equal(clip_rect, get_virtual_screen_rect());\n}\n\nauto get_window_process_id(HWND hwnd) -> DWORD {\n  DWORD process_id = 0;\n  GetWindowThreadProcessId(hwnd, &process_id);\n  return process_id;\n}\n\nauto get_client_rect_in_screen_coords(HWND hwnd) -> std::optional<RECT> {\n  RECT client_rect{};\n  if (!GetClientRect(hwnd, &client_rect)) {\n    return std::nullopt;\n  }\n\n  POINT top_left{client_rect.left, client_rect.top};\n  POINT bottom_right{client_rect.right, client_rect.bottom};\n  if (!ClientToScreen(hwnd, &top_left) || !ClientToScreen(hwnd, &bottom_right)) {\n    return std::nullopt;\n  }\n\n  return RECT{\n      .left = top_left.x,\n      .top = top_left.y,\n      .right = bottom_right.x,\n      .bottom = bottom_right.y,\n  };\n}\n\nauto calculate_center_lock_rect(const RECT& client_rect) -> RECT {\n  const int center_x = (client_rect.left + client_rect.right) / 2;\n  const int center_y = (client_rect.top + client_rect.bottom) / 2;\n  const int half_size = State::kCenterLockSize / 2;\n\n  return RECT{\n      .left = center_x - half_size,\n      .top = center_y - half_size,\n      .right = center_x + half_size,\n      .bottom = center_y + half_size,\n  };\n}\n\nauto release_center_lock_if_owned(State::WindowControlState& window_control) -> void {\n  if (!window_control.center_lock_owned) {\n    return;\n  }\n\n  // 只有当前 ClipCursor 仍然等于我们上次设置的小矩形时才释放。\n  // 如果游戏已经重新写入了新的 clip 区域，这里应当让游戏继续接管。\n  auto current_clip_rect = get_clip_rect();\n  if (current_clip_rect &&\n      are_rects_equal(*current_clip_rect, window_control.last_center_lock_rect)) {\n    ClipCursor(nullptr);\n  }\n\n  reset_center_lock_tracking(window_control);\n}\n\nauto process_center_lock_monitor(Core::State::AppState& state) -> void {\n  if (!state.window_control || !state.settings) {\n    return;\n  }\n\n  auto& window_control = *state.window_control;\n  // 任一前置条件不满足时，都回到“若是我们接管的 clip，则安全释放”的统一收口。\n  auto revert = [&]() { release_center_lock_if_owned(window_control); };\n\n  const auto& window_settings = state.settings->raw.window;\n  if (!window_settings.center_lock_cursor || window_settings.target_title.empty()) {\n    revert();\n    return;\n  }\n\n  HWND foreground_window = GetForegroundWindow();\n  if (!foreground_window || !IsWindow(foreground_window)) {\n    revert();\n    return;\n  }\n\n  auto configured_title = Utils::String::FromUtf8(window_settings.target_title);\n  // 这里先根据设置解析目标 HWND，再比较 foreground HWND。\n  // 不再直接读取前台窗口标题，避免退出阶段对本进程窗口发送同步文本消息而死锁。\n  auto target_window = find_target_window(configured_title);\n  if (!target_window || foreground_window != *target_window) {\n    revert();\n    return;\n  }\n\n  auto clip_rect = get_clip_rect();\n  if (!clip_rect || !is_clip_cursor_active(*clip_rect)) {\n    revert();\n    return;\n  }\n\n  auto client_rect = get_client_rect_in_screen_coords(*target_window);\n  if (!client_rect) {\n    revert();\n    return;\n  }\n\n  const auto center_lock_rect = calculate_center_lock_rect(*client_rect);\n  if (are_rects_equal(*clip_rect, center_lock_rect)) {\n    window_control.center_lock_owned = true;\n    window_control.last_center_lock_rect = center_lock_rect;\n    return;\n  }\n\n  if (!is_rect_similar(*clip_rect, *client_rect, State::kClipTolerance)) {\n    revert();\n    return;\n  }\n\n  SetLastError(0);\n  if (!ClipCursor(&center_lock_rect)) {\n    revert();\n    return;\n  }\n\n  window_control.center_lock_owned = true;\n  window_control.last_center_lock_rect = center_lock_rect;\n}\n\nauto center_lock_monitor_thread_proc(Core::State::AppState& state, std::stop_token stop_token)\n    -> void {\n  auto& window_control = *state.window_control;\n  // stop_token 触发时立即唤醒 wait_for，使 shutdown 阶段的 join() 可以快速返回。\n  std::stop_callback on_stop(\n      stop_token, [&window_control]() { window_control.center_lock_monitor_cv.notify_all(); });\n  std::unique_lock lock(window_control.center_lock_monitor_mutex);\n\n  while (!stop_token.stop_requested()) {\n    // 监控逻辑不需要持有互斥量；这里只把锁用于可中断等待。\n    lock.unlock();\n    process_center_lock_monitor(state);\n    lock.lock();\n    window_control.center_lock_monitor_cv.wait_for(lock, State::kCenterLockPollInterval);\n  }\n\n  if (state.window_control) {\n    release_center_lock_if_owned(*state.window_control);\n  }\n}\n\n// 查找目标窗口\nauto find_target_window(const std::wstring& configured_title) -> std::expected<HWND, std::string> {\n  if (configured_title.empty()) {\n    return std::unexpected{\"Target window not found. Please ensure the game is running.\"};\n  }\n\n  for (const auto& window : get_visible_windows()) {\n    if (window.title == configured_title) {\n      return window.handle;\n    }\n  }\n\n  return std::unexpected{\"Target window not found. Please ensure the game is running.\"};\n}\n\n// 调整窗口大小并居中\nauto resize_and_center_window(Core::State::AppState& state, HWND hwnd, int width, int height,\n                              bool activate) -> std::expected<void, std::string> {\n  if (!hwnd || !IsWindow(hwnd)) {\n    return std::unexpected{\"Failed to resize window: Invalid window handle provided.\"};\n  }\n\n  const int screen_w = GetSystemMetrics(SM_CXSCREEN);\n  const int screen_h = GetSystemMetrics(SM_CYSCREEN);\n\n  auto style_long = get_window_long_checked(hwnd, GWL_STYLE, \"style\");\n  if (!style_long) {\n    return std::unexpected{style_long.error()};\n  }\n  DWORD style = static_cast<DWORD>(*style_long);\n\n  auto ex_style_long = get_window_long_checked(hwnd, GWL_EXSTYLE, \"ex style\");\n  if (!ex_style_long) {\n    return std::unexpected{ex_style_long.error()};\n  }\n  DWORD exStyle = static_cast<DWORD>(*ex_style_long);\n\n  auto ex_style_result = apply_layered_capture_workaround(state, hwnd, width, height, exStyle);\n  if (!ex_style_result) {\n    return std::unexpected{ex_style_result.error()};\n  }\n  exStyle = ex_style_result.value();\n\n  // 如果是有边框窗口且需要超出屏幕尺寸，转换为无边框\n  if ((style & WS_OVERLAPPEDWINDOW) && (width >= screen_w || height >= screen_h)) {\n    style &= ~(WS_OVERLAPPEDWINDOW);\n    style |= WS_POPUP;\n    if (auto r = set_window_long_checked(hwnd, GWL_STYLE, static_cast<LONG>(style),\n                                         \"Failed to remove window border (SetWindowLong).\");\n        !r) {\n      return std::unexpected{r.error()};\n    }\n  }\n  // 如果是无边框窗口且高度小于屏幕高度，转换为有边框\n  else if ((style & WS_POPUP) && width < screen_w && height < screen_h) {\n    style &= ~(WS_POPUP);\n    style |= WS_OVERLAPPEDWINDOW;\n    if (auto r = set_window_long_checked(hwnd, GWL_STYLE, static_cast<LONG>(style),\n                                         \"Failed to restore window border (SetWindowLong).\");\n        !r) {\n      return std::unexpected{r.error()};\n    }\n  }\n\n  // 调整窗口大小\n  RECT rect = {0, 0, width, height};\n  if (!AdjustWindowRectEx(&rect, style, FALSE, exStyle)) {\n    return std::unexpected{\"Failed to calculate window rectangle (AdjustWindowRectEx).\"};\n  }\n\n  // 使用 rect 的 left 和 top 值来调整位置这些值通常是负数\n  int totalWidth = rect.right - rect.left;\n  int totalHeight = rect.bottom - rect.top;\n  int borderOffsetX = rect.left;  // 左边框的偏移量（负值）\n  int borderOffsetY = rect.top;   // 顶部边框的偏移量（负值）\n\n  // 计算屏幕中心位置，考虑边框偏移\n  int newLeft = (screen_w - width) / 2 + borderOffsetX;\n  int newTop = (screen_h - height) / 2 + borderOffsetY;\n\n  UINT flags = SWP_NOZORDER;\n  if (!activate) {\n    flags |= SWP_NOACTIVATE;\n  }\n\n  // 设置新的窗口大小和位置\n  if (!SetWindowPos(hwnd, nullptr, newLeft, newTop, totalWidth, totalHeight, flags)) {\n    return std::unexpected{\"Failed to set window position and size (SetWindowPos).\"};\n  }\n\n  // 窗口调整成功后，始终将任务栏置底\n  if (HWND taskbar = FindWindow(L\"Shell_TrayWnd\", nullptr)) {\n    SetWindowPos(taskbar, HWND_BOTTOM, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE);\n  }\n\n  return {};\n}\n\n// 获取所有可见窗口的列表\nauto get_visible_windows() -> std::vector<WindowInfo> {\n  std::vector<WindowInfo> windows;\n\n  // 回调函数\n  auto enumWindowsProc = [](HWND hwnd, LPARAM lParam) -> BOOL {\n    wchar_t windowText[256];\n\n    if (!IsWindowVisible(hwnd)) {\n      return TRUE;\n    }\n    // 跳过本进程窗口，避免锁鼠监控把浮窗 / WebView / 菜单等误当作候选目标，\n    // 也顺带规避本进程窗口文本读取带来的同步消息风险。\n    if (get_window_process_id(hwnd) == GetCurrentProcessId()) {\n      return TRUE;\n    }\n    if (!GetWindowText(hwnd, windowText, 256)) {\n      return TRUE;\n    }\n\n    auto* windows_ptr = reinterpret_cast<std::vector<WindowInfo>*>(lParam);\n    if (windowText[0] != '\\0') {  // 只收集有标题的窗口\n      windows_ptr->push_back({hwnd, windowText});\n    }\n\n    return TRUE;\n  };\n\n  EnumWindows(enumWindowsProc, reinterpret_cast<LPARAM>(&windows));\n  return windows;\n}\n\n// 切换窗口边框\nauto toggle_window_border(HWND hwnd) -> std::expected<bool, std::string> {\n  if (!hwnd || !IsWindow(hwnd)) {\n    return std::unexpected{\"Failed to toggle window border: Invalid window handle provided.\"};\n  }\n\n  auto style_long = get_window_long_checked(hwnd, GWL_STYLE, \"style\");\n  if (!style_long) {\n    return std::unexpected{style_long.error()};\n  }\n  LONG style = *style_long;\n\n  // 检查当前是否有边框\n  bool hasBorder = (style & WS_OVERLAPPEDWINDOW) != 0;\n\n  if (hasBorder) {\n    // 移除边框样式\n    style &= ~WS_OVERLAPPEDWINDOW;\n    style |= WS_POPUP;  // 添加WS_POPUP样式\n  } else {\n    // 添加边框样式\n    style &= ~WS_POPUP;  // 移除WS_POPUP样式\n    style |= WS_OVERLAPPEDWINDOW;\n  }\n\n  if (auto r = set_window_long_checked(hwnd, GWL_STYLE, style,\n                                       \"Failed to apply new window style (SetWindowLong).\");\n      !r) {\n    return std::unexpected{r.error()};\n  }\n\n  // 强制窗口重绘和重新布局\n  if (!SetWindowPos(hwnd, nullptr, 0, 0, 0, 0,\n                    SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED)) {\n    return std::unexpected{\"Failed to force window repaint (SetWindowPos).\"};\n  }\n\n  // 返回切换后的状态\n  return !hasBorder;\n}\n\n// 根据比例和总像素计算分辨率\nauto calculate_resolution(double ratio, std::uint64_t total_pixels) -> Resolution {\n  double height_d = std::sqrt(static_cast<double>(total_pixels) / ratio);\n  double width_d = height_d * ratio;\n\n  int height = static_cast<int>(std::round(height_d));\n  int width = static_cast<int>(std::round(width_d));\n\n  return {width, height};\n}\n\n// 根据屏幕尺寸和比例计算分辨率\nauto calculate_resolution_by_screen(double ratio) -> Resolution {\n  int screen_width = GetSystemMetrics(SM_CXSCREEN);\n  int screen_height = GetSystemMetrics(SM_CYSCREEN);\n  double screen_ratio = static_cast<double>(screen_width) / screen_height;\n\n  if (ratio > screen_ratio) {\n    // 宽比例，以屏幕宽度为基准\n    int width = screen_width;\n    int height = static_cast<int>(std::round(width / ratio));\n    return {width, height};\n  } else {\n    // 高比例，以屏幕高度为基准\n    int height = screen_height;\n    int width = static_cast<int>(std::round(height * ratio));\n    return {width, height};\n  }\n}\n\n// 应用窗口变换\nauto apply_window_transform(Core::State::AppState& state, HWND target_window,\n                            const Resolution& resolution, const TransformOptions& options)\n    -> std::expected<void, std::string> {\n  auto result = resize_and_center_window(state, target_window, resolution.width, resolution.height,\n                                         options.activate_window);\n  if (!result) {\n    return std::unexpected{result.error()};\n  }\n  return {};\n}\n\n// 重置窗口到屏幕尺寸\nauto reset_window_to_screen(Core::State::AppState& state, HWND target_window,\n                            const TransformOptions& options) -> std::expected<void, std::string> {\n  const int screen_width = GetSystemMetrics(SM_CXSCREEN);\n  const int screen_height = GetSystemMetrics(SM_CYSCREEN);\n  return apply_window_transform(state, target_window, Resolution{screen_width, screen_height},\n                                options);\n}\n\nauto start_center_lock_monitor(Core::State::AppState& state) -> std::expected<void, std::string> {\n  if (!state.window_control) {\n    return std::unexpected{\"Window control state is not initialized.\"};\n  }\n\n  auto& window_control = *state.window_control;\n  if (window_control.center_lock_monitor_thread.joinable()) {\n    return {};\n  }\n\n  try {\n    window_control.center_lock_monitor_thread = std::jthread([&state](std::stop_token stop_token) {\n      center_lock_monitor_thread_proc(state, stop_token);\n    });\n    Logger().info(\"Window control center lock monitor started\");\n    return {};\n  } catch (const std::exception& e) {\n    return std::unexpected{\"Failed to start center lock monitor: \" + std::string(e.what())};\n  }\n}\n\nauto stop_center_lock_monitor(Core::State::AppState& state) -> void {\n  if (!state.window_control) {\n    return;\n  }\n\n  auto& window_control = *state.window_control;\n  if (window_control.center_lock_monitor_thread.joinable()) {\n    window_control.center_lock_monitor_thread.request_stop();\n    window_control.center_lock_monitor_cv.notify_all();\n    window_control.center_lock_monitor_thread.join();\n  } else {\n    release_center_lock_if_owned(window_control);\n  }\n\n  if (auto release_result = release_layered_capture_workaround_if_owned(\n          window_control, window_control.layered_capture_workaround_hwnd);\n      !release_result) {\n    Logger().warn(\"Failed to release layered capture workaround on shutdown: {}\",\n                  release_result.error());\n  }\n\n  Logger().info(\"Window control center lock monitor stopped\");\n}\n\n}  // namespace Features::WindowControl\n"
  },
  {
    "path": "src/features/window_control/window_control.ixx",
    "content": "module;\r\n\r\nexport module Features.WindowControl;\r\n\r\nimport std;\r\nimport Core.State;\r\nimport Vendor.Windows;\r\n\r\nnamespace Features::WindowControl {\r\n\r\n// 窗口信息结构体\r\nexport struct WindowInfo {\r\n  Vendor::Windows::HWND handle = nullptr;  // 添加默认值\r\n  std::wstring title;\r\n\r\n  auto operator==(const WindowInfo& other) const noexcept -> bool {\r\n    return handle == other.handle && title == other.title;\r\n  }\r\n};\r\n\r\n// 分辨率结构体\r\nexport struct Resolution {\r\n  int width;\r\n  int height;\r\n\r\n  auto operator==(const Resolution& other) const noexcept -> bool {\r\n    return width == other.width && height == other.height;\r\n  }\r\n};\r\n\r\n// 窗口变换选项\r\nexport struct TransformOptions {\r\n  bool activate_window = true;\r\n  std::optional<Vendor::Windows::HWND> letterbox_window = std::nullopt;\r\n};\r\n\r\n// 查找目标窗口\r\nexport auto find_target_window(const std::wstring& configured_title)\r\n    -> std::expected<Vendor::Windows::HWND, std::string>;\r\n\r\n// 获取所有可见窗口的列表\r\nexport auto get_visible_windows() -> std::vector<WindowInfo>;\r\n\r\n// 切换窗口边框\r\nexport auto toggle_window_border(Vendor::Windows::HWND hwnd) -> std::expected<bool, std::string>;\r\n\r\n// 分辨率计算函数\r\nexport auto calculate_resolution(double ratio, std::uint64_t total_pixels) -> Resolution;\r\nexport auto calculate_resolution_by_screen(double ratio) -> Resolution;\r\n\r\n// 窗口变换操作\r\nexport auto apply_window_transform(Core::State::AppState& state,\r\n                                   Vendor::Windows::HWND target_window,\r\n                                   const Resolution& resolution,\r\n                                   const TransformOptions& options = {})\r\n    -> std::expected<void, std::string>;\r\n\r\nexport auto reset_window_to_screen(Core::State::AppState& state,\r\n                                   Vendor::Windows::HWND target_window,\r\n                                   const TransformOptions& options = {})\r\n    -> std::expected<void, std::string>;\r\n\r\n// 调整窗口大小并居中 (保持向后兼容)\r\nexport auto resize_and_center_window(Core::State::AppState& state, Vendor::Windows::HWND hwnd,\r\n                                     int width, int height, bool activate)\r\n    -> std::expected<void, std::string>;\r\n\r\nexport auto start_center_lock_monitor(Core::State::AppState& state)\r\n    -> std::expected<void, std::string>;\r\n\r\nexport auto stop_center_lock_monitor(Core::State::AppState& state) -> void;\r\n\r\n}  // namespace Features::WindowControl\r\n"
  },
  {
    "path": "src/locales/en-US.json",
    "content": "{\n  \"version\": \"1.0\",\n\n  \"menu.app_main\": \"Main\",\n  \"menu.app_float\": \"Floating Window\",\n  \"menu.app_exit\": \"Exit\",\n  \"menu.app_user_guide\": \"User Guide\",\n  \"menu.float_show\": \"Show Floating Window\",\n  \"menu.float_hide\": \"Hide Floating Window\",\n  \"menu.float_toggle\": \"Show/Hide Floating Window\",\n\n  \"menu.window_select\": \"Select Window\",\n  \"menu.window_no_available\": \"(No Available Windows)\",\n  \"menu.window_ratio\": \"Window Ratio\",\n  \"menu.window_resolution\": \"Resolution\",\n  \"menu.window_reset\": \"Reset\",\n  \"menu.window_toggle_borderless\": \"Toggle Window Border\",\n\n  \"menu.screenshot_capture\": \"Capture\",\n  \"menu.output_open_folder\": \"Output Folder\",\n  \"menu.external_album_open_folder\": \"Game Album\",\n  \"menu.overlay_toggle\": \"Overlay\",\n  \"menu.preview_toggle\": \"Preview\",\n  \"menu.recording_toggle\": \"Record\",\n  \"menu.motion_photo_toggle\": \"Motion Photo\",\n  \"menu.replay_buffer_toggle\": \"Instant Replay\",\n  \"menu.replay_buffer_save\": \"Save Replay\",\n  \"menu.letterbox_toggle\": \"Letterbox\",\n\n\n  \"menu.settings_config\": \"Open Config\",\n  \"menu.settings_language\": \"Language\",\n\n  \"message.app_startup\": \"Window ratio adjustment tool is running in background.\\nPress [\",\n  \"message.app_startup_suffix\": \"] to show/hide the adjustment window\",\n  \"message.app_feature_not_supported\": \"This feature requires Windows 10 1803 or higher and has been disabled.\",\n\n  \"message.window_selected\": \"Window Selected\",\n  \"message.window_adjust_success\": \"Window adjusted successfully!\",\n  \"message.window_adjust_failed\": \"Failed to adjust window. May need administrator privileges, or window doesn't support resizing.\",\n  \"message.window_not_found\": \"Target window not found. Please ensure the window is running.\",\n  \"message.window_reset_success\": \"Window has been reset to screen size.\",\n  \"message.window_reset_failed\": \"Failed to reset window size.\",\n\n  \"message.screenshot_success\": \"Screenshot saved to: \",\n  \"message.screenshot_failed\": \"Screenshot failed\",\n  \"message.preview_overlay_conflict\": \"Preview Window and Overlay Window cannot be used simultaneously, and one of the functions has been automatically disabled.\",\n  \"message.preview_start_failed\": \"Failed to start preview window: \",\n  \"message.overlay_start_failed\": \"Failed to start overlay window: \",\n  \"message.recording_started\": \"Recording started.\",\n  \"message.recording_saved\": \"Recording saved to: \",\n  \"message.recording_start_failed\": \"Failed to start recording: \",\n  \"message.recording_stop_failed\": \"Failed to stop recording: \",\n  \"message.motion_photo_success\": \"Motion Photo saved: \",\n  \"message.replay_saved\": \"Replay saved: \",\n  \"message.motion_photo_start_failed\": \"Failed to start Motion Photo: \",\n  \"message.replay_buffer_start_failed\": \"Failed to start Instant Replay: \",\n\n  \"message.settings_hotkey_prompt\": \"Please press new hotkey combination...\\nSupports Ctrl, Shift, Alt with other keys\",\n  \"message.settings_hotkey_success\": \"Hotkey set to: \",\n  \"message.settings_hotkey_failed\": \"Hotkey setting failed, restored to default.\",\n  \"message.settings_hotkey_register_failed\": \"Failed to register hotkey. The program can still be used, but the shortcut will not be available.\",\n  \"message.settings_config_help\": \"Config File Help:\\n1. [AspectRatioItems] section for custom ratios\\n2. [ResolutionItems] section for custom resolutions\\n3. Restart app after saving\",\n  \"message.settings_load_failed\": \"Failed to load config, please check the config file.\",\n  \"message.settings_format_error\": \"Format error: \",\n  \"message.settings_ratio_format_example\": \"Please use correct format, e.g.: 16:10,17:10\",\n  \"message.settings_resolution_format_example\": \"Please use correct format, e.g.: 3840x2160,7680x4320\",\n  \"message.update_available_about_prefix\": \"A new version is available. Install it from the About page: \",\n  \"message.app_updated_to_prefix\": \"Successfully updated to version \",\n\n  \"label.app_name\": \"SpinningMomo\",\n  \"label.language_zh_cn\": \"中文\",\n  \"label.language_en_us\": \"English\"\n}\n"
  },
  {
    "path": "src/locales/zh-CN.json",
    "content": "{\n  \"version\": \"1.0\",\n\n  \"menu.app_main\": \"主界面\",\n  \"menu.app_float\": \"悬浮窗\",\n  \"menu.app_exit\": \"退出\",\n  \"menu.app_user_guide\": \"使用指南\",\n  \"menu.float_show\": \"显示悬浮窗\",\n  \"menu.float_hide\": \"隐藏悬浮窗\",\n  \"menu.float_toggle\": \"显示/隐藏悬浮窗\",\n\n  \"menu.window_select\": \"选择窗口\",\n  \"menu.window_no_available\": \"(无可用窗口)\",\n  \"menu.window_ratio\": \"窗口比例\",\n  \"menu.window_resolution\": \"分辨率\",\n  \"menu.window_reset\": \"重置窗口\",\n  \"menu.window_toggle_borderless\": \"切换窗口边框\",\n\n  \"menu.screenshot_capture\": \"截图\",\n  \"menu.output_open_folder\": \"输出目录\",\n  \"menu.external_album_open_folder\": \"游戏相册\",\n  \"menu.overlay_toggle\": \"叠加层\",\n  \"menu.preview_toggle\": \"预览窗\",\n  \"menu.recording_toggle\": \"录制\",\n  \"menu.motion_photo_toggle\": \"动态照片\",\n  \"menu.replay_buffer_toggle\": \"即时回放\",\n  \"menu.replay_buffer_save\": \"保存回放\",\n  \"menu.letterbox_toggle\": \"黑边模式\",\n\n\n  \"menu.settings_config\": \"打开配置文件\",\n  \"menu.settings_language\": \"语言\",\n\n  \"message.app_startup\": \"窗口比例调整工具已在后台运行。\\n按 [\",\n  \"message.app_startup_suffix\": \"] 可以显示/隐藏调整窗口\",\n  \"message.app_feature_not_supported\": \"此功能需要 Windows 10 1803 或更高版本，已自动禁用。\",\n\n  \"message.window_selected\": \"已选择窗口\",\n  \"message.window_adjust_success\": \"窗口调整成功！\",\n  \"message.window_adjust_failed\": \"窗口调整失败。可能需要管理员权限，或窗口不支持调整大小。\",\n  \"message.window_not_found\": \"未找到目标窗口，请确保窗口已启动。\",\n  \"message.window_reset_success\": \"窗口已重置为屏幕大小。\",\n  \"message.window_reset_failed\": \"重置窗口尺寸失败。\",\n\n  \"message.screenshot_success\": \"截图成功，已保存至: \",\n  \"message.screenshot_failed\": \"截图失败\",\n  \"message.preview_overlay_conflict\": \"预览窗和叠加层功能冲突，已自动关闭另一功能\",\n  \"message.preview_start_failed\": \"预览窗启动失败: \",\n  \"message.overlay_start_failed\": \"叠加层启动失败: \",\n  \"message.recording_started\": \"录制已开始。\",\n  \"message.recording_saved\": \"录制已保存，输出文件: \",\n  \"message.recording_start_failed\": \"录制启动失败: \",\n  \"message.recording_stop_failed\": \"录制停止失败: \",\n  \"message.motion_photo_success\": \"动态照片已保存: \",\n  \"message.replay_saved\": \"回放已保存: \",\n  \"message.motion_photo_start_failed\": \"动态照片启动失败: \",\n  \"message.replay_buffer_start_failed\": \"即时回放启动失败: \",\n\n  \"message.settings_hotkey_prompt\": \"请按下新的热键组合...\\n支持 Ctrl、Shift、Alt 组合其他按键\",\n  \"message.settings_hotkey_success\": \"热键已设置为：\",\n  \"message.settings_hotkey_failed\": \"热键设置失败，已恢复默认热键。\",\n  \"message.settings_hotkey_register_failed\": \"热键注册失败。程序仍可使用，但快捷键将不可用。\",\n  \"message.settings_config_help\": \"配置文件说明：\\n1. [AspectRatioItems] 节用于添加自定义比例\\n2. [ResolutionItems] 节用于添加自定义分辨率\\n3. 保存后重启软件生效\",\n  \"message.settings_load_failed\": \"加载配置失败，请检查配置文件。\",\n  \"message.settings_format_error\": \"格式错误：\",\n  \"message.settings_ratio_format_example\": \"请使用正确格式，如：16:10,17:10\",\n  \"message.settings_resolution_format_example\": \"请使用正确格式，如：3840x2160,7680x4320\",\n  \"message.update_available_about_prefix\": \"发现新版本，可前往“关于”页面安装：\",\n  \"message.app_updated_to_prefix\": \"已成功更新到版本 \",\n\n  \"label.app_name\": \"旋转吧大喵\",\n  \"label.language_zh_cn\": \"中文\",\n  \"label.language_en_us\": \"English\"\n}\n"
  },
  {
    "path": "src/main.cpp",
    "content": "import std;\nimport App;\nimport Utils.CrashDump;\nimport Utils.Logger;\nimport Utils.System;\nimport Features.Settings;\nimport Vendor.Windows;\n\n// Win32 入口\nauto __stdcall wWinMain(Vendor::Windows::HINSTANCE hInstance,\n                        [[maybe_unused]] Vendor::Windows::HINSTANCE hPrevInstance,\n                        Vendor::Windows::LPWSTR lpCmdLine, [[maybe_unused]] int nCmdShow) -> int {\n  // 尽早安装崩溃转储处理器\n  Utils::CrashDump::install();\n\n  const auto startup_settings = Features::Settings::load_startup_settings();\n\n  // 尽早初始化日志，覆盖单实例、提权与启动早期故障\n  if (auto result = Utils::Logging::initialize(startup_settings.logger_level); !result) {\n    const auto error_message = \"Logger Failed: \" + result.error();\n    Vendor::Windows::MessageBoxA(nullptr, error_message.c_str(), \"Fatal Error\",\n                                 Vendor::Windows::kMB_ICONERROR);\n    return -1;\n  }\n\n  // 单实例检查（需早于提权流程）\n  if (!Utils::System::acquire_single_instance_lock()) {\n    Logger().info(\"Existing instance detected, activating the running instance\");\n    // 已有实例时激活并退出\n    Utils::System::activate_existing_instance();\n    Utils::Logging::shutdown();\n    return 0;\n  }\n\n  // 需要管理员权限时尝试提权重启\n  if (startup_settings.always_run_as_admin && !Utils::System::is_process_elevated()) {\n    Logger().info(\"Elevation required by settings, attempting restart as elevated\");\n    // 提权前先释放单实例锁，避免提权后的新进程误判为“已有实例”\n    Utils::System::release_single_instance_lock();\n\n    if (Utils::System::restart_as_elevated(lpCmdLine)) {\n      Logger().info(\"Elevated process started successfully, current process exits\");\n      Utils::Logging::shutdown();\n      // 提权进程已启动，当前进程退出\n      return 0;\n    }\n\n    Logger().warn(\"Elevation was cancelled or failed, continuing without admin privileges\");\n\n    // 取消 UAC 或启动失败：重新获取单实例锁后继续普通权限\n    if (!Utils::System::acquire_single_instance_lock()) {\n      Logger().info(\"Existing instance detected after elevation fallback, activating it\");\n      Utils::System::activate_existing_instance();\n      Utils::Logging::shutdown();\n      return 0;\n    }\n  }\n\n  int exit_code = 0;\n  // 主流程\n  try {\n    Application app;\n\n    if (!app.Initialize(hInstance)) {\n      Logger().critical(\"Failed to initialize application\");\n      Vendor::Windows::MessageBoxW(nullptr, L\"Failed to initialize application\", L\"Error\",\n                                   Vendor::Windows::kMB_ICONERROR);\n      exit_code = -1;\n    } else {\n      exit_code = app.Run();\n    }\n\n  } catch (const std::exception& e) {\n    Logger().critical(\"Unhandled exception: {}\", e.what());\n    Vendor::Windows::MessageBoxA(nullptr, e.what(), \"Fatal Error\", Vendor::Windows::kMB_ICONERROR);\n    exit_code = -1;\n  }\n\n  // 退出前关闭日志\n  Utils::Logging::shutdown();\n  return exit_code;\n}\n"
  },
  {
    "path": "src/migrations/001_initial_schema.sql",
    "content": "-- Initialize database schema\n-- This migration creates the initial database schema for SpinningMomo\n-- Tables: assets, folders, tags, asset_tags, ignore_rules\n-- ============================================================================\n-- Assets Table\n-- ============================================================================\nCREATE TABLE assets (\n    id INTEGER PRIMARY KEY AUTOINCREMENT,\n    name TEXT NOT NULL,\n    path TEXT NOT NULL UNIQUE,\n    type TEXT NOT NULL CHECK (\n        type IN ('photo', 'video', 'live_photo', 'unknown')\n    ),\n    description TEXT,\n    width INTEGER,\n    height INTEGER,\n    size INTEGER,\n    extension TEXT,\n    mime_type TEXT,\n    hash TEXT,\n    rating INTEGER NOT NULL DEFAULT 0 CHECK (\n        rating BETWEEN 0\n        AND 5\n    ),\n    review_flag TEXT NOT NULL DEFAULT 'none' CHECK (\n        review_flag IN ('none', 'picked', 'rejected')\n    ),\n    folder_id INTEGER REFERENCES folders(id) ON DELETE\n    SET\n        NULL,\n        file_created_at INTEGER,\n        file_modified_at INTEGER,\n        created_at INTEGER DEFAULT (unixepoch('subsec') * 1000),\n        updated_at INTEGER DEFAULT (unixepoch('subsec') * 1000)\n);\n\n-- ============================================================================\n-- Assets Indexes\n-- ============================================================================\nCREATE INDEX idx_assets_path ON assets(path);\n\nCREATE INDEX idx_assets_type ON assets(type);\n\nCREATE INDEX idx_assets_extension ON assets(extension);\n\nCREATE INDEX idx_assets_created_at ON assets(created_at);\n\nCREATE INDEX idx_assets_hash ON assets(hash);\n\nCREATE INDEX idx_assets_rating ON assets(rating);\n\nCREATE INDEX idx_assets_review_flag ON assets(review_flag);\n\nCREATE INDEX idx_assets_folder_id ON assets(folder_id);\n\nCREATE INDEX idx_assets_file_created_at ON assets(file_created_at);\n\nCREATE INDEX idx_assets_file_modified_at ON assets(file_modified_at);\n\nCREATE INDEX idx_assets_folder_time ON assets(folder_id, file_created_at);\n\n-- ============================================================================\n-- Assets Triggers\n-- ============================================================================\nCREATE TRIGGER update_assets_updated_at\nAFTER\nUPDATE\n    ON assets FOR EACH ROW BEGIN\nUPDATE\n    assets\nSET\n    updated_at = (unixepoch('subsec') * 1000)\nWHERE\n    id = NEW.id;\n\nEND;\n\n-- ============================================================================\n-- Folders Table\n-- ============================================================================\nCREATE TABLE folders (\n    id INTEGER PRIMARY KEY AUTOINCREMENT,\n    path TEXT NOT NULL UNIQUE,\n    parent_id INTEGER REFERENCES folders(id) ON DELETE CASCADE,\n    name TEXT NOT NULL,\n    display_name TEXT,\n    cover_asset_id INTEGER,\n    sort_order INTEGER DEFAULT 0,\n    is_hidden BOOLEAN DEFAULT 0,\n    created_at INTEGER DEFAULT (unixepoch('subsec') * 1000),\n    updated_at INTEGER DEFAULT (unixepoch('subsec') * 1000),\n    FOREIGN KEY (cover_asset_id) REFERENCES assets(id) ON DELETE\n    SET\n        NULL\n);\n\n-- ============================================================================\n-- Folders Indexes\n-- ============================================================================\nCREATE INDEX idx_folders_parent_sort ON folders(parent_id, sort_order);\n\nCREATE INDEX idx_folders_path ON folders(path);\n\n-- ============================================================================\n-- Folders Triggers\n-- ============================================================================\nCREATE TRIGGER update_folders_updated_at\nAFTER\nUPDATE\n    ON folders FOR EACH ROW BEGIN\nUPDATE\n    folders\nSET\n    updated_at = (unixepoch('subsec') * 1000)\nWHERE\n    id = NEW.id;\n\nEND;\n\n-- ============================================================================\n-- Tags Table\n-- ============================================================================\nCREATE TABLE tags (\n    id INTEGER PRIMARY KEY AUTOINCREMENT,\n    name TEXT NOT NULL,\n    parent_id INTEGER,\n    sort_order INTEGER DEFAULT 0,\n    created_at INTEGER DEFAULT (unixepoch('subsec') * 1000),\n    updated_at INTEGER DEFAULT (unixepoch('subsec') * 1000),\n    FOREIGN KEY (parent_id) REFERENCES tags(id) ON DELETE CASCADE\n);\n\n-- ============================================================================\n-- Asset Tags Junction Table\n-- ============================================================================\nCREATE TABLE asset_tags (\n    asset_id INTEGER NOT NULL,\n    tag_id INTEGER NOT NULL,\n    created_at INTEGER DEFAULT (unixepoch('subsec') * 1000),\n    PRIMARY KEY (asset_id, tag_id),\n    FOREIGN KEY (asset_id) REFERENCES assets(id) ON DELETE CASCADE,\n    FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE\n);\n\n-- ============================================================================\n-- Tags Indexes\n-- ============================================================================\nCREATE INDEX idx_tags_parent_sort ON tags(parent_id, sort_order);\n\n-- ============================================================================\n-- Tags Triggers\n-- ============================================================================\nCREATE TRIGGER update_tags_updated_at\nAFTER\nUPDATE\n    ON tags FOR EACH ROW BEGIN\nUPDATE\n    tags\nSET\n    updated_at = (unixepoch('subsec') * 1000)\nWHERE\n    id = NEW.id;\n\nEND;\n\n-- ============================================================================\n-- Asset Tags Indexes\n-- ============================================================================\nCREATE INDEX idx_asset_tags_tag ON asset_tags(tag_id);\n\n-- ============================================================================\n-- Asset Colors Table\n-- ============================================================================\nCREATE TABLE asset_colors (\n    id INTEGER PRIMARY KEY AUTOINCREMENT,\n    asset_id INTEGER NOT NULL REFERENCES assets(id) ON DELETE CASCADE,\n    r INTEGER NOT NULL CHECK (\n        r BETWEEN 0\n        AND 255\n    ),\n    g INTEGER NOT NULL CHECK (\n        g BETWEEN 0\n        AND 255\n    ),\n    b INTEGER NOT NULL CHECK (\n        b BETWEEN 0\n        AND 255\n    ),\n    lab_l REAL NOT NULL,\n    lab_a REAL NOT NULL,\n    lab_b REAL NOT NULL,\n    weight REAL NOT NULL CHECK (\n        weight > 0\n        AND weight <= 1\n    ),\n    l_bin INTEGER NOT NULL,\n    a_bin INTEGER NOT NULL,\n    b_bin INTEGER NOT NULL\n);\n\n-- ============================================================================\n-- Asset Colors Indexes\n-- ============================================================================\nCREATE INDEX idx_asset_colors_asset_id ON asset_colors(asset_id);\n\nCREATE INDEX idx_asset_colors_asset_weight ON asset_colors(asset_id, weight DESC);\n\nCREATE INDEX idx_asset_colors_lab_bin ON asset_colors(l_bin, a_bin, b_bin);\n\n-- ============================================================================\n-- Infinity Nikki Photo Params Table\n-- ============================================================================\nCREATE TABLE asset_infinity_nikki_params (\n    asset_id INTEGER PRIMARY KEY REFERENCES assets(id) ON DELETE CASCADE,\n    uid TEXT NOT NULL,\n    camera_params TEXT,\n    time_day INTEGER,\n    time_hour INTEGER,\n    time_min INTEGER,\n    time_sec REAL,\n    camera_focal_length REAL,\n    aperture_section INTEGER,\n    filter_id TEXT,\n    filter_strength REAL,\n    vignette_intensity REAL,\n    light_id TEXT,\n    light_strength REAL,\n    nikki_loc_x REAL,\n    nikki_loc_y REAL,\n    nikki_loc_z REAL,\n    nikki_hidden INTEGER,\n    pose_id INTEGER,\n    nikki_diy_json TEXT\n);\n\n-- ============================================================================\n-- Infinity Nikki Photo Params Indexes\n-- ============================================================================\nCREATE INDEX idx_infinity_nikki_params_uid ON asset_infinity_nikki_params(uid);\n\nCREATE INDEX idx_infinity_nikki_params_pose_id ON asset_infinity_nikki_params(pose_id);\n\n-- ============================================================================\n-- Infinity Nikki User Record Table\n-- ============================================================================\nCREATE TABLE asset_infinity_nikki_user_record (\n    asset_id INTEGER PRIMARY KEY REFERENCES assets(id) ON DELETE CASCADE,\n    code_type TEXT NOT NULL CHECK (\n        code_type IN ('dye', 'home_building')\n    ),\n    code_value TEXT NOT NULL,\n    created_at INTEGER DEFAULT (unixepoch('subsec') * 1000),\n    updated_at INTEGER DEFAULT (unixepoch('subsec') * 1000)\n);\n\n-- ============================================================================\n-- Infinity Nikki User Record Triggers\n-- ============================================================================\nCREATE TRIGGER update_asset_infinity_nikki_user_record_updated_at\nAFTER\nUPDATE\n    ON asset_infinity_nikki_user_record FOR EACH ROW BEGIN\nUPDATE\n    asset_infinity_nikki_user_record\nSET\n    updated_at = (unixepoch('subsec') * 1000)\nWHERE\n    asset_id = NEW.asset_id;\n\nEND;\n\n-- ============================================================================\n-- Infinity Nikki Clothes Table\n-- ============================================================================\nCREATE TABLE asset_infinity_nikki_clothes (\n    asset_id INTEGER NOT NULL REFERENCES assets(id) ON DELETE CASCADE,\n    cloth_id INTEGER NOT NULL,\n    PRIMARY KEY (asset_id, cloth_id)\n);\n\n-- ============================================================================\n-- Infinity Nikki Clothes Indexes\n-- ============================================================================\nCREATE INDEX idx_infinity_nikki_clothes_cloth_id ON asset_infinity_nikki_clothes(cloth_id);\n\n-- ============================================================================\n-- Ignore Rules Table\n-- ============================================================================\nCREATE TABLE ignore_rules (\n    id INTEGER PRIMARY KEY AUTOINCREMENT,\n    -- 如果为 NULL，表示这是一个全局规则\n    -- 如果有值，它将关联到 folders 表中的一个根目录 (即 parent_id IS NULL 的记录)\n    folder_id INTEGER REFERENCES folders(id) ON DELETE CASCADE,\n    -- 规则模式，可以是 glob 或正则表达式\n    rule_pattern TEXT NOT NULL,\n    -- 规则的类型，'glob' 类似于 .gitignore, 'regex' 则是标准的正则表达式\n    pattern_type TEXT NOT NULL CHECK (pattern_type IN ('glob', 'regex')) DEFAULT 'glob',\n    rule_type TEXT NOT NULL CHECK (rule_type IN ('exclude', 'include')) DEFAULT 'exclude',\n    is_enabled BOOLEAN NOT NULL DEFAULT 1,\n    description TEXT,\n    created_at INTEGER DEFAULT (unixepoch('subsec') * 1000),\n    updated_at INTEGER DEFAULT (unixepoch('subsec') * 1000),\n    UNIQUE(folder_id, rule_pattern)\n);\n\n-- ============================================================================\n-- Ignore Rules Indexes\n-- ============================================================================\nCREATE INDEX idx_ignore_rules_folder_id ON ignore_rules(folder_id);\n\nCREATE INDEX idx_ignore_rules_enabled ON ignore_rules(is_enabled);\n\nCREATE INDEX idx_ignore_rules_pattern_type ON ignore_rules(pattern_type);\n\nCREATE UNIQUE INDEX idx_ignore_rules_global_pattern_unique ON ignore_rules(rule_pattern)\nWHERE folder_id IS NULL;\n\n-- ============================================================================\n-- Ignore Rules Triggers\n-- ============================================================================\nCREATE TRIGGER update_ignore_rules_updated_at\nAFTER\nUPDATE\n    ON ignore_rules FOR EACH ROW BEGIN\nUPDATE\n    ignore_rules\nSET\n    updated_at = (unixepoch('subsec') * 1000)\nWHERE\n    id = NEW.id;\n\nEND;\n"
  },
  {
    "path": "src/migrations/002_watch_root_recovery_state.sql",
    "content": "-- ============================================================================\n-- Watch Root Recovery State Table\n-- ============================================================================\nCREATE TABLE watch_root_recovery_state (\n    root_path TEXT PRIMARY KEY,\n    volume_identity TEXT NOT NULL,\n    journal_id INTEGER,\n    checkpoint_usn INTEGER,\n    rule_fingerprint TEXT NOT NULL,\n    updated_at INTEGER DEFAULT (unixepoch('subsec') * 1000)\n);\n"
  },
  {
    "path": "src/migrations/003_infinity_nikki_params_nuan5_columns.sql",
    "content": "-- ============================================================================\n-- Infinity Nikki Params: nuan5 schema replacement\n-- ============================================================================\nDROP TABLE IF EXISTS asset_infinity_nikki_params_v2;\n\nDROP INDEX IF EXISTS idx_infinity_nikki_params_uid;\n\nDROP INDEX IF EXISTS idx_infinity_nikki_params_pose_id;\n\nCREATE TABLE asset_infinity_nikki_params_v2 (\n    asset_id INTEGER PRIMARY KEY REFERENCES assets(id) ON DELETE CASCADE,\n    uid TEXT NOT NULL,\n    camera_params TEXT,\n    time_hour INTEGER,\n    time_min INTEGER,\n    camera_focal_length REAL,\n    rotation REAL,\n    aperture_value REAL,\n    filter_id INTEGER,\n    filter_strength REAL,\n    vignette_intensity REAL,\n    light_id INTEGER,\n    light_strength REAL,\n    vertical INTEGER,\n    bloom_intensity REAL,\n    bloom_threshold REAL,\n    brightness REAL,\n    exposure REAL,\n    contrast REAL,\n    saturation REAL,\n    vibrance REAL,\n    highlights REAL,\n    shadow REAL,\n    nikki_loc_x REAL,\n    nikki_loc_y REAL,\n    nikki_loc_z REAL,\n    nikki_hidden INTEGER,\n    pose_id INTEGER,\n    nikki_diy_json TEXT\n);\n\nINSERT INTO asset_infinity_nikki_params_v2 (\n    asset_id, uid, camera_params,\n    time_hour, time_min,\n    camera_focal_length, rotation, aperture_value,\n    filter_id, filter_strength, vignette_intensity,\n    light_id, light_strength,\n    nikki_loc_x, nikki_loc_y, nikki_loc_z,\n    nikki_hidden, pose_id,\n    nikki_diy_json\n)\nSELECT\n    asset_id,\n    uid,\n    camera_params,\n    time_hour,\n    time_min,\n    camera_focal_length,\n    NULL AS rotation,\n    NULL AS aperture_value,\n    CASE\n        WHEN filter_id IS NULL THEN NULL\n        WHEN trim(filter_id) <> '' AND (trim(filter_id) GLOB '[0-9]*' OR trim(filter_id) GLOB '-[0-9]*')\n            THEN CAST(trim(filter_id) AS INTEGER)\n        ELSE NULL\n    END AS filter_id,\n    filter_strength,\n    vignette_intensity,\n    CASE\n        WHEN light_id IS NULL THEN NULL\n        WHEN trim(light_id) <> '' AND (trim(light_id) GLOB '[0-9]*' OR trim(light_id) GLOB '-[0-9]*')\n            THEN CAST(trim(light_id) AS INTEGER)\n        ELSE NULL\n    END AS light_id,\n    light_strength,\n    nikki_loc_x,\n    nikki_loc_y,\n    nikki_loc_z,\n    nikki_hidden,\n    pose_id,\n    NULL AS nikki_diy_json\nFROM asset_infinity_nikki_params;\n\nDROP TABLE asset_infinity_nikki_params;\n\nALTER TABLE asset_infinity_nikki_params_v2\nRENAME TO asset_infinity_nikki_params;\n\nCREATE INDEX IF NOT EXISTS idx_infinity_nikki_params_uid ON asset_infinity_nikki_params(uid);\n\n"
  },
  {
    "path": "src/ui/context_menu/context_menu.cpp",
    "content": "module;\n\nmodule UI.ContextMenu;\n\nimport std;\nimport Core.State;\nimport Core.I18n.State;\nimport Core.I18n.Types;\nimport Core.Events;\nimport Features.Settings.Menu;\nimport Core.Commands;\nimport Core.Commands.State;\nimport UI.FloatingWindow.Types;\nimport UI.FloatingWindow.State;\nimport UI.FloatingWindow.Events;\nimport UI.ContextMenu.State;\nimport UI.ContextMenu.Types;\nimport UI.ContextMenu.Layout;\nimport UI.ContextMenu.MessageHandler;\nimport UI.ContextMenu.Interaction;\nimport UI.ContextMenu.Painter;\nimport UI.ContextMenu.D2DContext;\nimport Utils.Logger;\nimport Utils.String;\nimport Vendor.Windows;\nimport Features.WindowControl;\nimport <d2d1.h>;\nimport <dwmapi.h>;\nimport <dwrite.h>;\nimport <windows.h>;\nimport <wrl/client.h>;\n\nnamespace UI::ContextMenu {\n\nauto apply_corner_preference(HWND hwnd) -> void {\n  DWM_WINDOW_CORNER_PREFERENCE corner = DWMWCP_ROUNDSMALL;\n  DwmSetWindowAttribute(hwnd, DWMWA_WINDOW_CORNER_PREFERENCE, &corner, sizeof(corner));\n}\n\nauto register_context_menu_class(HINSTANCE instance, WNDPROC wnd_proc) -> bool {\n  WNDCLASSEXW wc{};\n  wc.cbSize = sizeof(WNDCLASSEXW);\n  wc.style = CS_HREDRAW | CS_VREDRAW;\n  wc.lpfnWndProc = wnd_proc;\n  wc.cbClsExtra = 0;\n  wc.cbWndExtra = 0;\n  wc.hInstance = instance;\n  wc.hIcon = nullptr;\n  wc.hCursor = LoadCursor(nullptr, IDC_ARROW);\n  wc.hbrBackground = nullptr;\n  wc.lpszMenuName = nullptr;\n  wc.lpszClassName = L\"SpinningMomoContextMenuClass\";\n  wc.hIconSm = nullptr;\n\n  if (!RegisterClassExW(&wc)) {\n    return GetLastError() == ERROR_CLASS_ALREADY_EXISTS;\n  }\n  return true;\n}\n\nauto create_context_menu_window(HINSTANCE instance, Core::State::AppState* app_state, HWND owner,\n                                const POINT& position, const SIZE& size) -> HWND {\n  HWND hwnd = CreateWindowExW(\n      WS_EX_TOPMOST | WS_EX_TOOLWINDOW | WS_EX_LAYERED, L\"SpinningMomoContextMenuClass\",\n      L\"ContextMenu\",  // 窗口标题不重要\n      WS_POPUP, position.x, position.y, size.cx, size.cy, owner, nullptr, instance,\n      app_state  // 将AppState指针作为创建参数传递\n  );\n\n  if (hwnd) {\n    apply_corner_preference(hwnd);\n  }\n\n  return hwnd;\n}\n\n// 隐藏并销毁菜单窗口\nvoid hide_and_destroy_menu(Core::State::AppState& state) {\n  // 先销毁子菜单\n  if (state.context_menu->submenu_hwnd) {\n    DestroyWindow(state.context_menu->submenu_hwnd);\n    state.context_menu->submenu_hwnd = nullptr;\n    // 确保清理子菜单D2D资源\n    D2DContext::cleanup_submenu(state);\n  }\n\n  // 再销毁主菜单\n  if (state.context_menu->hwnd) {\n    DestroyWindow(state.context_menu->hwnd);\n    state.context_menu->hwnd = nullptr;\n    // 确保清理主菜单D2D资源\n    D2DContext::cleanup_context_menu(state);\n  }\n\n  // 重置交互状态，避免旧菜单残留的hover/定时意图影响下一次显示。\n  UI::ContextMenu::Interaction::reset(state);\n  state.context_menu->submenu_parent_index = -1;\n}\n\n// 处理菜单命令\nvoid handle_menu_action(Core::State::AppState& state,\n                        const UI::ContextMenu::Types::MenuItem& item) {\n  if (!item.has_action()) {\n    Logger().warn(\"Menu item '{}' has no associated action\", Utils::String::ToUtf8(item.text));\n    return;\n  }\n  const auto& action = item.action.value();\n\n  // 根据动作类型发送相应的事件\n  switch (action.type) {\n    case UI::ContextMenu::Types::MenuAction::Type::WindowSelection: {\n      try {\n        auto window_info = std::any_cast<Features::WindowControl::WindowInfo>(action.data);\n        // 使用新的事件系统发送窗口选择事件\n        Core::Events::send(*state.events, UI::FloatingWindow::Events::WindowSelectionEvent{\n                                              window_info.title, window_info.handle});\n        Logger().info(\"Window selected: {}\", Utils::String::ToUtf8(window_info.title));\n      } catch (const std::bad_any_cast& e) {\n        Logger().error(\"Failed to cast window selection data: {}\", e.what());\n      }\n      break;\n    }\n\n    case UI::ContextMenu::Types::MenuAction::Type::RatioSelection: {\n      try {\n        auto ratio_data = std::any_cast<UI::ContextMenu::Types::RatioData>(action.data);\n        // 使用新的事件系统发送比例改变事件\n        Core::Events::send(*state.events, UI::FloatingWindow::Events::RatioChangeEvent{\n                                              ratio_data.index, ratio_data.name, ratio_data.ratio});\n        Logger().info(\"Ratio selected: {} ({})\", Utils::String::ToUtf8(ratio_data.name),\n                      ratio_data.ratio);\n      } catch (const std::bad_any_cast& e) {\n        Logger().error(\"Failed to cast ratio selection data: {}\", e.what());\n      }\n      break;\n    }\n\n    case UI::ContextMenu::Types::MenuAction::Type::ResolutionSelection: {\n      try {\n        auto resolution_data = std::any_cast<UI::ContextMenu::Types::ResolutionData>(action.data);\n        // 使用新的事件系统发送分辨率改变事件\n        Core::Events::send(*state.events, UI::FloatingWindow::Events::ResolutionChangeEvent{\n                                              resolution_data.index, resolution_data.name,\n                                              resolution_data.total_pixels});\n        Logger().info(\"Resolution selected: {} ({}M pixels)\",\n                      Utils::String::ToUtf8(resolution_data.name),\n                      resolution_data.total_pixels / 1000000.0);\n      } catch (const std::bad_any_cast& e) {\n        Logger().error(\"Failed to cast resolution selection data: {}\", e.what());\n      }\n      break;\n    }\n\n    case UI::ContextMenu::Types::MenuAction::Type::FeatureToggle:\n    case UI::ContextMenu::Types::MenuAction::Type::SystemCommand: {\n      try {\n        auto action_id = std::any_cast<std::string>(action.data);\n\n        // 通过注册表调用命令\n        if (state.commands) {\n          Core::Commands::invoke_command(state.commands->registry, action_id);\n        }\n\n        Logger().info(\"Feature action triggered: {}\", action_id);\n      } catch (const std::bad_any_cast& e) {\n        Logger().error(\"Failed to cast action data: {}\", e.what());\n      }\n      break;\n    }\n\n    default:\n      Logger().warn(\"Unknown menu action type: {}\", static_cast<int>(action.type));\n      break;\n  }\n}\n\n// 隐藏子菜单\nauto hide_submenu(Core::State::AppState& state) -> void {\n  if (state.context_menu->submenu_hwnd) {\n    DestroyWindow(state.context_menu->submenu_hwnd);\n    D2DContext::cleanup_submenu(state);\n    state.context_menu->submenu_hwnd = nullptr;\n    state.context_menu->submenu_parent_index = -1;\n    state.context_menu->interaction.submenu_hover_index = -1;\n  }\n}\n\n// 显示子菜单\nauto show_submenu(Core::State::AppState& state, int index) -> void {\n  auto& menu_state = *state.context_menu;\n  Logger().debug(\"show_submenu called with index: {}\", index);\n\n  // 先隐藏现有的子菜单\n  hide_submenu(state);\n\n  // 检查索引是否有效\n  if (index < 0 || index >= static_cast<int>(menu_state.items.size())) {\n    return;\n  }\n\n  const auto& item = menu_state.items[index];\n  Logger().debug(\"Item at index {}: text='{}', has_submenu={}\", index,\n                 Utils::String::ToUtf8(item.text), item.has_submenu());\n\n  if (!item.has_submenu()) {\n    return;\n  }\n\n  // 设置父索引，这样get_current_submenu()才能正确返回子菜单项\n  menu_state.submenu_parent_index = index;\n\n  // 计算子菜单尺寸和位置\n  Layout::calculate_submenu_size(state);\n  Layout::calculate_submenu_position(state, index);\n\n  // 创建子菜单窗口\n  HINSTANCE instance = state.floating_window->window.instance;\n  menu_state.submenu_hwnd = create_context_menu_window(\n      instance, &state, menu_state.hwnd, menu_state.submenu_position, menu_state.submenu_size);\n\n  if (!menu_state.submenu_hwnd) {\n    Logger().error(\"Failed to create submenu window. Error: {}\", GetLastError());\n    menu_state.submenu_parent_index = -1;  // 重置父索引\n    return;\n  }\n\n  Logger().debug(\"Created submenu window: {}\", (void*)menu_state.submenu_hwnd);\n\n  // 初始化D2D资源\n  if (!UI::ContextMenu::D2DContext::initialize_submenu(state, menu_state.submenu_hwnd)) {\n    Logger().error(\"Failed to initialize D2D for submenu.\");\n    DestroyWindow(menu_state.submenu_hwnd);\n    menu_state.submenu_hwnd = nullptr;\n    menu_state.submenu_parent_index = -1;  // 重置父索引\n    return;\n  }\n\n  RECT client_rect{0, 0, menu_state.submenu_size.cx, menu_state.submenu_size.cy};\n  UI::ContextMenu::Painter::paint_submenu(state, client_rect);\n\n  // 显示窗口\n  ShowWindow(menu_state.submenu_hwnd, SW_SHOW);\n  SetForegroundWindow(menu_state.submenu_hwnd);\n  SetFocus(menu_state.submenu_hwnd);\n\n  Logger().debug(\"Submenu window shown successfully\");\n}\n\nauto initialize(Core::State::AppState& app_state) -> std::expected<void, std::string> {\n  try {\n    // 初始化上下文菜单状态\n    if (!app_state.context_menu) {\n      return std::unexpected(\"Context menu state is not allocated\");\n    }\n\n    // 注册窗口类\n    if (!register_context_menu_class(app_state.floating_window->window.instance,\n                                     MessageHandler::static_window_proc)) {\n      return std::unexpected(\"Failed to register context menu window class\");\n    }\n\n    return {};\n  } catch (const std::exception& e) {\n    return std::unexpected(std::string(\"Exception during context menu initialization: \") +\n                           e.what());\n  }\n}\n\nauto cleanup(Core::State::AppState& app_state) -> void {\n  // 清理上下文菜单资源\n  if (app_state.context_menu) {\n    // 销毁任何可能存在的窗口\n    if (app_state.context_menu->hwnd) {\n      DestroyWindow(app_state.context_menu->hwnd);\n      app_state.context_menu->hwnd = nullptr;\n    }\n\n    if (app_state.context_menu->submenu_hwnd) {\n      DestroyWindow(app_state.context_menu->submenu_hwnd);\n      app_state.context_menu->submenu_hwnd = nullptr;\n    }\n\n    // 清理D2D资源\n    D2DContext::cleanup_submenu(app_state);\n    D2DContext::cleanup_context_menu(app_state);\n    UI::ContextMenu::Interaction::reset(app_state);\n    app_state.context_menu->submenu_parent_index = -1;\n  }\n}\n\nauto Show(Core::State::AppState& app_state, std::vector<Types::MenuItem> items,\n          const Vendor::Windows::POINT& position) -> void {\n  // 若已有菜单实例，先回收，确保状态机从干净状态重新开始。\n  if (app_state.context_menu->hwnd || app_state.context_menu->submenu_hwnd) {\n    hide_and_destroy_menu(app_state);\n  }\n\n  // 1. 更新菜单状态\n  auto& menu_state = *app_state.context_menu;\n  UI::ContextMenu::Interaction::reset(app_state);\n  menu_state.submenu_parent_index = -1;\n  menu_state.items = std::move(items);\n  menu_state.position = position;\n\n  // 检查是否有菜单项\n  if (menu_state.items.empty()) {\n    Logger().warn(\"ContextMenu::Show called with no items.\");\n    return;\n  }\n\n  // 2. 创建窗口\n  // 2. 应用 DPI 缩放\n  UINT dpi = app_state.floating_window->window.dpi;\n  menu_state.layout.update_dpi_scaling(dpi);\n\n  if (!D2DContext::initialize_text_format(app_state)) {\n    Logger().error(\"Failed to initialize text format for context menu.\");\n    return;\n  }\n\n  // 3. 计算布局和最终位置\n  Layout::calculate_menu_size(app_state);\n  menu_state.position = Layout::calculate_menu_position(app_state, position);\n\n  // 4. 创建窗口（直接使用最终位置和尺寸）\n  HINSTANCE instance = app_state.floating_window->window.instance;\n  menu_state.hwnd = create_context_menu_window(instance, &app_state, nullptr, menu_state.position,\n                                               menu_state.menu_size);\n\n  if (!menu_state.hwnd) {\n    Logger().error(\"Failed to create context menu window.\");\n    return;\n  }\n\n  // 5. 初始化D2D资源\n  if (!D2DContext::initialize_context_menu(app_state, menu_state.hwnd)) {\n    Logger().error(\"Failed to initialize D2D for context menu.\");\n    DestroyWindow(menu_state.hwnd);\n    menu_state.hwnd = nullptr;\n    return;\n  }\n\n  RECT client_rect{0, 0, menu_state.menu_size.cx, menu_state.menu_size.cy};\n  Painter::paint_context_menu(app_state, client_rect);\n\n  // 6. 显示窗口并设置为前景\n  ShowWindow(menu_state.hwnd, SW_SHOWNA);\n  SetForegroundWindow(menu_state.hwnd);\n}\n\n}  // namespace UI::ContextMenu\n"
  },
  {
    "path": "src/ui/context_menu/context_menu.ixx",
    "content": "module;\n\nexport module UI.ContextMenu;\n\nimport std;\nimport Core.State;\nimport UI.ContextMenu.Types;\nimport Vendor.Windows;\n\nnamespace UI::ContextMenu {\n\nexport auto initialize(Core::State::AppState& state) -> std::expected<void, std::string>;\n\nexport auto cleanup(Core::State::AppState& state) -> void;\n\nexport auto Show(Core::State::AppState& state, std::vector<Types::MenuItem> items,\n                 const Vendor::Windows::POINT& position) -> void;\n\nexport auto hide_and_destroy_menu(Core::State::AppState& state) -> void;\n\nexport auto hide_submenu(Core::State::AppState& state) -> void;\n\nexport auto show_submenu(Core::State::AppState& state, int index) -> void;\n\nexport auto handle_menu_action(Core::State::AppState& state,\n                               const UI::ContextMenu::Types::MenuItem& item) -> void;\n\n}  // namespace UI::ContextMenu\n"
  },
  {
    "path": "src/ui/context_menu/d2d_context.cpp",
    "content": "module;\n\nmodule UI.ContextMenu.D2DContext;\n\nimport Core.State;\nimport UI.FloatingWindow.State;\nimport UI.ContextMenu.State;\nimport Utils.Logger;\nimport Features.Settings.State;\nimport <d2d1_3.h>;\nimport <dwrite_3.h>;\nimport <windows.h>;\n\nnamespace {\n\nusing UI::ContextMenu::State::RenderSurface;\n\nauto hex_with_alpha_to_color_f(const std::string& hex_color) -> D2D1_COLOR_F {\n  std::string color_str = hex_color;\n  if (color_str.starts_with(\"#\")) {\n    color_str = color_str.substr(1);\n  }\n\n  float r = 0.0f;\n  float g = 0.0f;\n  float b = 0.0f;\n  float a = 1.0f;\n\n  if (color_str.length() == 8) {\n    r = std::stoi(color_str.substr(0, 2), nullptr, 16) / 255.0f;\n    g = std::stoi(color_str.substr(2, 2), nullptr, 16) / 255.0f;\n    b = std::stoi(color_str.substr(4, 2), nullptr, 16) / 255.0f;\n    a = std::stoi(color_str.substr(6, 2), nullptr, 16) / 255.0f;\n  } else if (color_str.length() >= 6) {\n    r = std::stoi(color_str.substr(0, 2), nullptr, 16) / 255.0f;\n    g = std::stoi(color_str.substr(2, 2), nullptr, 16) / 255.0f;\n    b = std::stoi(color_str.substr(4, 2), nullptr, 16) / 255.0f;\n  }\n\n  return D2D1::ColorF(r, g, b, a);\n}\n\nauto force_opaque_hex_color(std::string hex_color) -> std::string {\n  if (hex_color.empty()) {\n    return hex_color;\n  }\n\n  const bool has_hash = hex_color.starts_with(\"#\");\n  std::string color = has_hash ? hex_color.substr(1) : hex_color;\n\n  if (color.length() >= 8) {\n    color = color.substr(0, 6) + \"FF\";\n  } else if (color.length() == 6) {\n    color += \"FF\";\n  }\n\n  return has_hash ? \"#\" + color : color;\n}\n\nauto create_brush_from_hex(ID2D1RenderTarget* target, const std::string& hex_color,\n                           ID2D1SolidColorBrush** brush) -> bool {\n  return SUCCEEDED(target->CreateSolidColorBrush(hex_with_alpha_to_color_f(hex_color), brush));\n}\n\nauto has_floating_d2d_context(const Core::State::AppState& state) -> bool {\n  return state.floating_window && state.floating_window->d2d_context.is_initialized &&\n         state.floating_window->d2d_context.factory &&\n         state.floating_window->d2d_context.write_factory;\n}\n\nauto release_brushes(RenderSurface& surface) -> void {\n  if (surface.background_brush) {\n    surface.background_brush->Release();\n    surface.background_brush = nullptr;\n  }\n  if (surface.text_brush) {\n    surface.text_brush->Release();\n    surface.text_brush = nullptr;\n  }\n  if (surface.separator_brush) {\n    surface.separator_brush->Release();\n    surface.separator_brush = nullptr;\n  }\n  if (surface.hover_brush) {\n    surface.hover_brush->Release();\n    surface.hover_brush = nullptr;\n  }\n  if (surface.indicator_brush) {\n    surface.indicator_brush->Release();\n    surface.indicator_brush = nullptr;\n  }\n}\n\nauto cleanup_surface_bitmap(RenderSurface& surface) -> void {\n  if (surface.old_bitmap && surface.memory_dc) {\n    SelectObject(surface.memory_dc, surface.old_bitmap);\n    surface.old_bitmap = nullptr;\n  }\n  if (surface.dib_bitmap) {\n    DeleteObject(surface.dib_bitmap);\n    surface.dib_bitmap = nullptr;\n  }\n  surface.bitmap_bits = nullptr;\n  surface.bitmap_size = {0, 0};\n}\n\nauto cleanup_surface(RenderSurface& surface) -> void {\n  release_brushes(surface);\n\n  if (surface.render_target) {\n    surface.render_target->Release();\n    surface.render_target = nullptr;\n  }\n\n  cleanup_surface_bitmap(surface);\n\n  if (surface.memory_dc) {\n    DeleteDC(surface.memory_dc);\n    surface.memory_dc = nullptr;\n  }\n\n  surface.is_ready = false;\n}\n\nauto create_brushes_for_surface(Core::State::AppState& state, RenderSurface& surface) -> bool {\n  const auto& colors = state.settings->raw.ui.floating_window_colors;\n  return create_brush_from_hex(surface.render_target, force_opaque_hex_color(colors.background),\n                               &surface.background_brush) &&\n         create_brush_from_hex(surface.render_target, colors.text, &surface.text_brush) &&\n         create_brush_from_hex(surface.render_target, force_opaque_hex_color(colors.separator),\n                               &surface.separator_brush) &&\n         create_brush_from_hex(surface.render_target, force_opaque_hex_color(colors.hover),\n                               &surface.hover_brush) &&\n         create_brush_from_hex(surface.render_target, colors.indicator, &surface.indicator_brush);\n}\n\nauto ensure_memory_dc(RenderSurface& surface) -> bool {\n  if (surface.memory_dc) {\n    return true;\n  }\n\n  HDC screen_dc = GetDC(nullptr);\n  surface.memory_dc = CreateCompatibleDC(screen_dc);\n  ReleaseDC(nullptr, screen_dc);\n  return surface.memory_dc != nullptr;\n}\n\nauto create_bitmap_for_surface(RenderSurface& surface, const SIZE& size) -> bool {\n  if (size.cx <= 0 || size.cy <= 0) {\n    return false;\n  }\n\n  if (!ensure_memory_dc(surface)) {\n    return false;\n  }\n\n  cleanup_surface_bitmap(surface);\n\n  BITMAPINFO bmi = {};\n  bmi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);\n  bmi.bmiHeader.biWidth = size.cx;\n  bmi.bmiHeader.biHeight = -size.cy;\n  bmi.bmiHeader.biPlanes = 1;\n  bmi.bmiHeader.biBitCount = 32;\n  bmi.bmiHeader.biCompression = BI_RGB;\n\n  surface.dib_bitmap =\n      CreateDIBSection(surface.memory_dc, &bmi, DIB_RGB_COLORS, &surface.bitmap_bits, nullptr, 0);\n  if (!surface.dib_bitmap) {\n    return false;\n  }\n\n  surface.old_bitmap = SelectObject(surface.memory_dc, surface.dib_bitmap);\n  surface.bitmap_size = size;\n  return true;\n}\n\nauto create_render_target(ID2D1Factory7* factory, RenderSurface& surface) -> bool {\n  if (surface.render_target) {\n    return true;\n  }\n\n  D2D1_RENDER_TARGET_PROPERTIES props = D2D1::RenderTargetProperties(\n      D2D1_RENDER_TARGET_TYPE_DEFAULT,\n      D2D1::PixelFormat(DXGI_FORMAT_B8G8R8A8_UNORM, D2D1_ALPHA_MODE_PREMULTIPLIED), 0, 0,\n      D2D1_RENDER_TARGET_USAGE_NONE, D2D1_FEATURE_LEVEL_DEFAULT);\n\n  return SUCCEEDED(factory->CreateDCRenderTarget(&props, &surface.render_target));\n}\n\nauto bind_surface(RenderSurface& surface) -> bool {\n  if (!surface.render_target || !surface.memory_dc || surface.bitmap_size.cx <= 0 ||\n      surface.bitmap_size.cy <= 0) {\n    return false;\n  }\n\n  RECT binding_rect = {0, 0, surface.bitmap_size.cx, surface.bitmap_size.cy};\n  return SUCCEEDED(surface.render_target->BindDC(surface.memory_dc, &binding_rect));\n}\n\nauto initialize_surface(Core::State::AppState& state, RenderSurface& surface, const SIZE& size)\n    -> bool {\n  if (!has_floating_d2d_context(state)) {\n    return false;\n  }\n\n  if (surface.is_ready && surface.bitmap_size.cx == size.cx && surface.bitmap_size.cy == size.cy) {\n    return true;\n  }\n\n  cleanup_surface(surface);\n\n  if (!create_render_target(state.floating_window->d2d_context.factory, surface)) {\n    cleanup_surface(surface);\n    return false;\n  }\n\n  if (!create_bitmap_for_surface(surface, size)) {\n    cleanup_surface(surface);\n    return false;\n  }\n\n  if (!bind_surface(surface)) {\n    cleanup_surface(surface);\n    return false;\n  }\n\n  if (!create_brushes_for_surface(state, surface)) {\n    cleanup_surface(surface);\n    return false;\n  }\n\n  surface.is_ready = true;\n  return true;\n}\n\nauto resize_surface(RenderSurface& surface, const SIZE& new_size) -> bool {\n  if (!surface.is_ready || new_size.cx <= 0 || new_size.cy <= 0) {\n    return false;\n  }\n\n  if (surface.bitmap_size.cx == new_size.cx && surface.bitmap_size.cy == new_size.cy) {\n    return true;\n  }\n\n  if (!create_bitmap_for_surface(surface, new_size)) {\n    return false;\n  }\n\n  return bind_surface(surface);\n}\n\nauto get_client_size(HWND hwnd) -> SIZE {\n  RECT rc{};\n  GetClientRect(hwnd, &rc);\n  return {rc.right - rc.left, rc.bottom - rc.top};\n}\n\n}  // namespace\n\nnamespace UI::ContextMenu::D2DContext {\n\nauto initialize_text_format(Core::State::AppState& state) -> bool {\n  auto& menu_state = *state.context_menu;\n\n  if (!has_floating_d2d_context(state)) {\n    return false;\n  }\n\n  if (menu_state.text_format) {\n    menu_state.text_format->Release();\n    menu_state.text_format = nullptr;\n  }\n\n  HRESULT hr = state.floating_window->d2d_context.write_factory->CreateTextFormat(\n      L\"Microsoft YaHei\", nullptr, DWRITE_FONT_WEIGHT_NORMAL, DWRITE_FONT_STYLE_NORMAL,\n      DWRITE_FONT_STRETCH_NORMAL, static_cast<float>(menu_state.layout.font_size), L\"zh-CN\",\n      &menu_state.text_format);\n  if (FAILED(hr) || !menu_state.text_format) {\n    return false;\n  }\n\n  menu_state.text_format->SetTextAlignment(DWRITE_TEXT_ALIGNMENT_LEADING);\n  menu_state.text_format->SetParagraphAlignment(DWRITE_PARAGRAPH_ALIGNMENT_CENTER);\n  return true;\n}\n\nauto initialize_context_menu(Core::State::AppState& state, HWND hwnd) -> bool {\n  auto& menu_state = *state.context_menu;\n  if (!menu_state.text_format && !initialize_text_format(state)) {\n    return false;\n  }\n\n  return initialize_surface(state, menu_state.main_surface, get_client_size(hwnd));\n}\n\nauto cleanup_context_menu(Core::State::AppState& state) -> void {\n  cleanup_surface(state.context_menu->main_surface);\n\n  auto& menu_state = *state.context_menu;\n  if (menu_state.text_format) {\n    menu_state.text_format->Release();\n    menu_state.text_format = nullptr;\n  }\n}\n\nauto initialize_submenu(Core::State::AppState& state, HWND hwnd) -> bool {\n  auto& menu_state = *state.context_menu;\n  if (!menu_state.text_format && !initialize_text_format(state)) {\n    return false;\n  }\n\n  return initialize_surface(state, menu_state.submenu_surface, get_client_size(hwnd));\n}\n\nauto cleanup_submenu(Core::State::AppState& state) -> void {\n  cleanup_surface(state.context_menu->submenu_surface);\n}\n\nauto resize_context_menu(Core::State::AppState& state, const SIZE& new_size) -> bool {\n  return resize_surface(state.context_menu->main_surface, new_size);\n}\n\nauto resize_submenu(Core::State::AppState& state, const SIZE& new_size) -> bool {\n  return resize_surface(state.context_menu->submenu_surface, new_size);\n}\n\n}  // namespace UI::ContextMenu::D2DContext\n"
  },
  {
    "path": "src/ui/context_menu/d2d_context.ixx",
    "content": "module;\n\nexport module UI.ContextMenu.D2DContext;\n\nimport std;\nimport Core.State;\nimport UI.ContextMenu.State;\nimport <windows.h>;\n\nexport namespace UI::ContextMenu::D2DContext {\n\nusing State::ContextMenuState;\n\nauto initialize_text_format(Core::State::AppState& app_state) -> bool;\n\n// 主菜单D2D资源管理\nauto initialize_context_menu(Core::State::AppState& app_state, HWND hwnd) -> bool;\nauto cleanup_context_menu(Core::State::AppState& app_state) -> void;\n\n// 子菜单D2D资源管理\nauto initialize_submenu(Core::State::AppState& app_state, HWND hwnd) -> bool;\nauto cleanup_submenu(Core::State::AppState& app_state) -> void;\n\n// 调整渲染目标大小\nauto resize_context_menu(Core::State::AppState& app_state, const SIZE& new_size) -> bool;\nauto resize_submenu(Core::State::AppState& app_state, const SIZE& new_size) -> bool;\n\n}  // namespace UI::ContextMenu::D2DContext\n"
  },
  {
    "path": "src/ui/context_menu/interaction.cpp",
    "content": "module;\n\n#include <windows.h>\n\nmodule UI.ContextMenu.Interaction;\n\nimport std;\nimport Core.State;\nimport UI.ContextMenu.State;\nimport UI.ContextMenu.Types;\n\nnamespace {\n\nusing UI::ContextMenu::State::ContextMenuState;\nusing UI::ContextMenu::Types::PendingIntentType;\n\nauto resolve_timer_owner(const ContextMenuState& menu_state, HWND fallback) -> HWND {\n  return menu_state.hwnd ? menu_state.hwnd : fallback;\n}\n\nauto cancel_pending_intent_impl(ContextMenuState& menu_state, HWND timer_owner) -> void {\n  auto& interaction = menu_state.interaction;\n  if (interaction.intent_timer_id != 0) {\n    KillTimer(timer_owner, interaction.INTENT_TIMER_ID);\n    interaction.intent_timer_id = 0;\n  }\n  interaction.pending_intent = PendingIntentType::None;\n  interaction.pending_parent_index = -1;\n}\n\nauto request_intent(ContextMenuState& menu_state, HWND timer_owner, PendingIntentType intent,\n                    int parent_index, UINT delay_ms) -> void {\n  auto& interaction = menu_state.interaction;\n\n  if (intent == PendingIntentType::None) {\n    cancel_pending_intent_impl(menu_state, timer_owner);\n    return;\n  }\n\n  // 相同意图保持原定时器，避免高频WM_MOUSEMOVE导致延迟被不断重置。\n  if (interaction.intent_timer_id != 0 && interaction.pending_intent == intent &&\n      interaction.pending_parent_index == parent_index) {\n    return;\n  }\n\n  cancel_pending_intent_impl(menu_state, timer_owner);\n  interaction.pending_intent = intent;\n  interaction.pending_parent_index = parent_index;\n  interaction.intent_timer_id =\n      SetTimer(timer_owner, interaction.INTENT_TIMER_ID, delay_ms, nullptr);\n\n  if (interaction.intent_timer_id == 0) {\n    interaction.pending_intent = PendingIntentType::None;\n    interaction.pending_parent_index = -1;\n  }\n}\n\n}  // anonymous namespace\n\nnamespace UI::ContextMenu::Interaction {\n\nauto reset(Core::State::AppState& state) -> void { state.context_menu->interaction = {}; }\n\nauto cancel_pending_intent(Core::State::AppState& state, HWND timer_owner) -> void {\n  auto& menu_state = *state.context_menu;\n  cancel_pending_intent_impl(menu_state, resolve_timer_owner(menu_state, timer_owner));\n}\n\nauto on_main_mouse_move(Core::State::AppState& state, int hover_index, HWND timer_owner) -> bool {\n  auto& menu_state = *state.context_menu;\n  auto& interaction = menu_state.interaction;\n  timer_owner = resolve_timer_owner(menu_state, timer_owner);\n  const auto previous_zone = interaction.cursor_zone;\n  interaction.cursor_zone = Types::CursorZone::MainMenu;\n\n  bool should_repaint = previous_zone != Types::CursorZone::MainMenu && menu_state.submenu_hwnd &&\n                        menu_state.submenu_parent_index >= 0 &&\n                        menu_state.submenu_parent_index < static_cast<int>(menu_state.items.size());\n\n  if (hover_index != interaction.hover_index) {\n    interaction.hover_index = hover_index;\n    should_repaint = true;\n  }\n\n  if (hover_index < 0 || hover_index >= static_cast<int>(menu_state.items.size())) {\n    if (menu_state.submenu_hwnd) {\n      request_intent(menu_state, timer_owner, PendingIntentType::HideSubmenu, -1,\n                     interaction.HIDE_SUBMENU_DELAY);\n    } else {\n      request_intent(menu_state, timer_owner, PendingIntentType::None, -1, 0);\n    }\n    return should_repaint;\n  }\n\n  const auto& item = menu_state.items[hover_index];\n  if (!item.has_submenu()) {\n    if (menu_state.submenu_hwnd) {\n      request_intent(menu_state, timer_owner, PendingIntentType::HideSubmenu, -1,\n                     interaction.HIDE_SUBMENU_DELAY);\n    } else {\n      request_intent(menu_state, timer_owner, PendingIntentType::None, -1, 0);\n    }\n    return should_repaint;\n  }\n\n  if (menu_state.submenu_hwnd && menu_state.submenu_parent_index == hover_index) {\n    request_intent(menu_state, timer_owner, PendingIntentType::None, -1, 0);\n    return should_repaint;\n  }\n\n  const bool has_open_submenu = menu_state.submenu_hwnd != nullptr;\n  request_intent(\n      menu_state, timer_owner,\n      has_open_submenu ? PendingIntentType::SwitchSubmenu : PendingIntentType::OpenSubmenu,\n      hover_index,\n      has_open_submenu ? interaction.SWITCH_SUBMENU_DELAY : interaction.OPEN_SUBMENU_DELAY);\n\n  return should_repaint;\n}\n\nauto on_submenu_mouse_move(Core::State::AppState& state, int submenu_hover_index, HWND timer_owner)\n    -> bool {\n  auto& menu_state = *state.context_menu;\n  auto& interaction = menu_state.interaction;\n  timer_owner = resolve_timer_owner(menu_state, timer_owner);\n  interaction.cursor_zone = Types::CursorZone::Submenu;\n\n  bool should_repaint = false;\n  if (submenu_hover_index != interaction.submenu_hover_index) {\n    interaction.submenu_hover_index = submenu_hover_index;\n    should_repaint = true;\n  }\n\n  // 进入当前子菜单即取消任何待处理意图（切换/隐藏）。\n  request_intent(menu_state, timer_owner, PendingIntentType::None, -1, 0);\n  return should_repaint;\n}\n\nauto on_mouse_leave(Core::State::AppState& state, HWND source_hwnd, HWND timer_owner) -> bool {\n  auto& menu_state = *state.context_menu;\n  auto& interaction = menu_state.interaction;\n  timer_owner = resolve_timer_owner(menu_state, timer_owner);\n\n  const auto previous_zone = interaction.cursor_zone;\n  interaction.cursor_zone = Types::CursorZone::Outside;\n  bool should_repaint = false;\n\n  if (source_hwnd == menu_state.hwnd && previous_zone == Types::CursorZone::MainMenu &&\n      menu_state.submenu_hwnd && menu_state.submenu_parent_index >= 0 &&\n      menu_state.submenu_parent_index < static_cast<int>(menu_state.items.size())) {\n    should_repaint = true;\n  }\n\n  if (source_hwnd == menu_state.submenu_hwnd) {\n    if (interaction.submenu_hover_index != -1) {\n      interaction.submenu_hover_index = -1;\n      should_repaint = true;\n    }\n  } else if (source_hwnd == menu_state.hwnd) {\n    if (interaction.hover_index != -1) {\n      interaction.hover_index = -1;\n      should_repaint = true;\n    }\n  }\n\n  if (menu_state.submenu_hwnd) {\n    request_intent(menu_state, timer_owner, PendingIntentType::HideSubmenu, -1,\n                   interaction.HIDE_SUBMENU_DELAY);\n  } else {\n    request_intent(menu_state, timer_owner, PendingIntentType::None, -1, 0);\n  }\n\n  return should_repaint;\n}\n\nauto on_timer(Core::State::AppState& state, HWND timer_owner, WPARAM timer_id) -> TimerAction {\n  auto& menu_state = *state.context_menu;\n  auto& interaction = menu_state.interaction;\n  timer_owner = resolve_timer_owner(menu_state, timer_owner);\n\n  if (timer_id != interaction.INTENT_TIMER_ID) {\n    return {};\n  }\n\n  KillTimer(timer_owner, interaction.INTENT_TIMER_ID);\n  interaction.intent_timer_id = 0;\n\n  const auto pending_intent = interaction.pending_intent;\n  const int pending_parent_index = interaction.pending_parent_index;\n  interaction.pending_intent = PendingIntentType::None;\n  interaction.pending_parent_index = -1;\n\n  if (interaction.cursor_zone == Types::CursorZone::Submenu) {\n    return {};\n  }\n\n  switch (pending_intent) {\n    case PendingIntentType::OpenSubmenu:\n    case PendingIntentType::SwitchSubmenu: {\n      if (pending_parent_index < 0 ||\n          pending_parent_index >= static_cast<int>(menu_state.items.size())) {\n        return {};\n      }\n      if (pending_parent_index != interaction.hover_index) {\n        return {};\n      }\n      if (!menu_state.items[pending_parent_index].has_submenu()) {\n        return {};\n      }\n      return {TimerActionType::ShowSubmenu, pending_parent_index, true};\n    }\n    case PendingIntentType::HideSubmenu: {\n      if (!menu_state.submenu_hwnd) {\n        return {};\n      }\n      return {TimerActionType::HideSubmenu, -1, true};\n    }\n    case PendingIntentType::None:\n    default:\n      return {};\n  }\n}\n\nauto get_main_highlight_index(const Core::State::AppState& state) -> int {\n  const auto& menu_state = *state.context_menu;\n  const auto& interaction = menu_state.interaction;\n\n  if (!menu_state.submenu_hwnd || menu_state.submenu_parent_index < 0 ||\n      menu_state.submenu_parent_index >= static_cast<int>(menu_state.items.size())) {\n    return interaction.hover_index;\n  }\n\n  // 子菜单打开时：主菜单区域遵循实时 hover；离开主菜单后回显当前子菜单父项。\n  if (interaction.cursor_zone == Types::CursorZone::MainMenu) {\n    return interaction.hover_index;\n  }\n\n  return menu_state.submenu_parent_index;\n}\n\n}  // namespace UI::ContextMenu::Interaction\n"
  },
  {
    "path": "src/ui/context_menu/interaction.ixx",
    "content": "module;\n\n#include <windows.h>\n\nexport module UI.ContextMenu.Interaction;\n\nimport Core.State;\n\nexport namespace UI::ContextMenu::Interaction {\n\nenum class TimerActionType { None, ShowSubmenu, HideSubmenu };\n\nstruct TimerAction {\n  TimerActionType type = TimerActionType::None;\n  int parent_index = -1;\n  bool invalidate_main = false;\n};\n\nexport auto reset(Core::State::AppState& state) -> void;\n\nexport auto cancel_pending_intent(Core::State::AppState& state, HWND timer_owner) -> void;\n\nexport auto on_main_mouse_move(Core::State::AppState& state, int hover_index, HWND timer_owner)\n    -> bool;\n\nexport auto on_submenu_mouse_move(Core::State::AppState& state, int submenu_hover_index,\n                                  HWND timer_owner) -> bool;\n\nexport auto on_mouse_leave(Core::State::AppState& state, HWND source_hwnd, HWND timer_owner)\n    -> bool;\n\nexport auto on_timer(Core::State::AppState& state, HWND timer_owner, WPARAM timer_id)\n    -> TimerAction;\n\nexport auto get_main_highlight_index(const Core::State::AppState& state) -> int;\n\n}  // namespace UI::ContextMenu::Interaction\n"
  },
  {
    "path": "src/ui/context_menu/layout.cpp",
    "content": "module;\n\nmodule UI.ContextMenu.Layout;\n\nimport std;\nimport Core.State;\nimport UI.ContextMenu.State;\nimport UI.ContextMenu.Types;\nimport UI.FloatingWindow.State;\nimport UI.FloatingWindow.Types;\nimport Vendor.Windows;\nimport <d2d1.h>;\nimport <dwrite.h>;\nimport <windows.h>;\nimport <wrl/client.h>;\n\nnamespace UI::ContextMenu::Layout {\n\nauto calculate_text_width(const Core::State::AppState& state, const std::wstring& text) -> int {\n  const auto& menu_state = *state.context_menu;\n  if (!state.floating_window) {\n    return static_cast<int>(text.length() * menu_state.layout.font_size * 0.6);\n  }\n  const auto& d2d = state.floating_window->d2d_context;\n  if (!d2d.is_initialized || !d2d.write_factory || !menu_state.text_format) {\n    return static_cast<int>(text.length() * menu_state.layout.font_size * 0.6);\n  }\n\n  Microsoft::WRL::ComPtr<IDWriteTextLayout> text_layout;\n  HRESULT hr = d2d.write_factory->CreateTextLayout(\n      text.c_str(), static_cast<UINT32>(text.length()), menu_state.text_format, 1000.0f,\n      static_cast<float>(menu_state.layout.item_height), &text_layout);\n\n  if (SUCCEEDED(hr)) {\n    DWRITE_TEXT_METRICS metrics;\n    if (SUCCEEDED(text_layout->GetMetrics(&metrics))) {\n      return static_cast<int>(std::ceil(metrics.width));\n    }\n  }\n  return static_cast<int>(text.length() * menu_state.layout.font_size * 0.6);\n}\n\nauto calculate_menu_size(Core::State::AppState& state) -> void {\n  auto& menu_state = *state.context_menu;\n  const auto& layout = menu_state.layout;\n  int total_height = layout.padding * 2;\n  int max_width = layout.min_width;\n\n  for (const auto& item : menu_state.items) {\n    if (item.type == Types::MenuItemType::Separator) {\n      total_height += layout.separator_height;\n    } else {\n      total_height += layout.item_height;\n      int text_width = calculate_text_width(state, item.text);\n      int item_width = text_width + layout.text_padding * 2;\n      if (item.is_checked) {\n        item_width += layout.font_size + layout.text_padding;\n      }\n      max_width = std::max(max_width, item_width);\n    }\n  }\n  menu_state.menu_size = {max_width, total_height};\n}\n\nauto calculate_menu_position(const Core::State::AppState& state,\n                             const Vendor::Windows::POINT& cursor_pos) -> Vendor::Windows::POINT {\n  const auto& menu_state = *state.context_menu;\n  HMONITOR monitor = MonitorFromPoint({cursor_pos.x, cursor_pos.y}, MONITOR_DEFAULTTONEAREST);\n  MONITORINFO monitor_info{sizeof(MONITORINFO)};\n  GetMonitorInfoW(monitor, &monitor_info);\n  const auto& work_area = monitor_info.rcWork;\n\n  Vendor::Windows::POINT position = cursor_pos;\n  if (position.x + menu_state.menu_size.cx > work_area.right) {\n    position.x = cursor_pos.x - menu_state.menu_size.cx;\n  }\n  if (position.y + menu_state.menu_size.cy > work_area.bottom) {\n    position.y = cursor_pos.y - menu_state.menu_size.cy;\n  }\n  position.x = std::max(position.x, work_area.left);\n  position.y = std::max(position.y, work_area.top);\n  return position;\n}\n\nauto get_menu_item_at_point(const Core::State::AppState& state, const POINT& pt) -> int {\n  const auto& menu_state = *state.context_menu;\n  int current_y = menu_state.layout.padding;\n  for (size_t i = 0; i < menu_state.items.size(); ++i) {\n    const auto& item = menu_state.items[i];\n    int item_height = (item.type == Types::MenuItemType::Separator)\n                          ? menu_state.layout.separator_height\n                          : menu_state.layout.item_height;\n    if (pt.y >= current_y && pt.y < current_y + item_height &&\n        item.type == Types::MenuItemType::Normal) {\n      return static_cast<int>(i);\n    }\n    current_y += item_height;\n  }\n  return -1;\n}\n\n// 计算子菜单尺寸\nauto calculate_submenu_size(Core::State::AppState& state) -> void {\n  auto& menu_state = *state.context_menu;\n  const auto& layout = menu_state.layout;\n  const auto& current_submenu = menu_state.get_current_submenu();\n\n  if (current_submenu.empty()) {\n    menu_state.submenu_size = {0, 0};\n    return;\n  }\n\n  // 计算子菜单的宽度和高度\n  int max_width = layout.min_width;\n  int total_height = layout.padding * 2;\n\n  for (const auto& item : current_submenu) {\n    if (item.type == Types::MenuItemType::Separator) {\n      total_height += layout.separator_height;\n    } else {\n      total_height += layout.item_height;\n      // 使用现有的文本宽度计算函数，比tray_menu的简单估算更精确\n      int text_width = calculate_text_width(state, item.text) + layout.text_padding * 2;\n      if (item.is_checked) {\n        text_width += layout.font_size + layout.text_padding;\n      }\n      max_width = std::max(max_width, text_width);\n    }\n  }\n\n  menu_state.submenu_size = {max_width, total_height};\n}\n\n// 计算子菜单位置\nauto calculate_submenu_position(Core::State::AppState& state, int parent_index) -> void {\n  auto& menu_state = *state.context_menu;\n  const auto& layout = menu_state.layout;\n\n  // 计算父菜单项的位置\n  int parent_y = layout.padding;\n  for (int i = 0; i < parent_index; ++i) {\n    const auto& item = menu_state.items[i];\n    if (item.type == Types::MenuItemType::Separator) {\n      parent_y += layout.separator_height;\n    } else {\n      parent_y += layout.item_height;\n    }\n  }\n\n  // 子菜单显示在主菜单右侧（略微重叠以补偿 DWM 圆角/阴影的视觉间距）\n  constexpr int kSubmenuOverlap = 4;\n  menu_state.submenu_position.x = menu_state.position.x + menu_state.menu_size.cx - kSubmenuOverlap;\n  menu_state.submenu_position.y = menu_state.position.y + parent_y;\n\n  // 确保子菜单不会超出屏幕边界，使用与主菜单一致的监视器检测方式\n  HMONITOR monitor =\n      MonitorFromPoint({menu_state.position.x, menu_state.position.y}, MONITOR_DEFAULTTONEAREST);\n  MONITORINFO monitor_info{sizeof(MONITORINFO)};\n  GetMonitorInfoW(monitor, &monitor_info);\n  const auto& work_area = monitor_info.rcWork;\n\n  // 检查右边界\n  if (menu_state.submenu_position.x + menu_state.submenu_size.cx > work_area.right) {\n    // 显示在主菜单左侧（同样应用 overlap）\n    menu_state.submenu_position.x =\n        menu_state.position.x - menu_state.submenu_size.cx + kSubmenuOverlap;\n  }\n\n  // 检查下边界\n  if (menu_state.submenu_position.y + menu_state.submenu_size.cy > work_area.bottom) {\n    menu_state.submenu_position.y = work_area.bottom - menu_state.submenu_size.cy;\n  }\n\n  // 检查上边界\n  if (menu_state.submenu_position.y < work_area.top) {\n    menu_state.submenu_position.y = work_area.top;\n  }\n}\n\n}  // namespace UI::ContextMenu::Layout\n"
  },
  {
    "path": "src/ui/context_menu/layout.ixx",
    "content": "module;\n\nexport module UI.ContextMenu.Layout;\n\nimport std;\nimport Core.State;\nimport UI.ContextMenu.State;\nimport Vendor.Windows;\n\nnamespace UI::ContextMenu::Layout {\n\n// 计算菜单总尺寸\nexport auto calculate_menu_size(Core::State::AppState& app_state) -> void;\n\n// 计算菜单显示位置（确保在屏幕内）\nexport auto calculate_menu_position(const Core::State::AppState& app_state,\n                                    const Vendor::Windows::POINT& cursor_pos)\n    -> Vendor::Windows::POINT;\n\n// 计算文本宽度\nexport auto calculate_text_width(const Core::State::AppState& app_state, const std::wstring& text)\n    -> int;\n\n// 根据坐标获取菜单项索引\nexport auto get_menu_item_at_point(const Core::State::AppState& app_state,\n                                   const Vendor::Windows::POINT& pt) -> int;\n\n// 子菜单计算函数\nexport auto calculate_submenu_size(Core::State::AppState& app_state) -> void;\nexport auto calculate_submenu_position(Core::State::AppState& app_state, int parent_index) -> void;\n\n}  // namespace UI::ContextMenu::Layout"
  },
  {
    "path": "src/ui/context_menu/message_handler.cpp",
    "content": "module;\n\nmodule UI.ContextMenu.MessageHandler;\n\nimport std;\nimport Core.State;\nimport UI.ContextMenu;\nimport UI.ContextMenu.Layout;\nimport UI.ContextMenu.Painter;\nimport UI.ContextMenu.D2DContext;\nimport UI.ContextMenu.State;\nimport UI.ContextMenu.Types;\nimport UI.ContextMenu.Interaction;\nimport Utils.Logger;\nimport <windows.h>;\nimport <windowsx.h>;\n\nnamespace {\n\nusing UI::ContextMenu::State::ContextMenuState;\n\nauto get_submenu_item_at_point(Core::State::AppState& state, const POINT& pt) -> int;\nauto handle_paint(Core::State::AppState& state, HWND hwnd) -> LRESULT;\nauto handle_size(Core::State::AppState& state, HWND hwnd) -> LRESULT;\nauto handle_mouse_move(Core::State::AppState& state, HWND hwnd, WPARAM wParam, LPARAM lParam)\n    -> LRESULT;\nauto handle_mouse_leave(Core::State::AppState& state, HWND hwnd) -> LRESULT;\nauto handle_left_button_down(Core::State::AppState& state, HWND hwnd, WPARAM wParam, LPARAM lParam)\n    -> LRESULT;\nauto handle_key_down(Core::State::AppState& state, HWND hwnd, WPARAM wParam, LPARAM lParam)\n    -> LRESULT;\nauto handle_kill_focus(Core::State::AppState& state, HWND hwnd) -> LRESULT;\nauto handle_timer(Core::State::AppState& state, HWND hwnd, WPARAM timer_id) -> LRESULT;\nauto handle_destroy(Core::State::AppState& state, HWND hwnd) -> LRESULT;\n\nauto get_timer_owner_hwnd(const ContextMenuState& menu_state, HWND fallback) -> HWND {\n  return menu_state.hwnd ? menu_state.hwnd : fallback;\n}\n\nauto window_procedure(Core::State::AppState& state, HWND hwnd, UINT msg, WPARAM wParam,\n                      LPARAM lParam) -> LRESULT {\n  switch (msg) {\n    case WM_PAINT:\n      return handle_paint(state, hwnd);\n    case WM_SIZE:\n      return handle_size(state, hwnd);\n    case WM_MOUSEMOVE:\n      return handle_mouse_move(state, hwnd, wParam, lParam);\n    case WM_MOUSELEAVE:\n      return handle_mouse_leave(state, hwnd);\n    case WM_LBUTTONDOWN:\n      return handle_left_button_down(state, hwnd, wParam, lParam);\n    case WM_KEYDOWN:\n      return handle_key_down(state, hwnd, wParam, lParam);\n    case WM_KILLFOCUS:\n      return handle_kill_focus(state, hwnd);\n    case WM_TIMER:\n      return handle_timer(state, hwnd, wParam);\n    case WM_DESTROY:\n      return handle_destroy(state, hwnd);\n  }\n  return DefWindowProcW(hwnd, msg, wParam, lParam);\n}\n\n}  // anonymous namespace\n\nnamespace UI::ContextMenu::MessageHandler {\n\nauto CALLBACK static_window_proc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) -> LRESULT {\n  Core::State::AppState* app_state = nullptr;\n\n  if (msg == WM_NCCREATE) {\n    auto* create_struct = reinterpret_cast<CREATESTRUCTW*>(lParam);\n    app_state = static_cast<Core::State::AppState*>(create_struct->lpCreateParams);\n    SetWindowLongPtrW(hwnd, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(app_state));\n  } else {\n    app_state = reinterpret_cast<Core::State::AppState*>(GetWindowLongPtrW(hwnd, GWLP_USERDATA));\n  }\n\n  if (app_state) {\n    return window_procedure(*app_state, hwnd, msg, wParam, lParam);\n  }\n\n  return DefWindowProcW(hwnd, msg, wParam, lParam);\n}\n\n}  // namespace UI::ContextMenu::MessageHandler\n\nnamespace {\n\nauto get_submenu_item_at_point(Core::State::AppState& state, const POINT& pt) -> int {\n  const auto& menu_state = *state.context_menu;\n  const auto& layout = menu_state.layout;\n  const auto& current_submenu = menu_state.get_current_submenu();\n  int current_y = layout.padding;\n  for (size_t i = 0; i < current_submenu.size(); ++i) {\n    const auto& item = current_submenu[i];\n    int item_height = (item.type == UI::ContextMenu::Types::MenuItemType::Separator)\n                          ? layout.separator_height\n                          : layout.item_height;\n    if (pt.y >= current_y && pt.y < current_y + item_height) {\n      return static_cast<int>(i);\n    }\n    current_y += item_height;\n  }\n  return -1;\n}\n\nauto handle_paint(Core::State::AppState& state, HWND hwnd) -> LRESULT {\n  const auto& menu_state = *state.context_menu;\n  PAINTSTRUCT ps{};\n  if (BeginPaint(hwnd, &ps)) {\n    RECT rect{};\n    GetClientRect(hwnd, &rect);\n    if (hwnd == menu_state.submenu_hwnd) {\n      UI::ContextMenu::Painter::paint_submenu(state, rect);\n    } else {\n      UI::ContextMenu::Painter::paint_context_menu(state, rect);\n    }\n    EndPaint(hwnd, &ps);\n  }\n  return 0;\n}\n\nauto handle_size(Core::State::AppState& state, HWND hwnd) -> LRESULT {\n  const auto& menu_state = *state.context_menu;\n  RECT rc;\n  GetClientRect(hwnd, &rc);\n  SIZE new_size = {rc.right - rc.left, rc.bottom - rc.top};\n  if (hwnd == menu_state.submenu_hwnd) {\n    Logger().debug(\"Resizing submenu to size: {}x{}\", new_size.cx, new_size.cy);\n    if (UI::ContextMenu::D2DContext::resize_submenu(state, new_size)) {\n      UI::ContextMenu::Painter::paint_submenu(state, rc);\n    }\n  } else if (hwnd == menu_state.hwnd) {\n    Logger().debug(\"Resizing context menu to size: {}x{}\", new_size.cx, new_size.cy);\n    if (UI::ContextMenu::D2DContext::resize_context_menu(state, new_size)) {\n      UI::ContextMenu::Painter::paint_context_menu(state, rc);\n    }\n  }\n  return 0;\n}\n\nauto handle_mouse_move(Core::State::AppState& state, HWND hwnd, WPARAM, LPARAM lParam) -> LRESULT {\n  auto& menu_state = *state.context_menu;\n  const HWND timer_owner = get_timer_owner_hwnd(menu_state, hwnd);\n  POINT pt = {GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)};\n\n  if (hwnd == menu_state.submenu_hwnd) {\n    int submenu_hover_index = get_submenu_item_at_point(state, pt);\n    if (UI::ContextMenu::Interaction::on_submenu_mouse_move(state, submenu_hover_index,\n                                                            timer_owner)) {\n      InvalidateRect(hwnd, nullptr, FALSE);\n    }\n  } else {\n    int hover_index = UI::ContextMenu::Layout::get_menu_item_at_point(state, pt);\n    if (UI::ContextMenu::Interaction::on_main_mouse_move(state, hover_index, timer_owner)) {\n      InvalidateRect(hwnd, nullptr, FALSE);\n    }\n  }\n\n  TRACKMOUSEEVENT tme{sizeof(TRACKMOUSEEVENT), TME_LEAVE, hwnd, 0};\n  TrackMouseEvent(&tme);\n  return 0;\n}\n\nauto handle_mouse_leave(Core::State::AppState& state, HWND hwnd) -> LRESULT {\n  auto& menu_state = *state.context_menu;\n  const HWND timer_owner = get_timer_owner_hwnd(menu_state, hwnd);\n\n  if (UI::ContextMenu::Interaction::on_mouse_leave(state, hwnd, timer_owner)) {\n    InvalidateRect(hwnd, nullptr, FALSE);\n  }\n  return 0;\n}\n\nauto handle_left_button_down(Core::State::AppState& state, HWND hwnd, WPARAM, LPARAM lParam)\n    -> LRESULT {\n  auto& menu_state = *state.context_menu;\n  const HWND timer_owner = get_timer_owner_hwnd(menu_state, hwnd);\n\n  if (hwnd == menu_state.submenu_hwnd) {\n    POINT pt = {GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)};\n    int clicked_index = get_submenu_item_at_point(state, pt);\n    const auto& current_submenu = menu_state.get_current_submenu();\n    if (clicked_index >= 0 && clicked_index < static_cast<int>(current_submenu.size())) {\n      const auto& item = current_submenu[clicked_index];\n      if (item.type == UI::ContextMenu::Types::MenuItemType::Normal && item.is_enabled) {\n        UI::ContextMenu::handle_menu_action(state, item);\n        DestroyWindow(menu_state.hwnd);  // Close on selection\n      }\n    }\n  } else {\n    POINT pt = {GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)};\n    int clicked_index = UI::ContextMenu::Layout::get_menu_item_at_point(state, pt);\n    if (clicked_index >= 0 && clicked_index < static_cast<int>(menu_state.items.size())) {\n      const auto& item = menu_state.items[clicked_index];\n      if (item.type == UI::ContextMenu::Types::MenuItemType::Normal && item.is_enabled) {\n        if (item.has_submenu()) {\n          UI::ContextMenu::Interaction::cancel_pending_intent(state, timer_owner);\n          UI::ContextMenu::show_submenu(state, clicked_index);\n          InvalidateRect(menu_state.hwnd, nullptr, FALSE);\n        } else {\n          UI::ContextMenu::handle_menu_action(state, item);\n          DestroyWindow(menu_state.hwnd);  // Close on selection\n        }\n      }\n    }\n  }\n  return 0;\n}\n\nauto handle_key_down(Core::State::AppState& state, HWND hwnd, WPARAM wParam, LPARAM) -> LRESULT {\n  switch (wParam) {\n    case VK_ESCAPE:\n      DestroyWindow(hwnd);\n      break;\n  }\n  return 0;\n}\n\nauto handle_kill_focus(Core::State::AppState& state, HWND hwnd) -> LRESULT {\n  auto& menu_state = *state.context_menu;\n  const HWND timer_owner = get_timer_owner_hwnd(menu_state, hwnd);\n\n  HWND new_focus = GetFocus();\n  if (new_focus != nullptr &&\n      (new_focus == menu_state.hwnd || new_focus == menu_state.submenu_hwnd)) {\n    return 0;\n  }\n\n  Logger().debug(\"Menu lost focus to external window, hiding entire menu system\");\n  UI::ContextMenu::Interaction::cancel_pending_intent(state, timer_owner);\n  UI::ContextMenu::hide_and_destroy_menu(state);\n  return 0;\n}\n\nauto handle_timer(Core::State::AppState& state, HWND hwnd, WPARAM timer_id) -> LRESULT {\n  auto& menu_state = *state.context_menu;\n  const HWND timer_owner = get_timer_owner_hwnd(menu_state, hwnd);\n\n  const auto action = UI::ContextMenu::Interaction::on_timer(state, timer_owner, timer_id);\n  switch (action.type) {\n    case UI::ContextMenu::Interaction::TimerActionType::ShowSubmenu:\n      UI::ContextMenu::show_submenu(state, action.parent_index);\n      break;\n    case UI::ContextMenu::Interaction::TimerActionType::HideSubmenu:\n      UI::ContextMenu::hide_submenu(state);\n      break;\n    case UI::ContextMenu::Interaction::TimerActionType::None:\n    default:\n      break;\n  }\n\n  if (action.invalidate_main && menu_state.hwnd) {\n    InvalidateRect(menu_state.hwnd, nullptr, FALSE);\n  }\n\n  return 0;\n}\n\nauto handle_destroy(Core::State::AppState& state, HWND hwnd) -> LRESULT {\n  auto& menu_state = *state.context_menu;\n  if (hwnd == menu_state.hwnd) {\n    UI::ContextMenu::Interaction::cancel_pending_intent(state, hwnd);\n  }\n  return 0;\n}\n\n}  // anonymous namespace\n"
  },
  {
    "path": "src/ui/context_menu/message_handler.ixx",
    "content": "module;\n\n#include <windows.h>\n\nexport module UI.ContextMenu.MessageHandler;\n\nimport std;\n\nnamespace UI::ContextMenu::MessageHandler {\n\n// 静态窗口过程函数，是模块与Windows消息系统交互的唯一入口\nexport auto CALLBACK static_window_proc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam)\n    -> LRESULT;\n\n}  // namespace UI::ContextMenu::MessageHandler"
  },
  {
    "path": "src/ui/context_menu/painter.cpp",
    "content": "module;\n\nmodule UI.ContextMenu.Painter;\n\nimport std;\nimport Core.State;\nimport UI.FloatingWindow.Types;\nimport UI.FloatingWindow.State;\nimport UI.ContextMenu.State;\nimport UI.ContextMenu.Types;\nimport UI.ContextMenu.Interaction;\nimport UI.ContextMenu.D2DContext;\nimport Utils.Logger;\nimport <d2d1_3.h>;\nimport <dwrite_3.h>;\nimport <windows.h>;\n\nnamespace {\n\nusing UI::ContextMenu::State::ContextMenuState;\nusing UI::ContextMenu::State::RenderSurface;\n\nauto present_surface(HWND hwnd, const RenderSurface& surface, const POINT& position) -> void {\n  if (!hwnd || !surface.memory_dc || surface.bitmap_size.cx <= 0 || surface.bitmap_size.cy <= 0) {\n    return;\n  }\n\n  BLENDFUNCTION blend = {};\n  blend.BlendOp = AC_SRC_OVER;\n  blend.BlendFlags = 0;\n  blend.SourceConstantAlpha = 255;\n  blend.AlphaFormat = AC_SRC_ALPHA;\n\n  POINT src_point = {0, 0};\n  POINT dst_point = position;\n  SIZE size = surface.bitmap_size;\n\n  UpdateLayeredWindow(hwnd, nullptr, &dst_point, &size, surface.memory_dc, &src_point, 0, &blend,\n                      ULW_ALPHA);\n}\n\nauto rect_to_d2d(const RECT& rect) -> D2D1_RECT_F {\n  return D2D1::RectF(static_cast<float>(rect.left), static_cast<float>(rect.top),\n                     static_cast<float>(rect.right), static_cast<float>(rect.bottom));\n}\n\n}  // namespace\n\nnamespace UI::ContextMenu::Painter {\n\nauto paint_context_menu(Core::State::AppState& state, const RECT& client_rect) -> void {\n  auto& menu_state = *state.context_menu;\n  auto& surface = menu_state.main_surface;\n  if (!surface.is_ready || !surface.render_target || !menu_state.text_format) {\n    return;\n  }\n\n  surface.render_target->BeginDraw();\n  surface.render_target->Clear(D2D1::ColorF(0.0f, 0.0f, 0.0f, 0.0f));\n\n  const auto rect_f = rect_to_d2d(client_rect);\n  draw_menu_background(state, rect_f);\n  draw_menu_items(state, rect_f);\n\n  HRESULT hr = surface.render_target->EndDraw();\n  if (FAILED(hr)) {\n    if (hr == D2DERR_RECREATE_TARGET) {\n      Logger().warn(\"Main menu render target needs recreation\");\n    } else {\n      Logger().error(\"Main menu paint error: 0x{:X}\", hr);\n    }\n    return;\n  }\n\n  present_surface(menu_state.hwnd, surface, menu_state.position);\n}\n\nauto draw_menu_background(Core::State::AppState& state, const D2D1_RECT_F& rect) -> void {\n  const auto& surface = state.context_menu->main_surface;\n  surface.render_target->FillRectangle(rect, surface.background_brush);\n}\n\nauto draw_menu_items(Core::State::AppState& state, const D2D1_RECT_F& rect) -> void {\n  const auto& menu_state = *state.context_menu;\n  const auto& layout = menu_state.layout;\n  const int highlight_index = UI::ContextMenu::Interaction::get_main_highlight_index(state);\n  float current_y = rect.top + static_cast<float>(layout.padding);\n  for (size_t i = 0; i < menu_state.items.size(); ++i) {\n    const auto& item = menu_state.items[i];\n    const bool is_hovered = static_cast<int>(i) == highlight_index;\n    if (item.type == Types::MenuItemType::Separator) {\n      const float separator_height = static_cast<float>(layout.separator_height);\n      const D2D1_RECT_F separator_rect = D2D1::RectF(\n          rect.left + static_cast<float>(layout.text_padding), current_y,\n          rect.right - static_cast<float>(layout.text_padding), current_y + separator_height);\n      draw_separator(state, separator_rect);\n      current_y += separator_height;\n    } else {\n      const float item_height = static_cast<float>(layout.item_height);\n      const D2D1_RECT_F item_rect =\n          D2D1::RectF(rect.left, current_y, rect.right, current_y + item_height);\n      draw_single_menu_item(state, item, item_rect, is_hovered);\n      current_y += item_height;\n    }\n  }\n}\n\nauto draw_single_menu_item(Core::State::AppState& state, const Types::MenuItem& item,\n                           const D2D1_RECT_F& item_rect, bool is_hovered) -> void {\n  const auto& menu_state = *state.context_menu;\n  const auto& surface = menu_state.main_surface;\n  const auto& layout = menu_state.layout;\n\n  if (is_hovered && item.is_enabled) {\n    surface.render_target->FillRectangle(item_rect, surface.hover_brush);\n  }\n\n  const D2D1_RECT_F text_rect =\n      D2D1::RectF(item_rect.left + static_cast<float>(layout.text_padding), item_rect.top,\n                  item_rect.right - static_cast<float>(layout.text_padding), item_rect.bottom);\n  ID2D1SolidColorBrush* text_brush = item.is_enabled ? surface.text_brush : surface.separator_brush;\n  surface.render_target->DrawText(item.text.c_str(), static_cast<UINT32>(item.text.length()),\n                                  menu_state.text_format, text_rect, text_brush);\n\n  if (item.has_submenu()) {\n    const float arrow_height = static_cast<float>(layout.font_size) * 0.6f;\n    const float arrow_width = arrow_height * 0.6f;\n    const float arrow_x = item_rect.right - static_cast<float>(layout.text_padding) - arrow_width;\n    const float arrow_y = item_rect.top + (item_rect.bottom - item_rect.top - arrow_height) / 2;\n\n    D2D1_POINT_2F points[3] = {\n        D2D1::Point2F(arrow_x, arrow_y),\n        D2D1::Point2F(arrow_x + arrow_width, arrow_y + arrow_height / 2),\n        D2D1::Point2F(arrow_x, arrow_y + arrow_height),\n    };\n\n    const float stroke_width = static_cast<float>(layout.font_size) * 0.1f;\n    surface.render_target->DrawLine(points[0], points[1], text_brush, stroke_width);\n    surface.render_target->DrawLine(points[1], points[2], text_brush, stroke_width);\n  } else if (item.is_checked) {\n    const float check_size = static_cast<float>(layout.font_size) * 0.6f;\n    const float check_x = item_rect.right - static_cast<float>(layout.text_padding) - check_size;\n    const float check_y = item_rect.top + (item_rect.bottom - item_rect.top - check_size) / 2;\n    const D2D1_RECT_F check_rect =\n        D2D1::RectF(check_x, check_y, check_x + check_size, check_y + check_size);\n    surface.render_target->FillRectangle(check_rect, surface.indicator_brush);\n  }\n}\n\nauto draw_separator(Core::State::AppState& state, const D2D1_RECT_F& separator_rect) -> void {\n  const auto& surface = state.context_menu->main_surface;\n  surface.render_target->FillRectangle(separator_rect, surface.separator_brush);\n}\n\nauto paint_submenu(Core::State::AppState& state, const RECT& client_rect) -> void {\n  auto& menu_state = *state.context_menu;\n  auto& surface = menu_state.submenu_surface;\n  if (!surface.is_ready || !surface.render_target || !menu_state.text_format) {\n    return;\n  }\n\n  surface.render_target->BeginDraw();\n  surface.render_target->Clear(D2D1::ColorF(0.0f, 0.0f, 0.0f, 0.0f));\n\n  const auto rect_f = rect_to_d2d(client_rect);\n  draw_submenu_background(state, rect_f);\n  draw_submenu_items(state, rect_f);\n\n  HRESULT hr = surface.render_target->EndDraw();\n  if (FAILED(hr)) {\n    if (hr == D2DERR_RECREATE_TARGET) {\n      Logger().warn(\"Submenu render target needs recreation\");\n    } else {\n      Logger().error(\"Submenu paint error: 0x{:X}\", hr);\n    }\n    return;\n  }\n\n  present_surface(menu_state.submenu_hwnd, surface, menu_state.submenu_position);\n}\n\nauto draw_submenu_background(Core::State::AppState& state, const D2D1_RECT_F& rect) -> void {\n  const auto& surface = state.context_menu->submenu_surface;\n  surface.render_target->FillRectangle(rect, surface.background_brush);\n}\n\nauto draw_submenu_items(Core::State::AppState& state, const D2D1_RECT_F& rect) -> void {\n  const auto& menu_state = *state.context_menu;\n  const auto& layout = menu_state.layout;\n  const auto& current_submenu = menu_state.get_current_submenu();\n  float current_y = rect.top + static_cast<float>(layout.padding);\n  for (size_t i = 0; i < current_submenu.size(); ++i) {\n    const auto& item = current_submenu[i];\n    const bool is_hovered = static_cast<int>(i) == menu_state.interaction.submenu_hover_index;\n    if (item.type == Types::MenuItemType::Separator) {\n      const float separator_height = static_cast<float>(layout.separator_height);\n      const D2D1_RECT_F separator_rect = D2D1::RectF(\n          rect.left + static_cast<float>(layout.text_padding), current_y,\n          rect.right - static_cast<float>(layout.text_padding), current_y + separator_height);\n      draw_submenu_separator(state, separator_rect);\n      current_y += separator_height;\n    } else {\n      const float item_height = static_cast<float>(layout.item_height);\n      const D2D1_RECT_F item_rect =\n          D2D1::RectF(rect.left, current_y, rect.right, current_y + item_height);\n      draw_submenu_single_item(state, item, item_rect, is_hovered);\n      current_y += item_height;\n    }\n  }\n}\n\nauto draw_submenu_single_item(Core::State::AppState& state, const Types::MenuItem& item,\n                              const D2D1_RECT_F& item_rect, bool is_hovered) -> void {\n  const auto& menu_state = *state.context_menu;\n  const auto& surface = menu_state.submenu_surface;\n  const auto& layout = menu_state.layout;\n\n  if (is_hovered && item.is_enabled) {\n    surface.render_target->FillRectangle(item_rect, surface.hover_brush);\n  }\n\n  const D2D1_RECT_F text_rect =\n      D2D1::RectF(item_rect.left + static_cast<float>(layout.text_padding), item_rect.top,\n                  item_rect.right - static_cast<float>(layout.text_padding), item_rect.bottom);\n  ID2D1SolidColorBrush* text_brush = item.is_enabled ? surface.text_brush : surface.separator_brush;\n  surface.render_target->DrawText(item.text.c_str(), static_cast<UINT32>(item.text.length()),\n                                  menu_state.text_format, text_rect, text_brush);\n\n  if (item.is_checked) {\n    const float check_size = static_cast<float>(layout.font_size) * 0.8f;\n    const float check_x = item_rect.right - static_cast<float>(layout.text_padding) - check_size;\n    const float check_y = item_rect.top + (item_rect.bottom - item_rect.top - check_size) / 2;\n    const D2D1_RECT_F check_rect =\n        D2D1::RectF(check_x, check_y, check_x + check_size, check_y + check_size);\n    surface.render_target->FillRectangle(check_rect, surface.indicator_brush);\n  }\n}\n\nauto draw_submenu_separator(Core::State::AppState& state, const D2D1_RECT_F& separator_rect)\n    -> void {\n  const auto& surface = state.context_menu->submenu_surface;\n  surface.render_target->FillRectangle(separator_rect, surface.separator_brush);\n}\n\n}  // namespace UI::ContextMenu::Painter\n"
  },
  {
    "path": "src/ui/context_menu/painter.ixx",
    "content": "module;\n\n#include <d2d1.h>\n#include <windows.h>\n\nexport module UI.ContextMenu.Painter;\n\nimport std;\nimport Core.State;\nimport UI.FloatingWindow.Types;\nimport UI.ContextMenu.State;\nimport UI.ContextMenu.Types;\n\nexport namespace UI::ContextMenu::Painter {\n\nusing State::ContextMenuState;\n\n// 主菜单绘制\nauto paint_context_menu(Core::State::AppState& app_state, const RECT& client_rect) -> void;\n\n// 子菜单绘制\nauto paint_submenu(Core::State::AppState& app_state, const RECT& client_rect) -> void;\n\n// 内部绘制函数\nauto draw_menu_background(Core::State::AppState& app_state, const D2D1_RECT_F& rect) -> void;\nauto draw_menu_items(Core::State::AppState& app_state, const D2D1_RECT_F& rect) -> void;\nauto draw_single_menu_item(Core::State::AppState& app_state, const Types::MenuItem& item,\n                           const D2D1_RECT_F& item_rect, bool is_hovered) -> void;\nauto draw_separator(Core::State::AppState& app_state, const D2D1_RECT_F& separator_rect) -> void;\n\nauto draw_submenu_background(Core::State::AppState& app_state, const D2D1_RECT_F& rect) -> void;\nauto draw_submenu_items(Core::State::AppState& app_state, const D2D1_RECT_F& rect) -> void;\nauto draw_submenu_single_item(Core::State::AppState& app_state, const Types::MenuItem& item,\n                              const D2D1_RECT_F& item_rect, bool is_hovered) -> void;\nauto draw_submenu_separator(Core::State::AppState& app_state, const D2D1_RECT_F& separator_rect)\n    -> void;\n\n}  // namespace UI::ContextMenu::Painter\n"
  },
  {
    "path": "src/ui/context_menu/state.ixx",
    "content": "module;\n\nexport module UI.ContextMenu.State;\n\nimport std;\nimport UI.ContextMenu.Types;\nimport <d2d1.h>;\nimport <dwrite.h>;\nimport <windows.h>;\n\nexport namespace UI::ContextMenu::State {\n\nstruct RenderSurface {\n  ID2D1DCRenderTarget* render_target = nullptr;\n  HDC memory_dc = nullptr;\n  HBITMAP dib_bitmap = nullptr;\n  HGDIOBJ old_bitmap = nullptr;\n  void* bitmap_bits = nullptr;\n  SIZE bitmap_size{};\n\n  ID2D1SolidColorBrush* background_brush = nullptr;\n  ID2D1SolidColorBrush* text_brush = nullptr;\n  ID2D1SolidColorBrush* separator_brush = nullptr;\n  ID2D1SolidColorBrush* hover_brush = nullptr;\n  ID2D1SolidColorBrush* indicator_brush = nullptr;\n\n  bool is_ready = false;\n};\n\nstruct ContextMenuState {\n  // 窗口句柄\n  HWND hwnd = nullptr;\n  HWND submenu_hwnd = nullptr;\n\n  // D2D资源\n  RenderSurface main_surface;\n  RenderSurface submenu_surface;\n\n  // 独立的文本格式（DPI 缩放后的字号，不依赖浮窗）\n  IDWriteTextFormat* text_format = nullptr;\n\n  // 菜单数据和布局\n  std::vector<Types::MenuItem> items;\n  Types::LayoutConfig layout;\n  Types::InteractionState interaction;\n  SIZE menu_size{};\n  POINT position{};\n\n  // 子菜单状态\n  int submenu_parent_index = -1;\n  SIZE submenu_size{};\n  POINT submenu_position{};\n\n  // 获取当前子菜单项的安全访问方法\n  auto get_current_submenu() const -> const std::vector<Types::MenuItem>& {\n    if (submenu_parent_index >= 0 && submenu_parent_index < static_cast<int>(items.size()) &&\n        items[submenu_parent_index].has_submenu()) {\n      return items[submenu_parent_index].submenu_items;\n    }\n    static const std::vector<Types::MenuItem> empty_submenu;\n    return empty_submenu;\n  }\n};\n\n}  // namespace UI::ContextMenu::State\n"
  },
  {
    "path": "src/ui/context_menu/types.ixx",
    "content": "module;\n\n#include <d2d1.h>\n#include <windows.h>\n\n#include <iostream>\n\nexport module UI.ContextMenu.Types;\n\nimport std;\nimport Features.WindowControl;\nimport Features.Settings.Menu;\n\nexport namespace UI::ContextMenu::Types {\n\nenum class CursorZone { MainMenu, Submenu, Outside };\n\nenum class PendingIntentType { None, OpenSubmenu, SwitchSubmenu, HideSubmenu };\n\n// 菜单项类型枚举\nenum class MenuItemType {\n  Normal,     // 普通菜单项\n  Separator,  // 分隔线\n  Submenu     // 子菜单（暂时不实现）\n};\n\n// 菜单动作数据结构\nstruct RatioData {\n  size_t index;\n  std::wstring name;\n  double ratio;\n};\n\nstruct ResolutionData {\n  size_t index;\n  std::wstring name;\n  std::uint64_t total_pixels;\n};\n\n// 业务动作类型 - 类型安全的菜单动作表示\nstruct MenuAction {\n  enum class Type {\n    WindowSelection,      // 窗口选择\n    RatioSelection,       // 比例选择\n    ResolutionSelection,  // 分辨率选择\n    FeatureToggle,        // 功能开关\n    SystemCommand         // 系统命令\n  };\n\n  Type type;\n  std::any data;  // 存储具体的业务对象\n\n  // 便捷构造函数\n  static auto window_selection(const Features::WindowControl::WindowInfo& window) -> MenuAction {\n    return MenuAction{Type::WindowSelection, window};\n  }\n\n  static auto ratio_selection(size_t index, const std::wstring& name, double ratio) -> MenuAction {\n    return MenuAction{Type::RatioSelection, RatioData{index, name, ratio}};\n  }\n\n  static auto resolution_selection(size_t index, const std::wstring& name, std::uint64_t pixels)\n      -> MenuAction {\n    return MenuAction{Type::ResolutionSelection, ResolutionData{index, name, pixels}};\n  }\n\n  static auto feature_toggle(const std::string& action_id) -> MenuAction {\n    return MenuAction{Type::FeatureToggle, action_id};\n  }\n\n  static auto system_command(const std::string& command) -> MenuAction {\n    return MenuAction{Type::SystemCommand, command};\n  }\n};\n\n// 菜单项结构 - 重构为数据驱动设计\nstruct MenuItem {\n  std::wstring text;\n  MenuItemType type = MenuItemType::Normal;\n  bool is_checked = false;\n  bool is_enabled = true;\n\n  // 核心改进：使用业务动作而不是命令ID\n  std::optional<MenuAction> action;\n\n  // 子菜单支持\n  std::vector<MenuItem> submenu_items;\n\n  // 构造函数\n  MenuItem() = default;\n\n  // 基础文本菜单项\n  MenuItem(const std::wstring& text) : text(text) {}\n\n  // 带动作的菜单项\n  MenuItem(const std::wstring& text, MenuAction action) : text(text), action(std::move(action)) {}\n\n  // 带选中状态的菜单项\n  MenuItem(const std::wstring& text, MenuAction action, bool checked)\n      : text(text), is_checked(checked), action(std::move(action)) {}\n\n  // 分隔线构造函数\n  static auto separator() -> MenuItem {\n    MenuItem item;\n    item.type = MenuItemType::Separator;\n    return item;\n  }\n\n  // 便捷工厂方法\n  static auto window_item(const Features::WindowControl::WindowInfo& window) -> MenuItem {\n    return MenuItem(window.title, MenuAction::window_selection(window));\n  }\n\n  static auto ratio_item(const Features::Settings::Menu::RatioPreset& ratio, size_t index,\n                         bool selected = false) -> MenuItem {\n    return MenuItem(ratio.name, MenuAction::ratio_selection(index, ratio.name, ratio.ratio),\n                    selected);\n  }\n\n  static auto resolution_item(const Features::Settings::Menu::ResolutionPreset& resolution,\n                              size_t index, bool selected = false) -> MenuItem {\n    std::wstring display_text;\n    if (resolution.base_width == 0 && resolution.base_height == 0) {\n      display_text = resolution.name;\n    } else {\n      const double megaPixels = resolution.total_pixels / 1000000.0;\n      display_text = std::format(L\"{} ({:.1f}M)\", resolution.name, megaPixels);\n    }\n    return MenuItem(\n        display_text,\n        MenuAction::resolution_selection(index, resolution.name, resolution.total_pixels),\n        selected);\n  }\n\n  static auto feature_item(const std::wstring& text, const std::string& action_id,\n                           bool enabled = false) -> MenuItem {\n    return MenuItem(text, MenuAction::feature_toggle(action_id), enabled);\n  }\n\n  static auto system_item(const std::wstring& text, const std::string& command) -> MenuItem {\n    return MenuItem(text, MenuAction::system_command(command));\n  }\n\n  // 便捷方法\n  auto has_submenu() const -> bool { return !submenu_items.empty(); }\n  auto has_action() const -> bool { return action.has_value(); }\n};\n\n// 布局配置\nstruct LayoutConfig {\n  // 基础尺寸（96 DPI）\n  static constexpr int BASE_ITEM_HEIGHT = 28;\n  static constexpr int BASE_SEPARATOR_HEIGHT = 1;\n  static constexpr int BASE_PADDING = 8;\n  static constexpr int BASE_TEXT_PADDING = 12;\n  static constexpr int BASE_MIN_WIDTH = 140;\n  static constexpr int BASE_FONT_SIZE = 12;\n  static constexpr int BASE_BORDER_RADIUS = 6;\n\n  // DPI缩放后的尺寸\n  UINT dpi = 96;\n  int item_height = BASE_ITEM_HEIGHT;\n  int separator_height = BASE_SEPARATOR_HEIGHT;\n  int padding = BASE_PADDING;\n  int text_padding = BASE_TEXT_PADDING;\n  int min_width = BASE_MIN_WIDTH;\n  int font_size = BASE_FONT_SIZE;\n  int border_radius = BASE_BORDER_RADIUS;\n\n  auto update_dpi_scaling(UINT new_dpi) -> void {\n    dpi = new_dpi;\n    const double scale = static_cast<double>(new_dpi) / 96.0;\n    item_height = static_cast<int>(BASE_ITEM_HEIGHT * scale);\n    separator_height = static_cast<int>(BASE_SEPARATOR_HEIGHT * scale);\n    padding = static_cast<int>(BASE_PADDING * scale);\n    text_padding = static_cast<int>(BASE_TEXT_PADDING * scale);\n    min_width = static_cast<int>(BASE_MIN_WIDTH * scale);\n    font_size = static_cast<int>(BASE_FONT_SIZE * scale);\n    border_radius = static_cast<int>(BASE_BORDER_RADIUS * scale);\n  }\n};\n\n// 交互状态（状态机驱动，统一意图定时器）\nstruct InteractionState {\n  int hover_index = -1;\n  int submenu_hover_index = -1;\n  bool is_mouse_tracking = false;\n  CursorZone cursor_zone = CursorZone::Outside;\n  PendingIntentType pending_intent = PendingIntentType::None;\n  int pending_parent_index = -1;\n  UINT_PTR intent_timer_id = 0;\n\n  // 单一意图定时器\n  static constexpr UINT_PTR INTENT_TIMER_ID = 1;\n\n  // 延迟参数（毫秒）\n  static constexpr UINT OPEN_SUBMENU_DELAY = 200;\n  static constexpr UINT SWITCH_SUBMENU_DELAY = 200;\n  static constexpr UINT HIDE_SUBMENU_DELAY = 200;\n};\n\n}  // namespace UI::ContextMenu::Types\n"
  },
  {
    "path": "src/ui/floating_window/d2d_context.cpp",
    "content": "module;\n\nmodule UI.FloatingWindow.D2DContext;\n\nimport Core.State;\nimport UI.FloatingWindow.State;\nimport UI.FloatingWindow.Types;\nimport Features.Settings.State;\nimport <d2d1_3.h>;\nimport <dwrite_3.h>;\nimport <windows.h>;\n\nnamespace UI::FloatingWindow::D2DContext {\n\nconstexpr const char* kRecordingIndicatorColor = \"#ED4C4CFF\";\n\n// 辅助函数：从包含透明度的十六进制颜色字符串创建 D2D1_COLOR_F\nauto hex_with_alpha_to_color_f(const std::string& hex_color) -> D2D1_COLOR_F {\n  // 支持 #RRGGBBAA 格式，如果只有6位则默认透明度为FF\n  std::string color_str = hex_color;\n  if (color_str.starts_with(\"#\")) {\n    color_str = color_str.substr(1);\n  }\n\n  float r = 0.0f, g = 0.0f, b = 0.0f, a = 1.0f;\n\n  if (color_str.length() == 8) {  // #RRGGBBAA\n    r = std::stoi(color_str.substr(0, 2), nullptr, 16) / 255.0f;\n    g = std::stoi(color_str.substr(2, 2), nullptr, 16) / 255.0f;\n    b = std::stoi(color_str.substr(4, 2), nullptr, 16) / 255.0f;\n    a = std::stoi(color_str.substr(6, 2), nullptr, 16) / 255.0f;\n  } else if (color_str.length() == 6) {  // #RRGGBB\n    r = std::stoi(color_str.substr(0, 2), nullptr, 16) / 255.0f;\n    g = std::stoi(color_str.substr(2, 2), nullptr, 16) / 255.0f;\n    b = std::stoi(color_str.substr(4, 2), nullptr, 16) / 255.0f;\n    a = 1.0f;\n  }\n\n  return D2D1::ColorF(r, g, b, a);\n}\n\n// 辅助函数：从十六进制颜色字符串创建画刷\nauto create_brush_from_hex(ID2D1RenderTarget* target, const std::string& hex_color,\n                           ID2D1SolidColorBrush** brush) -> bool {\n  return SUCCEEDED(target->CreateSolidColorBrush(hex_with_alpha_to_color_f(hex_color), brush));\n}\n\n// 辅助函数：批量创建所有画刷\nauto create_all_brushes_simple(Core::State::AppState& state, UI::FloatingWindow::RenderContext& d2d)\n    -> bool {\n  const auto& colors = state.settings->raw.ui.floating_window_colors;\n\n  return create_brush_from_hex(d2d.render_target, colors.background, &d2d.background_brush) &&\n         create_brush_from_hex(d2d.render_target, colors.separator, &d2d.separator_brush) &&\n         create_brush_from_hex(d2d.render_target, colors.text, &d2d.text_brush) &&\n         create_brush_from_hex(d2d.render_target, colors.indicator, &d2d.indicator_brush) &&\n         create_brush_from_hex(d2d.render_target, kRecordingIndicatorColor,\n                               &d2d.recording_indicator_brush) &&\n         create_brush_from_hex(d2d.render_target, colors.title_bar, &d2d.title_brush) &&\n         create_brush_from_hex(d2d.render_target, colors.hover, &d2d.hover_brush) &&\n         create_brush_from_hex(d2d.render_target, colors.scroll_indicator,\n                               &d2d.scroll_indicator_brush);\n}\n\nauto clear_text_caches(UI::FloatingWindow::RenderContext& d2d) -> void {\n  for (auto& [_, text_format] : d2d.adjusted_text_formats) {\n    if (text_format) {\n      text_format->Release();\n    }\n  }\n  d2d.adjusted_text_formats.clear();\n  d2d.text_measure_cache.clear();\n}\n\n// 辅助函数：测量文本宽度\nauto measure_text_width(const std::wstring& text, IDWriteTextFormat* text_format,\n                        IDWriteFactory7* write_factory) -> float {\n  if (text.empty() || !text_format || !write_factory) {\n    return 0.0f;\n  }\n\n  // 创建文本布局\n  IDWriteTextLayout* text_layout = nullptr;\n  HRESULT hr =\n      write_factory->CreateTextLayout(text.c_str(), static_cast<UINT32>(text.length()), text_format,\n                                      10000.0f,  // 宽度（足够大以避免换行）\n                                      10000.0f,  // 高度\n                                      &text_layout);\n\n  if (FAILED(hr) || !text_layout) {\n    return 0.0f;\n  }\n\n  // 获取文本布局的度量信息\n  DWRITE_TEXT_METRICS metrics = {};\n  hr = text_layout->GetMetrics(&metrics);\n  text_layout->Release();\n\n  if (FAILED(hr)) {\n    return 0.0f;\n  }\n\n  return metrics.width;\n}\n\n// 更新所有画刷颜色\nauto update_all_brush_colors(Core::State::AppState& state) -> void {\n  const auto& colors = state.settings->raw.ui.floating_window_colors;\n  auto& d2d = state.floating_window->d2d_context;\n\n  // 更新画刷颜色\n  if (d2d.background_brush) {\n    d2d.background_brush->SetColor(hex_with_alpha_to_color_f(colors.background));\n  }\n  if (d2d.title_brush) {\n    d2d.title_brush->SetColor(hex_with_alpha_to_color_f(colors.title_bar));\n  }\n  if (d2d.separator_brush) {\n    d2d.separator_brush->SetColor(hex_with_alpha_to_color_f(colors.separator));\n  }\n  if (d2d.text_brush) {\n    d2d.text_brush->SetColor(hex_with_alpha_to_color_f(colors.text));\n  }\n  if (d2d.indicator_brush) {\n    d2d.indicator_brush->SetColor(hex_with_alpha_to_color_f(colors.indicator));\n  }\n  if (d2d.recording_indicator_brush) {\n    d2d.recording_indicator_brush->SetColor(hex_with_alpha_to_color_f(kRecordingIndicatorColor));\n  }\n  if (d2d.hover_brush) {\n    d2d.hover_brush->SetColor(hex_with_alpha_to_color_f(colors.hover));\n  }\n  if (d2d.scroll_indicator_brush) {\n    d2d.scroll_indicator_brush->SetColor(hex_with_alpha_to_color_f(colors.scroll_indicator));\n  }\n}\n\n// 创建具有指定字体大小的文本格式\nauto create_text_format_with_size(IDWriteFactory7* write_factory, float font_size)\n    -> IDWriteTextFormat* {\n  if (!write_factory) {\n    return nullptr;\n  }\n\n  IDWriteTextFormat* text_format = nullptr;\n  HRESULT hr = write_factory->CreateTextFormat(\n      L\"Microsoft YaHei\", nullptr, DWRITE_FONT_WEIGHT_NORMAL, DWRITE_FONT_STYLE_NORMAL,\n      DWRITE_FONT_STRETCH_NORMAL, font_size, L\"zh-CN\", &text_format);\n\n  if (FAILED(hr) || !text_format) {\n    return nullptr;\n  }\n\n  // 设置文本对齐方式\n  text_format->SetTextAlignment(DWRITE_TEXT_ALIGNMENT_LEADING);\n  text_format->SetParagraphAlignment(DWRITE_PARAGRAPH_ALIGNMENT_CENTER);\n\n  return text_format;\n}\n\n// 初始化Direct2D资源\nauto initialize_d2d(Core::State::AppState& state, HWND hwnd) -> bool {\n  auto& d2d = state.floating_window->d2d_context;\n\n  // 获取窗口大小\n  RECT rc;\n  GetClientRect(hwnd, &rc);\n  const int width = rc.right - rc.left;\n  const int height = rc.bottom - rc.top;\n\n  if (width <= 0 || height <= 0) {\n    return false;\n  }\n\n  // 清理现有资源\n  cleanup_d2d(state);\n\n  // 创建Direct2D 1.3工厂\n  HRESULT hr = D2D1CreateFactory(D2D1_FACTORY_TYPE_SINGLE_THREADED, __uuidof(ID2D1Factory7),\n                                 nullptr, reinterpret_cast<void**>(&d2d.factory));\n  if (FAILED(hr)) {\n    return false;\n  }\n\n  hr = DWriteCreateFactory(DWRITE_FACTORY_TYPE_SHARED, __uuidof(IDWriteFactory7),\n                           reinterpret_cast<IUnknown**>(&d2d.write_factory));\n  if (FAILED(hr)) {\n    cleanup_d2d(state);\n    return false;\n  }\n\n  // 创建内存DC和32位BGRA位图\n  HDC screen_dc = GetDC(nullptr);\n  d2d.memory_dc = CreateCompatibleDC(screen_dc);\n  ReleaseDC(nullptr, screen_dc);\n\n  if (!d2d.memory_dc) {\n    cleanup_d2d(state);\n    return false;\n  }\n\n  // 创建32位BGRA位图\n  BITMAPINFO bmi = {};\n  bmi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);\n  bmi.bmiHeader.biWidth = width;\n  bmi.bmiHeader.biHeight = -height;  // 负值表示从上到下\n  bmi.bmiHeader.biPlanes = 1;\n  bmi.bmiHeader.biBitCount = 32;\n  bmi.bmiHeader.biCompression = BI_RGB;\n\n  d2d.dib_bitmap =\n      CreateDIBSection(d2d.memory_dc, &bmi, DIB_RGB_COLORS, &d2d.bitmap_bits, nullptr, 0);\n  if (!d2d.dib_bitmap) {\n    cleanup_d2d(state);\n    return false;\n  }\n\n  d2d.old_bitmap = SelectObject(d2d.memory_dc, d2d.dib_bitmap);\n  d2d.bitmap_size = {width, height};\n\n  // 创建DC渲染目标\n  D2D1_RENDER_TARGET_PROPERTIES rtProps = D2D1::RenderTargetProperties(\n      D2D1_RENDER_TARGET_TYPE_DEFAULT,\n      D2D1::PixelFormat(DXGI_FORMAT_B8G8R8A8_UNORM, D2D1_ALPHA_MODE_PREMULTIPLIED), 0,\n      0,  // 使用默认DPI\n      D2D1_RENDER_TARGET_USAGE_NONE, D2D1_FEATURE_LEVEL_DEFAULT);\n\n  hr = d2d.factory->CreateDCRenderTarget(&rtProps, &d2d.render_target);\n  if (FAILED(hr)) {\n    cleanup_d2d(state);\n    return false;\n  }\n\n  // 绑定DC渲染目标到内存DC\n  RECT binding_rect = {0, 0, width, height};\n  hr = d2d.render_target->BindDC(d2d.memory_dc, &binding_rect);\n  if (FAILED(hr)) {\n    cleanup_d2d(state);\n    return false;\n  }\n\n  // 尝试获取设备上下文接口（Direct2D 1.3功能）\n  hr = d2d.render_target->QueryInterface(__uuidof(ID2D1DeviceContext6),\n                                         reinterpret_cast<void**>(&d2d.device_context));\n  // 注意：DC渲染目标可能不支持设备上下文，这是正常的\n  // 我们将在绘制时检查是否可用\n\n  // 创建所有画刷（使用简化的批量创建函数）\n  if (!create_all_brushes_simple(state, d2d)) {\n    cleanup_d2d(state);\n    return false;\n  }\n\n  // 创建文本格式\n  hr = d2d.write_factory->CreateTextFormat(L\"Microsoft YaHei\", nullptr, DWRITE_FONT_WEIGHT_NORMAL,\n                                           DWRITE_FONT_STYLE_NORMAL, DWRITE_FONT_STRETCH_NORMAL,\n                                           13.0f, L\"zh-CN\", &d2d.text_format);\n  if (FAILED(hr)) {\n    cleanup_d2d(state);\n    return false;\n  }\n\n  d2d.text_format->SetTextAlignment(DWRITE_TEXT_ALIGNMENT_LEADING);\n  d2d.text_format->SetParagraphAlignment(DWRITE_PARAGRAPH_ALIGNMENT_CENTER);\n\n  d2d.is_initialized = true;\n  return true;\n}\n\n// 清理Direct2D资源\nauto cleanup_d2d(Core::State::AppState& state) -> void {\n  auto& d2d = state.floating_window->d2d_context;\n  clear_text_caches(d2d);\n\n  // 释放画刷\n  if (d2d.background_brush) {\n    d2d.background_brush->Release();\n    d2d.background_brush = nullptr;\n  }\n  if (d2d.title_brush) {\n    d2d.title_brush->Release();\n    d2d.title_brush = nullptr;\n  }\n  if (d2d.separator_brush) {\n    d2d.separator_brush->Release();\n    d2d.separator_brush = nullptr;\n  }\n  if (d2d.text_brush) {\n    d2d.text_brush->Release();\n    d2d.text_brush = nullptr;\n  }\n  if (d2d.indicator_brush) {\n    d2d.indicator_brush->Release();\n    d2d.indicator_brush = nullptr;\n  }\n  if (d2d.recording_indicator_brush) {\n    d2d.recording_indicator_brush->Release();\n    d2d.recording_indicator_brush = nullptr;\n  }\n  if (d2d.hover_brush) {\n    d2d.hover_brush->Release();\n    d2d.hover_brush = nullptr;\n  }\n  if (d2d.scroll_indicator_brush) {\n    d2d.scroll_indicator_brush->Release();\n    d2d.scroll_indicator_brush = nullptr;\n  }\n\n  // 释放文本格式\n  if (d2d.text_format) {\n    d2d.text_format->Release();\n    d2d.text_format = nullptr;\n  }\n\n  // 释放DirectWrite工厂\n  if (d2d.write_factory) {\n    d2d.write_factory->Release();\n    d2d.write_factory = nullptr;\n  }\n\n  if (d2d.old_bitmap && d2d.memory_dc) {\n    SelectObject(d2d.memory_dc, d2d.old_bitmap);\n    d2d.old_bitmap = nullptr;\n  }\n  if (d2d.dib_bitmap) {\n    DeleteObject(d2d.dib_bitmap);\n    d2d.dib_bitmap = nullptr;\n  }\n  if (d2d.memory_dc) {\n    DeleteDC(d2d.memory_dc);\n    d2d.memory_dc = nullptr;\n  }\n\n  // 释放设备上下文\n  if (d2d.device_context) {\n    d2d.device_context->Release();\n    d2d.device_context = nullptr;\n  }\n\n  // 释放DC渲染目标\n  if (d2d.render_target) {\n    d2d.render_target->Release();\n    d2d.render_target = nullptr;\n  }\n\n  // 释放D2D 1.3工厂\n  if (d2d.factory) {\n    d2d.factory->Release();\n    d2d.factory = nullptr;\n  }\n\n  d2d.is_initialized = false;\n  d2d.needs_resize = false;\n}\n\n// 调整渲染目标大小\nauto resize_d2d(Core::State::AppState& state, const SIZE& new_size) -> bool {\n  auto& d2d = state.floating_window->d2d_context;\n\n  if (!d2d.is_initialized || !d2d.render_target) {\n    return false;\n  }\n\n  // 对于DC渲染目标，我们需要重新创建位图和重新绑定\n  if (d2d.bitmap_size.cx == new_size.cx && d2d.bitmap_size.cy == new_size.cy) {\n    // 大小没有改变，无需重新创建\n    d2d.needs_resize = false;\n    return true;\n  }\n\n  // 释放旧的位图\n  if (d2d.old_bitmap) {\n    SelectObject(d2d.memory_dc, d2d.old_bitmap);\n    d2d.old_bitmap = nullptr;\n  }\n  if (d2d.dib_bitmap) {\n    DeleteObject(d2d.dib_bitmap);\n    d2d.dib_bitmap = nullptr;\n    d2d.bitmap_bits = nullptr;\n  }\n\n  // 创建新的32位BGRA位图\n  BITMAPINFO bmi = {};\n  bmi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);\n  bmi.bmiHeader.biWidth = new_size.cx;\n  bmi.bmiHeader.biHeight = -new_size.cy;  // 负值表示从上到下\n  bmi.bmiHeader.biPlanes = 1;\n  bmi.bmiHeader.biBitCount = 32;\n  bmi.bmiHeader.biCompression = BI_RGB;\n\n  d2d.dib_bitmap =\n      CreateDIBSection(d2d.memory_dc, &bmi, DIB_RGB_COLORS, &d2d.bitmap_bits, nullptr, 0);\n  if (!d2d.dib_bitmap) {\n    return false;\n  }\n\n  d2d.old_bitmap = SelectObject(d2d.memory_dc, d2d.dib_bitmap);\n  d2d.bitmap_size = new_size;\n\n  // 重新绑定DC渲染目标\n  RECT binding_rect = {0, 0, new_size.cx, new_size.cy};\n  HRESULT hr = d2d.render_target->BindDC(d2d.memory_dc, &binding_rect);\n\n  if (SUCCEEDED(hr)) {\n    d2d.needs_resize = false;\n    return true;\n  }\n\n  return false;\n}\n\n// 更新文本格式（DPI变化时）\nauto update_text_format_if_needed(Core::State::AppState& state) -> bool {\n  auto& d2d = state.floating_window->d2d_context;\n  auto& layout = state.floating_window->layout;\n\n  // 如果不需要更新或工厂不可用，直接返回成功\n  if (!d2d.needs_font_update || !d2d.write_factory) {\n    return true;\n  }\n\n  // 释放旧的文本格式\n  if (d2d.text_format) {\n    d2d.text_format->Release();\n    d2d.text_format = nullptr;\n  }\n\n  // 创建新的文本格式（使用DPI缩放后的字体大小）\n  HRESULT hr =\n      d2d.write_factory->CreateTextFormat(L\"Microsoft YaHei\", nullptr, DWRITE_FONT_WEIGHT_NORMAL,\n                                          DWRITE_FONT_STYLE_NORMAL, DWRITE_FONT_STRETCH_NORMAL,\n                                          layout.font_size,  // 使用DPI缩放后的字体大小\n                                          L\"zh-CN\", &d2d.text_format);\n\n  if (SUCCEEDED(hr) && d2d.text_format) {\n    // 设置文本对齐方式\n    hr = d2d.text_format->SetTextAlignment(DWRITE_TEXT_ALIGNMENT_LEADING);\n    if (SUCCEEDED(hr)) {\n      hr = d2d.text_format->SetParagraphAlignment(DWRITE_PARAGRAPH_ALIGNMENT_CENTER);\n    }\n\n    if (SUCCEEDED(hr)) {\n      clear_text_caches(d2d);\n      d2d.needs_font_update = false;\n      return true;\n    }\n  }\n\n  // 创建失败，清理资源\n  if (d2d.text_format) {\n    d2d.text_format->Release();\n    d2d.text_format = nullptr;\n  }\n\n  return false;\n}\n\n}  // namespace UI::FloatingWindow::D2DContext\n"
  },
  {
    "path": "src/ui/floating_window/d2d_context.ixx",
    "content": "module;\n\n#include <d2d1_3.h>\n#include <dwrite_3.h>\n#include <windows.h>\n\nexport module UI.FloatingWindow.D2DContext;\n\nimport std;\nimport Core.State;\n\nnamespace UI::FloatingWindow::D2DContext {\n\n// 初始化Direct2D资源\nexport auto initialize_d2d(Core::State::AppState& state, HWND hwnd) -> bool;\n\n// 清理Direct2D资源\nexport auto cleanup_d2d(Core::State::AppState& state) -> void;\n\n// 调整渲染目标大小\nexport auto resize_d2d(Core::State::AppState& state, const SIZE& new_size) -> bool;\n\n// 更新文本格式（DPI变化时）\nexport auto update_text_format_if_needed(Core::State::AppState& state) -> bool;\n\n// 测量文本宽度\nexport auto measure_text_width(const std::wstring& text, IDWriteTextFormat* text_format,\n                               IDWriteFactory7* write_factory) -> float;\n\n// 创建具有指定字体大小的文本格式\nexport auto create_text_format_with_size(IDWriteFactory7* write_factory, float font_size)\n    -> IDWriteTextFormat*;\n\n// 更新所有画刷颜色\nexport auto update_all_brush_colors(Core::State::AppState& state) -> void;\n\n}  // namespace UI::FloatingWindow::D2DContext\n"
  },
  {
    "path": "src/ui/floating_window/events.ixx",
    "content": "module;\n\nexport module UI.FloatingWindow.Events;\n\nimport std;\nimport Vendor.Windows;\n\nnamespace UI::FloatingWindow::Events {\n\n// DPI改变事件\nexport struct DpiChangeEvent {\n  Vendor::Windows::UINT new_dpi;\n  Vendor::Windows::SIZE window_size;\n\n  std::chrono::steady_clock::time_point timestamp = std::chrono::steady_clock::now();\n};\n\n// 比例改变事件\nexport struct RatioChangeEvent {\n  size_t index;\n  std::wstring ratio_name;\n  double ratio_value;\n\n  std::chrono::steady_clock::time_point timestamp = std::chrono::steady_clock::now();\n};\n\n// 分辨率改变事件\nexport struct ResolutionChangeEvent {\n  size_t index;\n  std::wstring resolution_name;\n  std::uint64_t total_pixels;\n\n  std::chrono::steady_clock::time_point timestamp = std::chrono::steady_clock::now();\n};\n\n// 功能开关事件\nexport struct PreviewToggleEvent {\n  bool enabled;\n\n  std::chrono::steady_clock::time_point timestamp = std::chrono::steady_clock::now();\n};\n\nexport struct OverlayToggleEvent {\n  bool enabled;\n\n  std::chrono::steady_clock::time_point timestamp = std::chrono::steady_clock::now();\n};\n\nexport struct LetterboxToggleEvent {\n  bool enabled;\n\n  std::chrono::steady_clock::time_point timestamp = std::chrono::steady_clock::now();\n};\n\nexport struct RecordingToggleEvent {\n  bool enabled;\n\n  std::chrono::steady_clock::time_point timestamp = std::chrono::steady_clock::now();\n};\n\n// 窗口动作事件\nexport struct CaptureEvent {\n  std::chrono::steady_clock::time_point timestamp = std::chrono::steady_clock::now();\n};\n\nexport struct ScreenshotsEvent {\n  std::chrono::steady_clock::time_point timestamp = std::chrono::steady_clock::now();\n};\n\nexport struct HideEvent {\n  std::chrono::steady_clock::time_point timestamp = std::chrono::steady_clock::now();\n};\n\nexport struct ExitEvent {\n  std::chrono::steady_clock::time_point timestamp = std::chrono::steady_clock::now();\n};\n\nexport struct ToggleVisibilityEvent {\n  std::chrono::steady_clock::time_point timestamp = std::chrono::steady_clock::now();\n};\n\nexport struct WindowSelectionEvent {\n  std::wstring window_title;\n  Vendor::Windows::HWND window_handle;\n\n  std::chrono::steady_clock::time_point timestamp = std::chrono::steady_clock::now();\n};\n\n// 通知事件（用于跨线程安全地显示通知）\nexport struct NotificationEvent {\n  std::string title;\n  std::string message;\n\n  std::chrono::steady_clock::time_point timestamp = std::chrono::steady_clock::now();\n};\n\n}  // namespace UI::FloatingWindow::Events\n"
  },
  {
    "path": "src/ui/floating_window/floating_window.cpp",
    "content": "module;\n\nmodule UI.FloatingWindow;\n\nimport std;\nimport Features.Settings.Menu;\nimport Features.Settings.Types;\nimport Features.Settings.State;\nimport Core.Commands;\nimport Core.Commands.State;\nimport Core.Events;\nimport Core.State;\nimport Core.I18n.Types;\nimport Core.I18n.State;\nimport UI.FloatingWindow.Events;\nimport UI.FloatingWindow.MessageHandler;\nimport UI.FloatingWindow.Layout;\nimport UI.FloatingWindow.D2DContext;\nimport UI.FloatingWindow.Painter;\nimport UI.FloatingWindow.State;\nimport UI.FloatingWindow.Types;\nimport Utils.Logger;\nimport Utils.String;\nimport <dwmapi.h>;\nimport <windows.h>;\nimport <windowsx.h>;\n\nnamespace UI::FloatingWindow {\n\n// 用于前台窗口变化回调，用于 Windows 11 TopMost Z 序失效 workaround\nstatic Core::State::AppState* g_floating_window_for_topmost_hook = nullptr;\n\nstatic void CALLBACK topmost_refresh_win_event_proc(HWINEVENTHOOK /*hook*/, DWORD event, HWND hwnd,\n                                                    LONG /*idObject*/, LONG /*idChild*/,\n                                                    DWORD /*idEventThread*/,\n                                                    DWORD /*dwmsEventTime*/) {\n  if (event != EVENT_SYSTEM_FOREGROUND || !g_floating_window_for_topmost_hook) {\n    return;\n  }\n  auto& state = *g_floating_window_for_topmost_hook;\n  if (!state.floating_window->window.is_visible || !state.floating_window->window.hwnd) {\n    return;\n  }\n  // 当前台变为本窗口时，无需刷新\n  if (hwnd == state.floating_window->window.hwnd) {\n    return;\n  }\n  // 当前台为本进程的其他窗口（如上下文菜单、WebView）时，不刷新，避免覆盖自己的 UI\n  DWORD fg_pid = 0;\n  GetWindowThreadProcessId(hwnd, &fg_pid);\n  if (fg_pid != 0 && fg_pid == GetCurrentProcessId()) {\n    return;\n  }\n  // 当前台变为外部应用时，请求刷新置顶状态以恢复 Z 序\n  PostMessageW(state.floating_window->window.hwnd, UI::FloatingWindow::WM_REFRESH_TOPMOST, 0, 0);\n}\n\nauto create_window(Core::State::AppState& state) -> std::expected<void, std::string> {\n  // 获取系统DPI\n  UINT dpi = 96;\n  if (HDC hdc = GetDC(nullptr); hdc) {\n    dpi = GetDeviceCaps(hdc, LOGPIXELSX);\n    ReleaseDC(nullptr, hdc);\n  }\n\n  // 保存DPI到状态中\n  state.floating_window->window.dpi = dpi;\n\n  // 应用布局配置（基于应用状态）\n  UI::FloatingWindow::Layout::update_layout(state);\n\n  // 初始化菜单项\n  initialize_menu_items(state);\n\n  // 计算窗口尺寸和位置\n  const auto window_size = UI::FloatingWindow::Layout::calculate_window_size(state);\n  const auto window_pos = UI::FloatingWindow::Layout::calculate_center_position(window_size);\n\n  // 发送DPI改变事件来更新渲染状态\n  Core::Events::send(*state.events, UI::FloatingWindow::Events::DpiChangeEvent{dpi, window_size});\n\n  register_window_class(state.floating_window->window.instance);\n\n  state.floating_window->window.hwnd = CreateWindowExW(\n      WS_EX_LAYERED | WS_EX_TOOLWINDOW | WS_EX_TOPMOST, L\"SpinningMomoFloatingWindowClass\",\n      L\"SpinningMomo\", WS_POPUP | WS_CLIPCHILDREN, window_pos.x, window_pos.y, window_size.cx,\n      window_size.cy, nullptr, nullptr, state.floating_window->window.instance, &state);\n\n  if (!state.floating_window->window.hwnd) {\n    return std::unexpected(\"Failed to create window\");\n  }\n\n  // 允许低权限进程发送显示窗口的消息（绕过 UIPI 限制）\n  ChangeWindowMessageFilterEx(state.floating_window->window.hwnd, 0x8000 + 100, MSGFLT_ALLOW,\n                              nullptr);\n\n  // 保存窗口尺寸和位置\n  state.floating_window->window.size = window_size;\n  state.floating_window->window.position = window_pos;\n\n  // 创建窗口属性\n  create_window_attributes(state.floating_window->window.hwnd);\n\n  // 初始化Direct2D渲染\n  if (!UI::FloatingWindow::D2DContext::initialize_d2d(state, state.floating_window->window.hwnd)) {\n    Logger().error(\"Failed to initialize Direct2D rendering\");\n  }\n\n  return {};\n}\n\n// 标准化渲染触发机制\nauto request_repaint(Core::State::AppState& state) -> void {\n  if (state.floating_window->window.hwnd && state.floating_window->window.is_visible) {\n    InvalidateRect(state.floating_window->window.hwnd, nullptr, FALSE);\n  }\n}\n\nauto install_topmost_refresh_hook(Core::State::AppState& state) -> void {\n  auto& win = state.floating_window->window;\n  if (win.topmost_refresh_hook) {\n    return;  // 已安装\n  }\n  g_floating_window_for_topmost_hook = &state;\n  win.topmost_refresh_hook =\n      SetWinEventHook(EVENT_SYSTEM_FOREGROUND, EVENT_SYSTEM_FOREGROUND, nullptr,\n                      topmost_refresh_win_event_proc, 0, 0, WINEVENT_OUTOFCONTEXT);\n  if (!win.topmost_refresh_hook) {\n    Logger().warn(\"Failed to install topmost refresh hook for Windows 11 workaround\");\n    g_floating_window_for_topmost_hook = nullptr;\n  }\n}\n\nauto uninstall_topmost_refresh_hook(Core::State::AppState& state) -> void {\n  auto& win = state.floating_window->window;\n  if (win.topmost_refresh_hook) {\n    UnhookWinEvent(win.topmost_refresh_hook);\n    win.topmost_refresh_hook = nullptr;\n  }\n  if (g_floating_window_for_topmost_hook == &state) {\n    g_floating_window_for_topmost_hook = nullptr;\n  }\n}\n\nauto show_window(Core::State::AppState& state) -> void {\n  if (state.floating_window->window.hwnd) {\n    ShowWindow(state.floating_window->window.hwnd, SW_SHOWNA);\n    UpdateWindow(state.floating_window->window.hwnd);\n    state.floating_window->window.is_visible = true;\n\n    // Windows 11 TopMost Z 序失效 workaround：显示时安装前台变化监听\n    install_topmost_refresh_hook(state);\n\n    // 触发初始绘制（使用标准InvalidateRect机制）\n    request_repaint(state);\n  }\n}\n\nauto hide_window(Core::State::AppState& state) -> void {\n  if (state.floating_window->window.hwnd) {\n    uninstall_topmost_refresh_hook(state);\n    ShowWindow(state.floating_window->window.hwnd, SW_HIDE);\n    state.floating_window->window.is_visible = false;\n  }\n}\n\nauto toggle_visibility(Core::State::AppState& state) -> void {\n  if (state.floating_window->window.is_visible) {\n    hide_window(state);\n  } else {\n    show_window(state);\n  }\n}\n\nauto destroy_window(Core::State::AppState& state) -> void {\n  // 清理Direct2D资源\n  UI::FloatingWindow::D2DContext::cleanup_d2d(state);\n\n  if (state.floating_window->window.hwnd) {\n    uninstall_topmost_refresh_hook(state);\n    DestroyWindow(state.floating_window->window.hwnd);\n    state.floating_window->window.hwnd = nullptr;\n    state.floating_window->window.is_visible = false;\n  }\n}\n\nauto set_current_ratio(Core::State::AppState& state, size_t index) -> void {\n  state.floating_window->ui.current_ratio_index = index;\n  if (state.floating_window->window.hwnd) {\n    request_repaint(state);\n  }\n}\n\nauto set_current_resolution(Core::State::AppState& state, size_t index) -> void {\n  const auto& resolutions = Features::Settings::Menu::get_resolutions(*state.settings);\n  if (index < resolutions.size()) {\n    state.floating_window->ui.current_resolution_index = index;\n    if (state.floating_window->window.hwnd) {\n      request_repaint(state);\n    }\n  }\n}\n\nauto update_menu_items(Core::State::AppState& state) -> void {\n  state.floating_window->data.menu_items.clear();\n  initialize_menu_items(state);\n  if (state.floating_window->window.hwnd) {\n    request_repaint(state);\n  }\n}\n\nauto set_menu_items_to_show(Core::State::AppState& state, std::span<const std::wstring> items)\n    -> void {\n  state.floating_window->data.menu_items_to_show.assign(items.begin(), items.end());\n}\n\n// 内部辅助函数实现\n\n// 根据 i18n_key 获取本地化文本（扁平化版本）\nauto get_text_by_i18n_key(const std::string& i18n_key, const Core::I18n::Types::TextData& texts)\n    -> std::wstring {\n  auto it = texts.find(i18n_key);\n  if (it != texts.end()) {\n    return Utils::String::FromUtf8(it->second);\n  }\n  // Fallback: 返回 key 本身\n  return Utils::String::FromUtf8(i18n_key);\n}\n\nauto normalize_scroll_offsets(Core::State::AppState& state) -> void {\n  const size_t page_size = static_cast<size_t>(state.floating_window->layout.max_visible_rows);\n  if (page_size == 0) {\n    state.floating_window->ui.ratio_scroll_offset = 0;\n    state.floating_window->ui.resolution_scroll_offset = 0;\n    state.floating_window->ui.feature_scroll_offset = 0;\n    return;\n  }\n\n  size_t ratio_count = 0;\n  size_t resolution_count = 0;\n  size_t feature_count = 0;\n  for (const auto& item : state.floating_window->data.menu_items) {\n    switch (item.category) {\n      case UI::FloatingWindow::MenuItemCategory::AspectRatio:\n        ++ratio_count;\n        break;\n      case UI::FloatingWindow::MenuItemCategory::Resolution:\n        ++resolution_count;\n        break;\n      case UI::FloatingWindow::MenuItemCategory::Feature:\n        ++feature_count;\n        break;\n    }\n  }\n\n  const auto clamp_offset = [page_size](size_t& offset, size_t item_count) -> void {\n    if (item_count == 0) {\n      offset = 0;\n      return;\n    }\n    const size_t max_page = (item_count - 1) / page_size;\n    const size_t current_page = offset / page_size;\n    offset = std::min(current_page, max_page) * page_size;\n  };\n\n  auto& ui = state.floating_window->ui;\n  clamp_offset(ui.ratio_scroll_offset, ratio_count);\n  clamp_offset(ui.resolution_scroll_offset, resolution_count);\n  clamp_offset(ui.feature_scroll_offset, feature_count);\n}\n\nauto register_window_class(HINSTANCE instance) -> void {\n  WNDCLASSEXW wc{};\n  wc.cbSize = sizeof(WNDCLASSEXW);\n  wc.lpfnWndProc = MessageHandler::static_window_proc;\n  wc.hInstance = instance;\n  wc.lpszClassName = L\"SpinningMomoFloatingWindowClass\";\n  wc.hbrBackground = nullptr;\n  wc.style = CS_HREDRAW | CS_VREDRAW;\n  wc.hCursor = LoadCursorW(nullptr, IDC_ARROW);\n  RegisterClassExW(&wc);\n}\n\nauto initialize_menu_items(Core::State::AppState& state) -> void {\n  state.floating_window->data.menu_items.clear();\n\n  // 获取比例和分辨率预设\n  const auto& ratios = Features::Settings::Menu::get_ratios(*state.settings);\n  const auto& resolutions = Features::Settings::Menu::get_resolutions(*state.settings);\n\n  // 从配置获取功能项顺序\n  const auto& feature_config = state.settings->raw.ui.app_menu.features;\n  const auto& texts = state.i18n->texts;\n\n  // 添加比例选项\n  for (size_t i = 0; i < ratios.size(); ++i) {\n    state.floating_window->data.menu_items.emplace_back(\n        ratios[i].name, UI::FloatingWindow::MenuItemCategory::AspectRatio, static_cast<int>(i));\n  }\n\n  // 添加分辨率选项\n  for (size_t i = 0; i < resolutions.size(); ++i) {\n    const auto& preset = resolutions[i];\n    state.floating_window->data.menu_items.emplace_back(\n        preset.name, UI::FloatingWindow::MenuItemCategory::Resolution, static_cast<int>(i));\n  }\n\n  // 添加功能项（从命令注册表获取）\n  if (state.commands) {\n    for (size_t i = 0; i < feature_config.size(); ++i) {\n      const auto& command_id = feature_config[i];\n      // 从注册表获取命令描述\n      if (const auto* command = Core::Commands::get_command(state.commands->registry, command_id)) {\n        // 使用 i18n_key 获取文本\n        std::wstring text = get_text_by_i18n_key(command->i18n_key, texts);\n        state.floating_window->data.menu_items.emplace_back(\n            text, UI::FloatingWindow::MenuItemCategory::Feature, static_cast<int>(i), command_id);\n      } else {\n        Logger().warn(\"Command not found in registry: {}\", command_id);\n      }\n    }\n  }\n}\n\nauto create_window_attributes(HWND hwnd) -> void {\n  DWM_WINDOW_CORNER_PREFERENCE corner = DWMWCP_ROUNDSMALL;\n  DwmSetWindowAttribute(hwnd, DWMWA_WINDOW_CORNER_PREFERENCE, &corner, sizeof(corner));\n}\n\n// 设置变更响应实现\nauto refresh_from_settings(Core::State::AppState& state) -> void {\n  // 更新菜单项\n  update_menu_items(state);\n\n  // 更新布局配置（基于应用状态）\n  UI::FloatingWindow::Layout::update_layout(state);\n\n  // 行数配置变化后，确保翻页偏移仍落在有效页\n  normalize_scroll_offsets(state);\n\n  // 更新颜色配置\n  UI::FloatingWindow::D2DContext::update_all_brush_colors(state);\n\n  // 重新计算窗口大小\n  const auto new_size = UI::FloatingWindow::Layout::calculate_window_size(state);\n  if (state.floating_window->window.hwnd) {\n    // 调整窗口大小\n    SetWindowPos(state.floating_window->window.hwnd, nullptr, 0, 0, new_size.cx, new_size.cy,\n                 SWP_NOMOVE | SWP_NOZORDER);\n    state.floating_window->window.size = new_size;\n    // 日志输出大小\n    Logger().info(\"Window size updated: {}x{}\", new_size.cx, new_size.cy);\n  }\n\n  state.floating_window->d2d_context.needs_font_update = true;\n\n  // 请求重绘\n  request_repaint(state);\n}\n\n}  // namespace UI::FloatingWindow\n"
  },
  {
    "path": "src/ui/floating_window/floating_window.ixx",
    "content": "module;\n\nexport module UI.FloatingWindow;\n\nimport std;\nimport Core.State;\nimport UI.FloatingWindow.Layout;\nimport Vendor.Windows;\n\nnamespace UI::FloatingWindow {\n\n// 窗口创建和销毁\nexport auto create_window(Core::State::AppState& state) -> std::expected<void, std::string>;\nexport auto destroy_window(Core::State::AppState& state) -> void;\n\n// 窗口显示控制\nexport auto show_window(Core::State::AppState& state) -> void;\nexport auto hide_window(Core::State::AppState& state) -> void;\nexport auto toggle_visibility(Core::State::AppState& state) -> void;\n\n// 更新UI状态\nexport auto set_current_ratio(Core::State::AppState& state, size_t index) -> void;\nexport auto set_current_resolution(Core::State::AppState& state, size_t index) -> void;\n\n// 更新菜单项\nexport auto update_menu_items(Core::State::AppState& state) -> void;\nexport auto set_menu_items_to_show(Core::State::AppState& state,\n                                   std::span<const std::wstring> items) -> void;\n\n// 渲染触发\nexport auto request_repaint(Core::State::AppState& state) -> void;\n\n// 设置变更响应\nexport auto refresh_from_settings(Core::State::AppState& state) -> void;\n\n// 注册窗口类\nauto register_window_class(Vendor::Windows::HINSTANCE instance) -> void;\n\n// 初始化菜单项\nauto initialize_menu_items(Core::State::AppState& state) -> void;\n\n// 创建窗口样式和属性\nauto create_window_attributes(Vendor::Windows::HWND hwnd) -> void;\n\n}  // namespace UI::FloatingWindow"
  },
  {
    "path": "src/ui/floating_window/layout.cpp",
    "content": "module;\n\n#include <dwmapi.h>\n#include <windows.h>\n\n#include <string>\n\nmodule UI.FloatingWindow.Layout;\n\nimport std;\nimport Core.State;\nimport UI.FloatingWindow.State;\nimport Features.Settings.State;\nimport Features.Settings.Types;\nimport Utils.Logger;\n\nnamespace UI::FloatingWindow::Layout {\n\nauto update_layout(Core::State::AppState& state) -> void {\n  const auto& settings = state.settings->raw;\n  const auto& layout_settings = settings.ui.floating_window_layout;\n  const UINT dpi = state.floating_window->window.dpi;\n  const double scale = static_cast<double>(dpi) / 96.0;\n\n  auto& layout = state.floating_window->layout;\n\n  // 直接从配置计算实际渲染尺寸\n  layout.item_height = static_cast<int>(layout_settings.base_item_height * scale);\n  layout.title_height = static_cast<int>(layout_settings.base_title_height * scale);\n  layout.separator_height = static_cast<int>(layout_settings.base_separator_height * scale);\n  layout.font_size = static_cast<float>(layout_settings.base_font_size * scale);\n  layout.text_padding = static_cast<int>(layout_settings.base_text_padding * scale);\n  layout.indicator_width = static_cast<int>(layout_settings.base_indicator_width * scale);\n  layout.ratio_indicator_width =\n      static_cast<int>(layout_settings.base_ratio_indicator_width * scale);\n  layout.ratio_column_width = static_cast<int>(layout_settings.base_ratio_column_width * scale);\n  layout.resolution_column_width =\n      static_cast<int>(layout_settings.base_resolution_column_width * scale);\n  layout.settings_column_width =\n      static_cast<int>(layout_settings.base_settings_column_width * scale);\n  layout.scroll_indicator_width =\n      static_cast<int>(layout_settings.base_scroll_indicator_width * scale);\n  layout.max_visible_rows = std::max(layout_settings.max_visible_rows, 1);\n}\n\nauto calculate_window_size(const Core::State::AppState& state) -> SIZE {\n  const auto& render = state.floating_window->layout;\n  const int total_width =\n      render.ratio_column_width + render.resolution_column_width + render.settings_column_width;\n  const int window_height = calculate_window_height(state);\n\n  return {total_width, window_height};\n}\n\nauto calculate_window_height(const Core::State::AppState& state) -> int {\n  const auto& render = state.floating_window->layout;\n\n  // 翻页模式：返回固定高度\n  if (render.layout_mode == UI::FloatingWindow::MenuLayoutMode::Paged) {\n    return render.title_height + render.separator_height +\n           render.item_height * render.max_visible_rows;\n  }\n\n  // 自适应高度模式：由最大列决定高度\n  const auto counts = count_items_per_column(state.floating_window->data.menu_items);\n\n  // 计算每列的高度\n  const int ratio_height = counts.ratio_count * render.item_height;\n  const int resolution_height = counts.resolution_count * render.item_height;\n  const int settings_height = counts.settings_count * render.item_height;\n\n  // 找出最大高度\n  const int max_column_height = std::max({ratio_height, resolution_height, settings_height});\n\n  // 返回总高度\n  return render.title_height + render.separator_height + max_column_height;\n}\n\nauto calculate_center_position(const SIZE& window_size) -> POINT {\n  // 获取主显示器工作区\n  RECT work_area{};\n  if (!SystemParametersInfo(SPI_GETWORKAREA, 0, &work_area, 0)) {\n    return {0, 0};  // 失败时返回原点\n  }\n\n  // 计算窗口位置（屏幕中央）\n  const int x_pos = (work_area.right - work_area.left - window_size.cx) / 2;\n  const int y_pos = (work_area.bottom - work_area.top - window_size.cy) / 2;\n\n  return {x_pos, y_pos};\n}\n\nauto get_item_index_from_point(const Core::State::AppState& state, int x, int y) -> int {\n  const auto& render = state.floating_window->layout;\n  const auto& items = state.floating_window->data.menu_items;\n  const auto& ui = state.floating_window->ui;\n\n  // 检查是否在标题栏或分隔线区域\n  if (y < render.title_height + render.separator_height) {\n    return -1;\n  }\n\n  const auto bounds = get_column_bounds(state);\n\n  // 确定点击的是哪一列\n  UI::FloatingWindow::MenuItemCategory target_category;\n  size_t scroll_offset = 0;\n\n  if (x < bounds.ratio_column_right) {\n    target_category = UI::FloatingWindow::MenuItemCategory::AspectRatio;\n    if (render.layout_mode == UI::FloatingWindow::MenuLayoutMode::Paged) {\n      scroll_offset = ui.ratio_scroll_offset;\n    }\n  } else if (x < bounds.resolution_column_right) {\n    target_category = UI::FloatingWindow::MenuItemCategory::Resolution;\n    if (render.layout_mode == UI::FloatingWindow::MenuLayoutMode::Paged) {\n      scroll_offset = ui.resolution_scroll_offset;\n    }\n  } else {\n    // 设置列的特殊处理\n    return get_settings_item_index(state, y);\n  }\n\n  // 处理比例和分辨率列\n  size_t visible_index = 0;\n  int item_y = render.title_height + render.separator_height;\n\n  for (size_t i = 0; i < items.size(); ++i) {\n    const auto& item = items[i];\n    if (item.category == target_category) {\n      // 翻页模式下跳过不可见项\n      if (visible_index < scroll_offset) {\n        visible_index++;\n        continue;\n      }\n\n      if (y >= item_y && y < item_y + render.item_height) {\n        return static_cast<int>(i);\n      }\n      item_y += render.item_height;\n      visible_index++;\n    }\n  }\n\n  return -1;\n}\n\nauto count_items_per_column(const std::vector<UI::FloatingWindow::MenuItem>& items)\n    -> ColumnCounts {\n  ColumnCounts counts;\n\n  for (const auto& item : items) {\n    switch (item.category) {\n      case UI::FloatingWindow::MenuItemCategory::AspectRatio:\n        ++counts.ratio_count;\n        break;\n      case UI::FloatingWindow::MenuItemCategory::Resolution:\n        ++counts.resolution_count;\n        break;\n      case UI::FloatingWindow::MenuItemCategory::Feature:\n        ++counts.settings_count;\n        break;\n    }\n  }\n\n  return counts;\n}\n\nauto get_column_bounds(const Core::State::AppState& state) -> ColumnBounds {\n  const auto& render = state.floating_window->layout;\n  const int ratio_column_right = render.ratio_column_width;\n  const int resolution_column_right = ratio_column_right + render.resolution_column_width;\n  const int settings_column_left = resolution_column_right + render.separator_height;\n\n  return {ratio_column_right, resolution_column_right, settings_column_left};\n}\n\nauto get_settings_item_index(const Core::State::AppState& state, int y) -> int {\n  const auto& render = state.floating_window->layout;\n  const auto& items = state.floating_window->data.menu_items;\n  const auto& ui = state.floating_window->ui;\n\n  size_t scroll_offset = 0;\n  if (render.layout_mode == UI::FloatingWindow::MenuLayoutMode::Paged) {\n    scroll_offset = ui.feature_scroll_offset;\n  }\n\n  size_t visible_index = 0;\n  int settings_y = render.title_height + render.separator_height;\n\n  for (size_t i = 0; i < items.size(); ++i) {\n    const auto& item = items[i];\n\n    // 判断是否为功能项\n    if (item.category == UI::FloatingWindow::MenuItemCategory::Feature) {\n      // 翻页模式下跳过不可见项\n      if (visible_index < scroll_offset) {\n        visible_index++;\n        continue;\n      }\n\n      if (y >= settings_y && y < settings_y + render.item_height) {\n        return static_cast<int>(i);\n      }\n      settings_y += render.item_height;\n      visible_index++;\n    }\n  }\n  return -1;\n}\n\nauto get_indicator_width(const UI::FloatingWindow::MenuItem& item,\n                         const Core::State::AppState& state) -> int {\n  return (item.category == UI::FloatingWindow::MenuItemCategory::AspectRatio)\n             ? state.floating_window->layout.ratio_indicator_width\n             : state.floating_window->layout.indicator_width;\n}\n\n}  // namespace UI::FloatingWindow::Layout\n"
  },
  {
    "path": "src/ui/floating_window/layout.ixx",
    "content": "module;\n\n#include <dwmapi.h>\n#include <windows.h>\n\nexport module UI.FloatingWindow.Layout;\n\nimport std;\nimport Core.State;\nimport UI.FloatingWindow.State;\nimport UI.FloatingWindow.Types;\n\nnamespace UI::FloatingWindow::Layout {\n\n// 列计数结构\nexport struct ColumnCounts {\n  int ratio_count = 0;\n  int resolution_count = 0;\n  int settings_count = 0;\n};\n\n// 列边界结构\nexport struct ColumnBounds {\n  int ratio_column_right;\n  int resolution_column_right;\n  int settings_column_left;\n};\n\n// 更新布局配置\nexport auto update_layout(Core::State::AppState& state) -> void;\n\n// 计算窗口尺寸\nexport auto calculate_window_size(const Core::State::AppState& state) -> SIZE;\n\n// 计算窗口高度\nexport auto calculate_window_height(const Core::State::AppState& state) -> int;\n\n// 计算窗口居中位置\nexport auto calculate_center_position(const SIZE& window_size) -> POINT;\n\n// 从点击坐标获取菜单项索引\nexport auto get_item_index_from_point(const Core::State::AppState& state, int x, int y) -> int;\n\n// 计算每列的项目数量\nexport auto count_items_per_column(const std::vector<UI::FloatingWindow::MenuItem>& items)\n    -> ColumnCounts;\n\n// 获取列边界\nexport auto get_column_bounds(const Core::State::AppState& state) -> ColumnBounds;\n\n// 获取指示器宽度\nexport auto get_indicator_width(const UI::FloatingWindow::MenuItem& item,\n                                const Core::State::AppState& state) -> int;\n\n// 获取设置列中的项目索引\nauto get_settings_item_index(const Core::State::AppState& state, int y) -> int;\n\n}  // namespace UI::FloatingWindow::Layout"
  },
  {
    "path": "src/ui/floating_window/message_handler.cpp",
    "content": "module;\n\nmodule UI.FloatingWindow.MessageHandler;\n\nimport std;\nimport Features.Settings.Menu;\nimport Core.Commands;\nimport Core.Commands.State;\nimport Core.Events;\nimport Core.State;\nimport UI.FloatingWindow;\nimport UI.FloatingWindow.Events;\nimport UI.FloatingWindow.Layout;\nimport UI.FloatingWindow.Painter;\nimport UI.FloatingWindow.State;\nimport UI.FloatingWindow.Types;\nimport UI.TrayIcon;\nimport UI.TrayIcon.Types;\nimport UI.ContextMenu;\nimport UI.ContextMenu.Types;\nimport UI.FloatingWindow.D2DContext;\nimport Features.Notifications;\nimport Features.Notifications.Constants;\nimport Utils.Logger;\nimport <windows.h>;\nimport <windowsx.h>;\n\nnamespace UI::FloatingWindow::MessageHandler {\n\n// 统计指定列的项目数量\nauto count_column_items(const std::vector<UI::FloatingWindow::MenuItem>& items,\n                        UI::FloatingWindow::MenuItemCategory category) -> size_t {\n  size_t count = 0;\n  for (const auto& item : items) {\n    if (item.category == category) {\n      count++;\n    }\n  }\n  return count;\n}\n\n// 确保窗口能接收到WM_MOUSELEAVE消息\nauto ensure_mouse_tracking(HWND hwnd) -> void {\n  TRACKMOUSEEVENT tme{};\n  tme.cbSize = sizeof(TRACKMOUSEEVENT);\n  tme.dwFlags = TME_LEAVE;\n  tme.hwndTrack = hwnd;\n  TrackMouseEvent(&tme);\n}\n\n// 检查鼠标是否在关闭按钮上\nauto is_mouse_on_close_button(const Core::State::AppState& state, int x, int y) -> bool {\n  const auto& render = state.floating_window->layout;\n\n  // 计算按钮尺寸（正方形，与标题栏高度一致）\n  const int button_size = render.title_height;\n\n  // 计算按钮位置（右上角）\n  const int button_right = state.floating_window->window.size.cx;\n  const int button_left = button_right - button_size;\n  const int button_top = 0;\n  const int button_bottom = button_size;\n\n  return (x >= button_left && x <= button_right && y >= button_top && y <= button_bottom);\n}\n\n// 将菜单项点击转换为具体的高层应用事件\nauto dispatch_item_click_event(Core::State::AppState& state,\n                               const UI::FloatingWindow::MenuItem& item) -> void {\n  using namespace UI::FloatingWindow::Events;\n\n  switch (item.category) {\n    case UI::FloatingWindow::MenuItemCategory::AspectRatio: {\n      const auto& ratios = Features::Settings::Menu::get_ratios(*state.settings);\n      if (item.index >= 0 && static_cast<size_t>(item.index) < ratios.size()) {\n        const auto& ratio_preset = ratios[item.index];\n        Core::Events::send(*state.events, RatioChangeEvent{static_cast<size_t>(item.index),\n                                                           ratio_preset.name, ratio_preset.ratio});\n      }\n      break;\n    }\n    case UI::FloatingWindow::MenuItemCategory::Resolution: {\n      const auto& resolutions = Features::Settings::Menu::get_resolutions(*state.settings);\n      if (item.index >= 0 && static_cast<size_t>(item.index) < resolutions.size()) {\n        const auto& res_preset = resolutions[item.index];\n        Core::Events::send(*state.events, ResolutionChangeEvent{\n                                              static_cast<size_t>(item.index), res_preset.name,\n                                              res_preset.base_width *\n                                                  static_cast<uint64_t>(res_preset.base_height)});\n      }\n      break;\n    }\n    case UI::FloatingWindow::MenuItemCategory::Feature: {\n      // 通过注册表调用命令\n      if (state.commands) {\n        Core::Commands::invoke_command(state.commands->registry, item.action_id);\n      }\n      break;\n    }\n  }\n}\n\n// 处理热键，通过命令系统统一分发\nauto handle_hotkey_message(Core::State::AppState& state, WPARAM hotkey_id) -> void {\n  Logger().debug(\"WM_HOTKEY received, wParam={}\", hotkey_id);\n  Core::Commands::handle_hotkey(state, static_cast<int>(hotkey_id));\n}\n\n// 处理鼠标移出窗口，重置悬停状态并重绘\nauto handle_mouse_leave(Core::State::AppState& state) -> void {\n  // 重置悬停索引\n  state.floating_window->ui.hover_index = -1;\n\n  // 重置关闭按钮悬停状态\n  state.floating_window->ui.close_button_hovered = false;\n\n  // 重置 hovered_column\n  state.floating_window->ui.hovered_column = -1;\n\n  UI::FloatingWindow::request_repaint(state);\n}\n\n// 处理鼠标移动，更新悬停状态并重绘\nauto handle_mouse_move(Core::State::AppState& state, int x, int y) -> void {\n  const int new_hover_index = UI::FloatingWindow::Layout::get_item_index_from_point(state, x, y);\n  if (new_hover_index != state.floating_window->ui.hover_index) {\n    // 更新悬停索引\n    state.floating_window->ui.hover_index = new_hover_index;\n\n    UI::FloatingWindow::request_repaint(state);\n    ensure_mouse_tracking(state.floating_window->window.hwnd);\n  }\n\n  // 检查关闭按钮悬停状态\n  const bool close_hovered = is_mouse_on_close_button(state, x, y);\n  if (close_hovered != state.floating_window->ui.close_button_hovered) {\n    state.floating_window->ui.close_button_hovered = close_hovered;\n    UI::FloatingWindow::request_repaint(state);\n    ensure_mouse_tracking(state.floating_window->window.hwnd);\n  }\n\n  // 更新 hovered_column 状态\n  const auto& render = state.floating_window->layout;\n  const auto bounds = UI::FloatingWindow::Layout::get_column_bounds(state);\n\n  int new_hovered_column = -1;\n  if (y >= render.title_height + render.separator_height) {\n    if (x < bounds.ratio_column_right) {\n      new_hovered_column = 0;  // 比例列\n    } else if (x >= bounds.ratio_column_right + render.separator_height &&\n               x < bounds.resolution_column_right) {\n      new_hovered_column = 1;  // 分辨率列\n    } else if (x >= bounds.resolution_column_right + render.separator_height) {\n      new_hovered_column = 2;  // 功能列\n    }\n  }\n\n  if (new_hovered_column != state.floating_window->ui.hovered_column) {\n    state.floating_window->ui.hovered_column = new_hovered_column;\n    UI::FloatingWindow::request_repaint(state);\n  }\n}\n\n// 处理鼠标左键点击，分发项目点击事件\nauto handle_left_click(Core::State::AppState& state, int x, int y) -> void {\n  // 检查是否点击了关闭按钮\n  if (is_mouse_on_close_button(state, x, y)) {\n    // 发送隐藏事件而不是退出事件\n    Core::Events::send(*state.events, UI::FloatingWindow::Events::HideEvent{});\n    return;\n  }\n\n  const int clicked_index = UI::FloatingWindow::Layout::get_item_index_from_point(state, x, y);\n  if (clicked_index >= 0 &&\n      clicked_index < static_cast<int>(state.floating_window->data.menu_items.size())) {\n    const auto& item = state.floating_window->data.menu_items[clicked_index];\n    dispatch_item_click_event(state, item);\n  }\n}\n\n// 主窗口过程函数，负责将Windows消息翻译成应用程序事件\nauto window_procedure(Core::State::AppState& state, HWND hwnd, UINT msg, WPARAM wParam,\n                      LPARAM lParam) -> LRESULT {\n  switch (msg) {\n    case UI::TrayIcon::Types::WM_TRAYICON:\n      if (lParam == WM_RBUTTONUP || lParam == WM_LBUTTONUP) {\n        UI::TrayIcon::show_context_menu(state);\n      }\n      return 0;\n\n    case WM_HOTKEY:\n      handle_hotkey_message(state, wParam);\n      return 0;\n\n    case WM_DPICHANGED: {\n      const UINT dpi = HIWORD(wParam);\n      const auto window_size = UI::FloatingWindow::Layout::calculate_window_size(state);\n\n      // 发送DPI改变事件来更新渲染状态\n      Core::Events::send(*state.events,\n                         UI::FloatingWindow::Events::DpiChangeEvent{dpi, window_size});\n\n      return 0;\n    }\n\n    case WM_PAINT: {\n      PAINTSTRUCT ps{};\n      if (HDC hdc = BeginPaint(hwnd, &ps); hdc) {\n        RECT rect{};\n        GetClientRect(hwnd, &rect);\n        UI::FloatingWindow::Painter::paint(state, hwnd, rect);\n        EndPaint(hwnd, &ps);\n      }\n      return 0;\n    }\n\n    case WM_MOUSEMOVE: {\n      handle_mouse_move(state, GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam));\n      return 0;\n    }\n\n    case WM_MOUSELEAVE: {\n      handle_mouse_leave(state);\n      return 0;\n    }\n\n    case WM_LBUTTONDOWN: {\n      handle_left_click(state, GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam));\n      return 0;\n    }\n\n    case WM_MOUSEWHEEL: {\n      // 只在翻页模式下处理滚轮\n      if (state.floating_window->layout.layout_mode != UI::FloatingWindow::MenuLayoutMode::Paged) {\n        return 0;\n      }\n\n      // 将屏幕坐标转换为客户端坐标\n      POINT pt = {GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)};\n      ScreenToClient(hwnd, &pt);\n\n      // 判断鼠标在哪一列（排除分隔线区域）\n      const auto& render = state.floating_window->layout;\n      const auto bounds = UI::FloatingWindow::Layout::get_column_bounds(state);\n      const auto& items = state.floating_window->data.menu_items;\n      auto& ui = state.floating_window->ui;\n\n      size_t* target_offset = nullptr;\n      size_t column_item_count = 0;\n\n      if (pt.x < bounds.ratio_column_right) {\n        // 比例列\n        target_offset = &ui.ratio_scroll_offset;\n        column_item_count =\n            count_column_items(items, UI::FloatingWindow::MenuItemCategory::AspectRatio);\n      } else if (pt.x >= bounds.ratio_column_right + render.separator_height &&\n                 pt.x < bounds.resolution_column_right) {\n        // 分辨率列（排除第一条分隔线）\n        target_offset = &ui.resolution_scroll_offset;\n        column_item_count =\n            count_column_items(items, UI::FloatingWindow::MenuItemCategory::Resolution);\n      } else if (pt.x >= bounds.resolution_column_right + render.separator_height) {\n        // 功能列（排除第二条分隔线）\n        target_offset = &ui.feature_scroll_offset;\n        column_item_count =\n            count_column_items(items, UI::FloatingWindow::MenuItemCategory::Feature);\n      } else {\n        // 在分隔线上，不处理\n        return 0;\n      }\n\n      // 计算当前页号\n      const int page_size = render.max_visible_rows;\n      const int current_page = static_cast<int>(*target_offset) / page_size;\n\n      // 滚轮方向：向上滚-1页，向下滚+1页\n      const int delta = GET_WHEEL_DELTA_WPARAM(wParam);\n      const int page_delta = (delta > 0) ? -1 : 1;\n      const int new_page = current_page + page_delta;\n\n      // 计算总页数\n      const int total_pages = (static_cast<int>(column_item_count) + page_size - 1) / page_size;\n\n      // 限制页号范围并计算新的offset（必须是页大小的整数倍）\n      const int clamped_page = std::clamp(new_page, 0, std::max(0, total_pages - 1));\n      *target_offset = static_cast<size_t>(clamped_page * page_size);\n\n      UI::FloatingWindow::request_repaint(state);\n      return 0;\n    }\n\n    case WM_NCHITTEST: {\n      POINT pt{GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)};\n      ScreenToClient(hwnd, &pt);\n\n      // 检查是否在关闭按钮上\n      if (is_mouse_on_close_button(state, pt.x, pt.y)) {\n        return HTCLIENT;  // 关闭按钮区域不支持拖动\n      }\n\n      if (pt.y < state.floating_window->layout.title_height) {\n        return HTCAPTION;\n      }\n      return HTCLIENT;\n    }\n\n    case WM_RBUTTONUP: {\n      // 复用托盘菜单的逻辑来显示上下文菜单\n      UI::TrayIcon::show_context_menu(state);\n      return 0;\n    }\n\n    case WM_SIZE: {\n      SIZE new_size = {LOWORD(lParam), HIWORD(lParam)};\n      // 调整Direct2D渲染上下文以适应新的窗口大小\n      UI::FloatingWindow::D2DContext::resize_d2d(state, new_size);\n      return 0;\n    }\n\n    case WM_CLOSE:\n      Core::Events::send(*state.events, UI::FloatingWindow::Events::HideEvent{});\n      return 0;\n\n    case WM_DESTROY:\n      PostQuitMessage(0);\n      return 0;\n\n    // 自定义消息：另一个实例请求显示窗口\n    case 0x8000 + 100:  // WM_SPINNINGMOMO_SHOW\n      UI::FloatingWindow::show_window(state);\n      SetForegroundWindow(hwnd);\n      return 0;\n\n    // 处理异步事件队列 (WM_APP + 1)\n    case Core::Events::kWM_APP_PROCESS_EVENTS:\n      Core::Events::process_events(*state.events);\n      return 0;\n\n    // 处理通知动画定时器\n    case WM_TIMER:\n      if (wParam == Features::Notifications::Constants::ANIMATION_TIMER_ID) {\n        Features::Notifications::update_notifications(state);\n      }\n      return 0;\n\n    // Windows 11 TopMost Z 序失效 workaround：重新应用置顶以恢复视觉层级\n    case UI::FloatingWindow::WM_REFRESH_TOPMOST:\n      SetWindowPos(hwnd, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE);\n      return 0;\n  }\n  return DefWindowProc(hwnd, msg, wParam, lParam);\n}\n\n// 静态窗口过程函数，将窗口句柄与应用程序状态关联起来\nLRESULT CALLBACK static_window_proc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) {\n  Core::State::AppState* state = nullptr;\n\n  if (msg == WM_NCCREATE) {\n    const auto* cs = reinterpret_cast<CREATESTRUCT*>(lParam);\n    state = reinterpret_cast<Core::State::AppState*>(cs->lpCreateParams);\n    SetWindowLongPtr(hwnd, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(state));\n  } else {\n    state = reinterpret_cast<Core::State::AppState*>(GetWindowLongPtr(hwnd, GWLP_USERDATA));\n  }\n\n  if (state) {\n    return window_procedure(*state, hwnd, msg, wParam, lParam);\n  }\n\n  return DefWindowProc(hwnd, msg, wParam, lParam);\n}\n\n}  // namespace UI::FloatingWindow::MessageHandler\n"
  },
  {
    "path": "src/ui/floating_window/message_handler.ixx",
    "content": "module;\n\n#include <windows.h>\n\nexport module UI.FloatingWindow.MessageHandler;\n\nimport Core.State;\nimport UI.FloatingWindow.State;\n\nnamespace UI::FloatingWindow::MessageHandler {\n\nexport LRESULT CALLBACK static_window_proc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam);\n\n}  // namespace UI::FloatingWindow::MessageHandler"
  },
  {
    "path": "src/ui/floating_window/painter.cpp",
    "content": "module;\n\nmodule UI.FloatingWindow.Painter;\n\nimport std;\nimport Core.State;\nimport UI.FloatingWindow.Layout;\nimport UI.FloatingWindow.State;\nimport UI.FloatingWindow.Types;\nimport UI.FloatingWindow.D2DContext;\nimport Features.Settings.Menu;\nimport <d2d1_3.h>;\nimport <dwrite_3.h>;\nimport <windows.h>;\n\nnamespace UI::FloatingWindow::Painter {\n\n// 列数据结构：包含原始索引和项指针\nstruct ColumnItems {\n  std::vector<size_t> indices;                             // 在原数组中的索引（用于 hover 判断）\n  std::vector<const UI::FloatingWindow::MenuItem*> items;  // 项指针\n};\n\n// 列绘制参数\nstruct ColumnDrawParams {\n  float x_left;\n  float x_right;\n  size_t scroll_offset;\n  size_t max_visible;\n  bool is_paged;\n};\n\nconstexpr float kWidthCacheScale = 10.0f;\nconstexpr float kFontCacheScale = 100.0f;\nconstexpr size_t kMaxTextMeasureCacheEntries = 256;\nconstexpr size_t kMaxAdjustedFormatEntries = 32;\n\nauto to_cache_key(float value, float scale) -> int {\n  return static_cast<int>(std::lround(value * scale));\n}\n\nauto find_cached_font_key(const UI::FloatingWindow::RenderContext& d2d, std::wstring_view text,\n                          int width_key, int base_font_key) -> std::optional<int> {\n  for (const auto& entry : d2d.text_measure_cache) {\n    if (entry.width_key == width_key && entry.base_font_key == base_font_key &&\n        entry.text == text) {\n      return entry.resolved_font_key;\n    }\n  }\n  return std::nullopt;\n}\n\nauto store_text_measure_cache(UI::FloatingWindow::RenderContext& d2d, std::wstring_view text,\n                              int width_key, int base_font_key, int resolved_font_key) -> void {\n  if (d2d.text_measure_cache.size() >= kMaxTextMeasureCacheEntries) {\n    d2d.text_measure_cache.clear();\n  }\n\n  d2d.text_measure_cache.push_back(UI::FloatingWindow::TextMeasureCacheEntry{\n      .text = std::wstring(text),\n      .width_key = width_key,\n      .base_font_key = base_font_key,\n      .resolved_font_key = resolved_font_key,\n  });\n}\n\nauto get_or_create_adjusted_text_format(UI::FloatingWindow::RenderContext& d2d, int font_key)\n    -> IDWriteTextFormat* {\n  if (auto it = d2d.adjusted_text_formats.find(font_key); it != d2d.adjusted_text_formats.end()) {\n    return it->second;\n  }\n\n  if (d2d.adjusted_text_formats.size() >= kMaxAdjustedFormatEntries) {\n    for (auto& [_, text_format] : d2d.adjusted_text_formats) {\n      if (text_format) {\n        text_format->Release();\n      }\n    }\n    d2d.adjusted_text_formats.clear();\n  }\n\n  const float font_size = static_cast<float>(font_key) / kFontCacheScale;\n  auto* text_format =\n      UI::FloatingWindow::D2DContext::create_text_format_with_size(d2d.write_factory, font_size);\n  if (!text_format) {\n    return nullptr;\n  }\n\n  d2d.adjusted_text_formats.emplace(font_key, text_format);\n  return text_format;\n}\n\n// 按类别分组菜单项\nauto group_items_by_column(const std::vector<UI::FloatingWindow::MenuItem>& items)\n    -> std::tuple<ColumnItems, ColumnItems, ColumnItems> {\n  ColumnItems ratio, resolution, feature;\n\n  for (size_t i = 0; i < items.size(); ++i) {\n    switch (items[i].category) {\n      case UI::FloatingWindow::MenuItemCategory::AspectRatio:\n        ratio.indices.push_back(i);\n        ratio.items.push_back(&items[i]);\n        break;\n      case UI::FloatingWindow::MenuItemCategory::Resolution:\n        resolution.indices.push_back(i);\n        resolution.items.push_back(&items[i]);\n        break;\n      case UI::FloatingWindow::MenuItemCategory::Feature:\n        feature.indices.push_back(i);\n        feature.items.push_back(&items[i]);\n        break;\n    }\n  }\n\n  return {ratio, resolution, feature};\n}\n\n// 绘制单个列\nauto draw_single_column(Core::State::AppState& state, const D2D1_RECT_F& rect,\n                        const ColumnItems& column, const ColumnDrawParams& params) -> void {\n  const auto& render = state.floating_window->layout;\n  float y = rect.top + static_cast<float>(render.title_height + render.separator_height);\n\n  // 确定可见范围\n  const size_t start_index = params.is_paged ? params.scroll_offset : 0;\n  const size_t end_index = params.is_paged\n                               ? std::min(start_index + params.max_visible, column.items.size())\n                               : column.items.size();\n\n  // 绘制可见项\n  for (size_t i = start_index; i < end_index; ++i) {\n    const auto& item = *column.items[i];\n    const size_t original_index = column.indices[i];\n\n    D2D1_RECT_F item_rect = UI::FloatingWindow::make_d2d_rect(\n        params.x_left, y, params.x_right, y + static_cast<float>(render.item_height));\n\n    const bool is_hovered =\n        (static_cast<int>(original_index) == state.floating_window->ui.hover_index);\n    draw_single_item(state, item, item_rect, is_hovered);\n\n    y += static_cast<float>(render.item_height);\n  }\n}\n\n// 绘制滚动条指示器\nauto draw_scroll_indicator(const Core::State::AppState& state, const D2D1_RECT_F& column_rect,\n                           size_t total_items, size_t scroll_offset, bool is_hovered,\n                           bool is_last_column) -> void {\n  const auto& render = state.floating_window->layout;\n  if (!is_hovered || total_items <= static_cast<size_t>(render.max_visible_rows)) {\n    return;  // 不需要显示滚动条\n  }\n\n  const auto& d2d = state.floating_window->d2d_context;\n\n  // 计算轨道高度\n  const float track_height = static_cast<float>(render.item_height * render.max_visible_rows);\n  const float track_top =\n      column_rect.top + static_cast<float>(render.title_height + render.separator_height);\n\n  // 分页模式：计算总页数和当前页号\n  const int page_size = render.max_visible_rows;\n  const int total_pages = (static_cast<int>(total_items) + page_size - 1) / page_size;\n  const int current_page = static_cast<int>(scroll_offset) / page_size;\n\n  // 滑块高度 = 轨道高度 / 总页数\n  const float thumb_height = track_height / static_cast<float>(total_pages);\n\n  // 滑块位置：根据当前页号分布在轨道上\n  const float thumb_top =\n      (total_pages > 1)\n          ? track_top + (track_height - thumb_height) *\n                            (static_cast<float>(current_page) / static_cast<float>(total_pages - 1))\n          : track_top;\n\n  // 滚动条宽度和位置（与分隔线右边界对齐，最后一列除外）\n  const float indicator_width = static_cast<float>(render.scroll_indicator_width);\n  const float indicator_right =\n      is_last_column ? column_rect.right - 1.0f\n                     : column_rect.right + static_cast<float>(render.separator_height);\n  const float indicator_left = indicator_right - indicator_width;\n\n  // 绘制滑块\n  D2D1_RECT_F thumb_rect = UI::FloatingWindow::make_d2d_rect(\n      indicator_left, thumb_top, indicator_right, thumb_top + thumb_height);\n  d2d.render_target->FillRectangle(thumb_rect, d2d.scroll_indicator_brush);\n}\n\n// 主绘制函数实现\nauto paint(Core::State::AppState& state, HWND hwnd, const RECT& client_rect) -> void {\n  auto& d2d = state.floating_window->d2d_context;\n\n  if (!d2d.is_initialized || !d2d.render_target) {\n    return;\n  }\n\n  // 1. 先处理渲染目标resize（如果需要）\n  if (d2d.needs_resize) {\n    if (!UI::FloatingWindow::D2DContext::resize_d2d(\n            state, {client_rect.right - client_rect.left, client_rect.bottom - client_rect.top})) {\n      return;  // resize失败，无法继续绘制\n    }\n  }\n\n  // 2. 再处理字体更新（如果需要）\n  if (d2d.needs_font_update) {\n    if (!UI::FloatingWindow::D2DContext::update_text_format_if_needed(state)) {\n      return;  // 字体更新失败，无法继续绘制\n    }\n  }\n\n  if (d2d.is_rendering) {\n    return;\n  }\n\n  d2d.is_rendering = true;\n\n  d2d.render_target->BeginDraw();\n\n  // 清空背景为完全透明\n  d2d.render_target->Clear(D2D1::ColorF(0.0f, 0.0f, 0.0f, 0.0f));\n\n  // 全局设置替换混合模式，避免所有颜色叠加\n  if (d2d.device_context) {\n    d2d.device_context->SetPrimitiveBlend(D2D1_PRIMITIVE_BLEND_COPY);\n  }\n\n  const auto rect_f = UI::FloatingWindow::rect_to_d2d(client_rect);\n\n  // 4. 绘制各个部分\n  draw_background(state, rect_f);\n  draw_title_bar(state, rect_f);\n  draw_separators(state, rect_f);\n  draw_items(state, rect_f);\n\n  HRESULT hr = d2d.render_target->EndDraw();\n\n  // 处理设备丢失等错误\n  if (hr == D2DERR_RECREATE_TARGET) {\n    // 设备丢失，标记需要重新创建渲染目标\n    d2d.needs_resize = true;\n  }\n\n  d2d.is_rendering = false;\n\n  // 5. 更新分层窗口\n  if (SUCCEEDED(hr)) {\n    update_layered_window(state, hwnd);\n  }\n}\n\n// 绘制背景\nauto draw_background(const Core::State::AppState& state, const D2D1_RECT_F& rect) -> void {\n  const auto& d2d = state.floating_window->d2d_context;\n  // 使用半透明白色背景\n  d2d.render_target->FillRectangle(rect, d2d.background_brush);\n}\n\n// 绘制关闭按钮\nauto draw_close_button(const Core::State::AppState& state, const D2D1_RECT_F& title_rect) -> void {\n  const auto& d2d = state.floating_window->d2d_context;\n  const auto& render = state.floating_window->layout;\n\n  // 计算按钮尺寸（正方形，与标题栏高度一致）\n  const float button_size = static_cast<float>(render.title_height);\n\n  // 计算按钮位置（右上角）\n  const float x = title_rect.right - button_size;\n  const float y = title_rect.top;\n\n  // 创建按钮区域矩形\n  const D2D1_RECT_F button_rect = D2D1::RectF(x, y, x + button_size, y + button_size);\n\n  // 绘制悬停背景（如果需要）\n  if (state.floating_window->ui.close_button_hovered) {\n    d2d.render_target->FillRectangle(button_rect, d2d.hover_brush);\n  }\n\n  // 计算\"X\"图标尺寸和位置\n  const float icon_margin = button_size * 0.35f;  // 边距\n  const float icon_size = button_size - 2 * icon_margin;\n\n  const float icon_left = x + icon_margin;\n  const float icon_top = y + icon_margin;\n  const float icon_right = icon_left + icon_size;\n  const float icon_bottom = icon_top + icon_size;\n\n  // 绘制\"X\"图标\n  const float pen_width = 1.5f;\n  d2d.render_target->DrawLine(D2D1::Point2F(icon_left, icon_top),\n                              D2D1::Point2F(icon_right, icon_bottom), d2d.text_brush, pen_width,\n                              nullptr);\n\n  d2d.render_target->DrawLine(D2D1::Point2F(icon_right, icon_top),\n                              D2D1::Point2F(icon_left, icon_bottom), d2d.text_brush, pen_width,\n                              nullptr);\n}\n\n// 绘制标题栏\nauto draw_title_bar(const Core::State::AppState& state, const D2D1_RECT_F& rect) -> void {\n  const auto& d2d = state.floating_window->d2d_context;\n  const auto& render = state.floating_window->layout;\n\n  // 绘制标题栏背景\n  D2D1_RECT_F title_rect = UI::FloatingWindow::make_d2d_rect(\n      rect.left, rect.top, rect.right, rect.top + static_cast<float>(render.title_height));\n  d2d.render_target->FillRectangle(title_rect, d2d.title_brush);\n\n  // 绘制标题文本（保持完全不透明）\n  D2D1_RECT_F text_rect = UI::FloatingWindow::make_d2d_rect(\n      rect.left + static_cast<float>(render.text_padding), rect.top, rect.right,\n      rect.top + static_cast<float>(render.title_height));\n\n  d2d.render_target->DrawText(L\"SpinningMomo\",\n                              12,  // 文本长度\n                              d2d.text_format, text_rect, d2d.text_brush);\n\n  // 绘制关闭按钮\n  draw_close_button(state, title_rect);\n}\n\n// 绘制分隔线\nauto draw_separators(const Core::State::AppState& state, const D2D1_RECT_F& rect) -> void {\n  const auto& d2d = state.floating_window->d2d_context;\n  const auto& render = state.floating_window->layout;\n\n  // 使用简单的列边界计算\n  const auto bounds = UI::FloatingWindow::Layout::get_column_bounds(state);\n\n  // 绘制水平分隔线（使用半透明画刷）\n  D2D1_RECT_F h_sep_rect = UI::FloatingWindow::make_d2d_rect(\n      rect.left, rect.top + static_cast<float>(render.title_height), rect.right,\n      rect.top + static_cast<float>(render.title_height + render.separator_height));\n  d2d.render_target->FillRectangle(h_sep_rect, d2d.separator_brush);\n\n  // 绘制垂直分隔线1（使用半透明画刷）\n  D2D1_RECT_F v_sep_rect1 = UI::FloatingWindow::make_d2d_rect(\n      static_cast<float>(bounds.ratio_column_right),\n      rect.top + static_cast<float>(render.title_height),\n      static_cast<float>(bounds.ratio_column_right + render.separator_height), rect.bottom);\n  d2d.render_target->FillRectangle(v_sep_rect1, d2d.separator_brush);\n\n  // 绘制垂直分隔线2（使用半透明画刷）\n  D2D1_RECT_F v_sep_rect2 = UI::FloatingWindow::make_d2d_rect(\n      static_cast<float>(bounds.resolution_column_right),\n      rect.top + static_cast<float>(render.title_height),\n      static_cast<float>(bounds.resolution_column_right + render.separator_height), rect.bottom);\n  d2d.render_target->FillRectangle(v_sep_rect2, d2d.separator_brush);\n}\n\n// 绘制所有菜单项\nauto draw_items(Core::State::AppState& state, const D2D1_RECT_F& rect) -> void {\n  const auto& render = state.floating_window->layout;\n  const auto& ui = state.floating_window->ui;\n  const auto& items = state.floating_window->data.menu_items;\n  const auto bounds = UI::FloatingWindow::Layout::get_column_bounds(state);\n\n  // 按类别分组\n  auto [ratio_col, resolution_col, feature_col] = group_items_by_column(items);\n\n  const bool is_paged = (render.layout_mode == UI::FloatingWindow::MenuLayoutMode::Paged);\n  const size_t max_visible = static_cast<size_t>(render.max_visible_rows);\n\n  // 绘制比例列\n  draw_single_column(state, rect, ratio_col,\n                     {.x_left = rect.left,\n                      .x_right = static_cast<float>(bounds.ratio_column_right),\n                      .scroll_offset = ui.ratio_scroll_offset,\n                      .max_visible = max_visible,\n                      .is_paged = is_paged});\n\n  // 绘制分辨率列\n  draw_single_column(\n      state, rect, resolution_col,\n      {.x_left = static_cast<float>(bounds.ratio_column_right + render.separator_height),\n       .x_right = static_cast<float>(bounds.resolution_column_right),\n       .scroll_offset = ui.resolution_scroll_offset,\n       .max_visible = max_visible,\n       .is_paged = is_paged});\n\n  // 绘制功能列\n  draw_single_column(\n      state, rect, feature_col,\n      {.x_left = static_cast<float>(bounds.resolution_column_right + render.separator_height),\n       .x_right = rect.right,\n       .scroll_offset = ui.feature_scroll_offset,\n       .max_visible = max_visible,\n       .is_paged = is_paged});\n\n  // 绘制滚动条指示器（仅在翻页模式下）\n  if (is_paged) {\n    // 比例列滚动条\n    D2D1_RECT_F ratio_column_rect = UI::FloatingWindow::make_d2d_rect(\n        rect.left, rect.top, static_cast<float>(bounds.ratio_column_right), rect.bottom);\n    draw_scroll_indicator(state, ratio_column_rect, ratio_col.items.size(), ui.ratio_scroll_offset,\n                          ui.hovered_column == 0, false);\n\n    // 分辨率列滚动条\n    D2D1_RECT_F resolution_column_rect = UI::FloatingWindow::make_d2d_rect(\n        static_cast<float>(bounds.ratio_column_right + render.separator_height), rect.top,\n        static_cast<float>(bounds.resolution_column_right), rect.bottom);\n    draw_scroll_indicator(state, resolution_column_rect, resolution_col.items.size(),\n                          ui.resolution_scroll_offset, ui.hovered_column == 1, false);\n\n    // 功能列滚动条\n    D2D1_RECT_F feature_column_rect = UI::FloatingWindow::make_d2d_rect(\n        static_cast<float>(bounds.resolution_column_right + render.separator_height), rect.top,\n        rect.right, rect.bottom);\n    draw_scroll_indicator(state, feature_column_rect, feature_col.items.size(),\n                          ui.feature_scroll_offset, ui.hovered_column == 2, true);\n  }\n}\n\n// 绘制单个菜单项\nauto draw_single_item(Core::State::AppState& state, const UI::FloatingWindow::MenuItem& item,\n                      const D2D1_RECT_F& item_rect, bool is_hovered) -> void {\n  auto& d2d = state.floating_window->d2d_context;\n  const auto& render = state.floating_window->layout;\n  const int indicator_width = UI::FloatingWindow::Layout::get_indicator_width(item, state);\n\n  // 绘制悬停背景\n  if (is_hovered) {\n    d2d.render_target->FillRectangle(item_rect, d2d.hover_brush);\n  }\n\n  // 绘制选中指示器（保持完全不透明）\n  const bool is_selected = UI::FloatingWindow::State::is_item_selected(item, state);\n  if (is_selected) {\n    D2D1_RECT_F indicator_rect = UI::FloatingWindow::make_d2d_rect(\n        item_rect.left, item_rect.top, item_rect.left + static_cast<float>(indicator_width),\n        item_rect.bottom);\n    ID2D1SolidColorBrush* indicator_brush = d2d.indicator_brush;\n    if (item.category == UI::FloatingWindow::MenuItemCategory::Feature &&\n        item.action_id == \"recording.toggle\" && d2d.recording_indicator_brush) {\n      indicator_brush = d2d.recording_indicator_brush;\n    }\n    if (indicator_brush) {\n      d2d.render_target->FillRectangle(indicator_rect, indicator_brush);\n    }\n  }\n\n  // 绘制文本（保持完全不透明）\n  D2D1_RECT_F text_rect = UI::FloatingWindow::make_d2d_rect(\n      item_rect.left + static_cast<float>(render.text_padding + indicator_width), item_rect.top,\n      item_rect.right - static_cast<float>(render.text_padding / 2), item_rect.bottom);\n  const auto draw_default_text = [&]() -> void {\n    d2d.render_target->DrawText(item.text.c_str(), static_cast<UINT32>(item.text.length()),\n                                d2d.text_format, text_rect, d2d.text_brush);\n  };\n\n  // 计算可用于文本的宽度\n  const float available_width = text_rect.right - text_rect.left;\n\n  // 如果文本为空或宽度无效，则直接使用默认字体绘制\n  if (item.text.empty() || available_width <= 0.0f || !d2d.text_format || !d2d.write_factory) {\n    draw_default_text();\n    return;\n  }\n\n  const int width_key = to_cache_key(available_width, kWidthCacheScale);\n  const int base_font_key = to_cache_key(render.font_size, kFontCacheScale);\n\n  int resolved_font_key = base_font_key;\n  if (const auto cached_font_key = find_cached_font_key(d2d, item.text, width_key, base_font_key)) {\n    resolved_font_key = *cached_font_key;\n  } else {\n    float text_width = UI::FloatingWindow::D2DContext::measure_text_width(\n        item.text, d2d.text_format, d2d.write_factory);\n\n    if (text_width > available_width) {\n      float adjusted_font_size = render.font_size;\n\n      for (adjusted_font_size -= UI::FloatingWindow::LayoutConfig::FONT_SIZE_STEP;\n           adjusted_font_size >= UI::FloatingWindow::LayoutConfig::MIN_FONT_SIZE;\n           adjusted_font_size -= UI::FloatingWindow::LayoutConfig::FONT_SIZE_STEP) {\n        const float clamped_font_size =\n            std::max(adjusted_font_size, UI::FloatingWindow::LayoutConfig::MIN_FONT_SIZE);\n\n        const int adjusted_font_key = to_cache_key(clamped_font_size, kFontCacheScale);\n        auto* adjusted_text_format = get_or_create_adjusted_text_format(d2d, adjusted_font_key);\n        if (!adjusted_text_format) {\n          break;\n        }\n\n        text_width = UI::FloatingWindow::D2DContext::measure_text_width(\n            item.text, adjusted_text_format, d2d.write_factory);\n        if (text_width <= available_width) {\n          resolved_font_key = adjusted_font_key;\n          break;\n        }\n      }\n    }\n\n    store_text_measure_cache(d2d, item.text, width_key, base_font_key, resolved_font_key);\n  }\n\n  if (resolved_font_key == base_font_key) {\n    draw_default_text();\n    return;\n  }\n\n  if (auto* adjusted_text_format = get_or_create_adjusted_text_format(d2d, resolved_font_key)) {\n    d2d.render_target->DrawText(item.text.c_str(), static_cast<UINT32>(item.text.length()),\n                                adjusted_text_format, text_rect, d2d.text_brush);\n    return;\n  }\n\n  // 缓存命中但创建失败时，回退默认字体绘制\n  draw_default_text();\n}\n\n// UpdateLayeredWindow函数 - 将内存DC更新到分层窗口\nauto update_layered_window(const Core::State::AppState& state, HWND hwnd) -> void {\n  const auto& d2d = state.floating_window->d2d_context;\n\n  if (!d2d.memory_dc || !d2d.is_initialized) {\n    return;\n  }\n\n  // 配置Alpha混合\n  BLENDFUNCTION blend_func = {};\n  blend_func.BlendOp = AC_SRC_OVER;\n  blend_func.BlendFlags = 0;\n  blend_func.SourceConstantAlpha = 255;\n  blend_func.AlphaFormat = AC_SRC_ALPHA;\n\n  // 源点和窗口大小\n  POINT src_point = {0, 0};\n  SIZE window_size = d2d.bitmap_size;\n\n  // 更新分层窗口\n  UpdateLayeredWindow(hwnd,           // 目标窗口\n                      nullptr,        // 桌面DC（使用默认）\n                      nullptr,        // 窗口位置（不改变）\n                      &window_size,   // 窗口大小\n                      d2d.memory_dc,  // 源DC\n                      &src_point,     // 源起始点\n                      0,              // 颜色键（不使用）\n                      &blend_func,    // Alpha混合函数\n                      ULW_ALPHA       // 使用Alpha通道\n  );\n}\n\n}  // namespace UI::FloatingWindow::Painter\n"
  },
  {
    "path": "src/ui/floating_window/painter.ixx",
    "content": "module;\n\nexport module UI.FloatingWindow.Painter;\n\nimport std;\nimport Core.State;\nimport UI.FloatingWindow.Types;\nimport <d2d1_3.h>;\nimport <windows.h>;\n\nnamespace UI::FloatingWindow::Painter {\n\n// 内部函数声明\nauto draw_background(const Core::State::AppState& state, const D2D1_RECT_F& rect) -> void;\nauto draw_title_bar(const Core::State::AppState& state, const D2D1_RECT_F& rect) -> void;\nauto draw_separators(const Core::State::AppState& state, const D2D1_RECT_F& rect) -> void;\nauto draw_items(Core::State::AppState& state, const D2D1_RECT_F& rect) -> void;\nauto draw_single_item(Core::State::AppState& state, const UI::FloatingWindow::MenuItem& item,\n                      const D2D1_RECT_F& item_rect, bool is_hovered) -> void;\nauto draw_scroll_indicator(const Core::State::AppState& state, const D2D1_RECT_F& column_rect,\n                           size_t total_items, size_t scroll_offset, bool is_hovered) -> void;\n\n// 分层窗口更新函数\nexport auto update_layered_window(const Core::State::AppState& state, HWND hwnd) -> void;\n\n// 主绘制函数\nexport auto paint(Core::State::AppState& state, HWND hwnd, const RECT& client_rect) -> void;\n\n}  // namespace UI::FloatingWindow::Painter\n"
  },
  {
    "path": "src/ui/floating_window/state.ixx",
    "content": "module;\n\nexport module UI.FloatingWindow.State;\n\nimport std;\nimport Features.Settings.Menu;\nimport Core.Commands;\nimport Core.Commands.State;\nimport UI.FloatingWindow.Types;\nimport Core.State;\n\nexport namespace UI::FloatingWindow::State {\n\n// 主窗口聚合状态\nstruct FloatingWindowState {\n  FloatingWindow::WindowInfo window;\n  FloatingWindow::InteractionState ui;\n  FloatingWindow::DataState data;\n  FloatingWindow::LayoutConfig layout;\n  FloatingWindow::RenderContext d2d_context;  // 私有的D2D渲染上下文\n};\n\n// 辅助函数：判断菜单项是否选中\nauto is_item_selected(const FloatingWindow::MenuItem& item, const Core::State::AppState& app_state)\n    -> bool {\n  switch (item.category) {\n    case FloatingWindow::MenuItemCategory::AspectRatio:\n      return item.index == static_cast<int>(app_state.floating_window->ui.current_ratio_index);\n    case FloatingWindow::MenuItemCategory::Resolution:\n      return item.index == static_cast<int>(app_state.floating_window->ui.current_resolution_index);\n    case FloatingWindow::MenuItemCategory::Feature: {\n      // 从命令注册表查询状态\n      if (app_state.commands) {\n        if (const auto* command =\n                Core::Commands::get_command(app_state.commands->registry, item.action_id)) {\n          if (command->is_toggle && command->get_state) {\n            return command->get_state();\n          }\n        }\n      }\n      return false;\n    }\n    default:\n      return false;\n  }\n}\n\n}  // namespace UI::FloatingWindow::State\n"
  },
  {
    "path": "src/ui/floating_window/types.ixx",
    "content": "module;\n\nexport module UI.FloatingWindow.Types;\n\nimport std;\nimport Features.WindowControl;\nimport Features.Settings.Menu;\nimport <d2d1_3.h>;\nimport <dwrite_3.h>;\nimport <windows.h>;\n\nexport namespace UI::FloatingWindow {\n\n// 用于 Windows 11 TopMost Z 序失效 workaround 的自定义消息\nconstexpr UINT WM_REFRESH_TOPMOST = WM_USER + 10;\n\n// 菜单布局模式\nenum class MenuLayoutMode {\n  AutoHeight,  // 自适应高度：高度由最大列决定\n  Paged        // 翻页模式：固定高度 + 独立列翻页\n};\n\n// 菜单项类别枚举（简化版本）\nenum class MenuItemCategory { AspectRatio, Resolution, Feature };\n\n// 菜单项结构\nstruct MenuItem {\n  std::wstring text;\n  MenuItemCategory category;\n  int index;              // 在对应类别中的索引\n  std::string action_id;  // 仅 Feature 类别使用\n\n  // 构造函数\n  MenuItem(const std::wstring& t, MenuItemCategory cat, int idx, const std::string& action = \"\")\n      : text(t), category(cat), index(idx), action_id(action) {}\n};\n\n// 窗口系统状态\nstruct WindowInfo {\n  HWND hwnd = nullptr;\n  HINSTANCE instance = nullptr;\n  SIZE size{};\n  POINT position{};\n  UINT dpi = 96;\n  bool is_visible = false;\n  bool is_tracking_mouse = false;\n  HWINEVENTHOOK topmost_refresh_hook = nullptr;  // 用于 Windows 11 TopMost workaround\n};\n\n// UI交互状态\nstruct InteractionState {\n  int hover_index = -1;\n  size_t current_ratio_index = std::numeric_limits<size_t>::max();\n  size_t current_resolution_index = 0;\n  bool close_button_hovered = false;\n\n  // 翻页模式状态（仅 Paged 模式使用）\n  size_t ratio_scroll_offset = 0;\n  size_t resolution_scroll_offset = 0;\n  size_t feature_scroll_offset = 0;\n  int hovered_column = -1;  // -1: 无, 0: 比例列, 1: 分辨率列, 2: 功能列\n};\n\n// 数据状态（拥有或引用外部数据）\nstruct DataState {\n  std::vector<MenuItem> menu_items;  // 从settings计算生成的菜单项\n  std::vector<Features::WindowControl::WindowInfo> windows;\n  std::vector<std::wstring> menu_items_to_show;\n};\n\n// 渲染相关状态（实际渲染尺寸）\nstruct LayoutConfig {\n  // 实际渲染尺寸（基于DPI缩放和配置）\n  int item_height = 24;\n  int title_height = 26;\n  int separator_height = 1;\n  float font_size = 12.0f;  // 改为float，DirectWrite使用浮点数\n  int text_padding = 12;\n  int indicator_width = 3;\n  int ratio_indicator_width = 4;\n  int ratio_column_width = 60;\n  int resolution_column_width = 120;\n  int settings_column_width = 120;\n  int scroll_indicator_width = 2;  // 滚动条宽度\n  int max_visible_rows = 7;        // 翻页模式下每列最大可见行数，下限 1\n\n  // 字体大小调整相关常量\n  static constexpr float MIN_FONT_SIZE = 8.0f;   // 最小字体大小\n  static constexpr float FONT_SIZE_STEP = 0.5f;  // 字体大小调整步长\n\n  // 翻页模式配置\n  MenuLayoutMode layout_mode = MenuLayoutMode::Paged;\n};\n\n// 浮窗专用的Direct2D渲染状态\nstruct TextMeasureCacheEntry {\n  std::wstring text;\n  int width_key = 0;\n  int base_font_key = 0;\n  int resolved_font_key = 0;\n};\n\nstruct RenderContext {\n  // Direct2D 1.3资源句柄\n  ID2D1Factory7* factory = nullptr;               // Direct2D 1.3 工厂\n  ID2D1DCRenderTarget* render_target = nullptr;   // DC渲染目标（兼容性）\n  ID2D1DeviceContext6* device_context = nullptr;  // Direct2D 1.3 设备上下文\n  IDWriteFactory7* write_factory = nullptr;       // DirectWrite 1.3 工厂\n\n  // 内存DC和位图资源\n  HDC memory_dc = nullptr;\n  HBITMAP dib_bitmap = nullptr;\n  HGDIOBJ old_bitmap = nullptr;\n  void* bitmap_bits = nullptr;\n  SIZE bitmap_size = {0, 0};\n\n  // 缓存的画刷（简单的固定数组，避免动态分配）\n  ID2D1SolidColorBrush* background_brush = nullptr;\n  ID2D1SolidColorBrush* title_brush = nullptr;\n  ID2D1SolidColorBrush* separator_brush = nullptr;\n  ID2D1SolidColorBrush* text_brush = nullptr;\n  ID2D1SolidColorBrush* indicator_brush = nullptr;\n  ID2D1SolidColorBrush* recording_indicator_brush = nullptr;\n  ID2D1SolidColorBrush* hover_brush = nullptr;\n  ID2D1SolidColorBrush* scroll_indicator_brush = nullptr;  // 滚动条画刷\n\n  // 文本格式\n  IDWriteTextFormat* text_format = nullptr;\n  std::unordered_map<int, IDWriteTextFormat*> adjusted_text_formats;  // 按字号缓存文本格式\n  std::vector<TextMeasureCacheEntry> text_measure_cache;              // 文本测量结果缓存\n\n  // 状态标志\n  bool is_initialized = false;\n  bool is_rendering = false;\n  bool needs_resize = false;\n  bool needs_font_update = false;\n};\n\n// 辅助函数：将RECT转换为D2D1_RECT_F\ninline auto rect_to_d2d(const RECT& rect) -> D2D1_RECT_F {\n  return D2D1::RectF(static_cast<float>(rect.left), static_cast<float>(rect.top),\n                     static_cast<float>(rect.right), static_cast<float>(rect.bottom));\n}\n\n// 辅助函数：创建D2D矩形\ninline auto make_d2d_rect(float left, float top, float right, float bottom) -> D2D1_RECT_F {\n  return D2D1::RectF(left, top, right, bottom);\n}\n\n}  // namespace UI::FloatingWindow\n"
  },
  {
    "path": "src/ui/tray_icon/state.ixx",
    "content": "module;\r\n\r\nexport module UI.TrayIcon.State;\r\n\r\nimport Vendor.ShellApi;\r\n\r\nnamespace UI::TrayIcon::State {\r\n\r\nexport struct TrayIconState {\r\n  Vendor::ShellApi::NOTIFYICONDATAW nid{};\r\n  bool is_created = false;\r\n};\r\n\r\n}  // namespace UI::TrayIcon::State"
  },
  {
    "path": "src/ui/tray_icon/tray_icon.cpp",
    "content": "module;\n\n#include <windows.h>\n\n#include <string>\n\nmodule UI.TrayIcon;\n\nimport std;\nimport Core.State;\nimport Core.Commands;\nimport Core.Commands.State;\nimport Core.I18n.Types;\nimport Core.I18n.State;\nimport Features.Settings.Menu;\nimport Features.WindowControl;\nimport UI.ContextMenu;\nimport UI.ContextMenu.Types;\nimport UI.FloatingWindow.State;\nimport UI.TrayIcon.State;\nimport UI.TrayIcon.Types;\nimport Utils.String;\nimport Vendor.Windows;\nimport Vendor.ShellApi;\n\nnamespace {\n\nauto build_window_submenu(Core::State::AppState& state)\n    -> std::vector<UI::ContextMenu::Types::MenuItem> {\n  std::vector<UI::ContextMenu::Types::MenuItem> items;\n  const auto& texts = state.i18n->texts;\n  auto windows = Features::WindowControl::get_visible_windows();\n  for (const auto& window : windows) {\n    if (!window.title.empty() && window.title != L\"Program Manager\" &&\n        window.title.find(L\"SpinningMomo\") == std::wstring::npos) {\n      items.emplace_back(UI::ContextMenu::Types::MenuItem::window_item(window));\n    }\n  }\n  if (items.empty()) {\n    auto disabled_item = UI::ContextMenu::Types::MenuItem(\n        Utils::String::FromUtf8(texts.at(\"menu.window_no_available\")));\n    disabled_item.is_enabled = false;\n    items.emplace_back(std::move(disabled_item));\n  }\n  return items;\n}\n\nauto build_ratio_submenu(Core::State::AppState& state)\n    -> std::vector<UI::ContextMenu::Types::MenuItem> {\n  std::vector<UI::ContextMenu::Types::MenuItem> items;\n  const auto& ratios = Features::Settings::Menu::get_ratios(*state.settings);\n  for (size_t i = 0; i < ratios.size(); ++i) {\n    items.emplace_back(UI::ContextMenu::Types::MenuItem::ratio_item(\n        ratios[i], i, i == state.floating_window->ui.current_ratio_index));\n  }\n  return items;\n}\n\nauto build_resolution_submenu(Core::State::AppState& state)\n    -> std::vector<UI::ContextMenu::Types::MenuItem> {\n  std::vector<UI::ContextMenu::Types::MenuItem> items;\n  const auto& resolutions = Features::Settings::Menu::get_resolutions(*state.settings);\n  for (size_t i = 0; i < resolutions.size(); ++i) {\n    items.emplace_back(UI::ContextMenu::Types::MenuItem::resolution_item(\n        resolutions[i], i, i == state.floating_window->ui.current_resolution_index));\n  }\n  return items;\n}\n\nauto build_tray_menu_items(Core::State::AppState& state)\n    -> std::vector<UI::ContextMenu::Types::MenuItem> {\n  std::vector<UI::ContextMenu::Types::MenuItem> items;\n  const auto& texts = state.i18n->texts;\n\n  auto window_menu =\n      UI::ContextMenu::Types::MenuItem(Utils::String::FromUtf8(texts.at(\"menu.window_select\")));\n  window_menu.submenu_items = build_window_submenu(state);\n  items.emplace_back(std::move(window_menu));\n\n  items.emplace_back(UI::ContextMenu::Types::MenuItem::separator());\n\n  auto ratio_menu =\n      UI::ContextMenu::Types::MenuItem(Utils::String::FromUtf8(texts.at(\"menu.window_ratio\")));\n  ratio_menu.submenu_items = build_ratio_submenu(state);\n  items.emplace_back(std::move(ratio_menu));\n\n  auto resolution_menu =\n      UI::ContextMenu::Types::MenuItem(Utils::String::FromUtf8(texts.at(\"menu.window_resolution\")));\n  resolution_menu.submenu_items = build_resolution_submenu(state);\n  items.emplace_back(std::move(resolution_menu));\n\n  items.emplace_back(UI::ContextMenu::Types::MenuItem::separator());\n\n  items.emplace_back(UI::ContextMenu::Types::MenuItem::system_item(\n      Utils::String::FromUtf8(texts.at(\"menu.app_main\")), \"app.main\"));\n\n  items.emplace_back(UI::ContextMenu::Types::MenuItem::separator());\n\n  items.emplace_back(UI::ContextMenu::Types::MenuItem::feature_item(\n      state.floating_window->window.is_visible\n          ? Utils::String::FromUtf8(texts.at(\"menu.float_hide\"))\n          : Utils::String::FromUtf8(texts.at(\"menu.float_show\")),\n      \"app.float\"));\n\n  items.emplace_back(UI::ContextMenu::Types::MenuItem::system_item(\n      Utils::String::FromUtf8(texts.at(\"menu.app_exit\")), \"app.exit\"));\n\n  return items;\n}\n}  // anonymous namespace\n\nnamespace UI::TrayIcon {\n\nauto create(Core::State::AppState& state) -> std::expected<void, std::string> {\n  if (state.tray_icon->is_created) {\n    return {};\n  }\n  auto& nid = state.tray_icon->nid;\n  nid.cbSize = sizeof(decltype(nid));\n  nid.hWnd = state.floating_window->window.hwnd;\n  nid.uID = UI::TrayIcon::Types::HOTKEY_ID;\n  nid.uFlags =\n      Vendor::ShellApi::kNIF_ICON | Vendor::ShellApi::kNIF_MESSAGE | Vendor::ShellApi::kNIF_TIP;\n  nid.uCallbackMessage = UI::TrayIcon::Types::WM_TRAYICON;\n\n  nid.hIcon = static_cast<HICON>(LoadImageW(\n      state.floating_window->window.instance, MAKEINTRESOURCEW(UI::TrayIcon::Types::IDI_ICON1),\n      IMAGE_ICON, GetSystemMetrics(SM_CXSMICON), GetSystemMetrics(SM_CYSMICON), LR_DEFAULTCOLOR));\n  if (!nid.hIcon) nid.hIcon = LoadIcon(nullptr, IDI_APPLICATION);\n  if (!nid.hIcon) return std::unexpected(\"Failed to load tray icon.\");\n\n  const auto app_name = UI::TrayIcon::Types::APP_NAME;\n  const auto buffer_size = std::size(nid.szTip);\n  const auto copy_len = std::min(app_name.length(), buffer_size - 1);\n  app_name.copy(nid.szTip, copy_len);\n  nid.szTip[copy_len] = L'\\0';\n\n  if (!Vendor::ShellApi::Shell_NotifyIconW(Vendor::ShellApi::kNIM_ADD, &nid)) {\n    return std::unexpected(\"Failed to add tray icon to the shell.\");\n  }\n  state.tray_icon->is_created = true;\n  return {};\n}\n\nauto destroy(Core::State::AppState& state) -> void {\n  if (!state.tray_icon->is_created) {\n    return;\n  }\n  Vendor::ShellApi::Shell_NotifyIconW(Vendor::ShellApi::kNIM_DELETE, &state.tray_icon->nid);\n  if (state.tray_icon->nid.hIcon) {\n    DestroyIcon(state.tray_icon->nid.hIcon);\n    state.tray_icon->nid.hIcon = nullptr;\n  }\n  state.tray_icon->is_created = false;\n}\n\nauto show_context_menu(Core::State::AppState& state) -> void {\n  Vendor::Windows::POINT pt;\n  GetCursorPos(reinterpret_cast<POINT*>(&pt));\n\n  // Build the menu items and show the generic context menu\n  auto items = build_tray_menu_items(state);\n  UI::ContextMenu::Show(state, std::move(items), pt);\n}\n\n}  // namespace UI::TrayIcon\n"
  },
  {
    "path": "src/ui/tray_icon/tray_icon.ixx",
    "content": "module;\n\nexport module UI.TrayIcon;\n\nimport std;\nimport Core.State;\n\nnamespace UI::TrayIcon {\n\nexport auto create(Core::State::AppState& state) -> std::expected<void, std::string>;\n\nexport auto destroy(Core::State::AppState& state) -> void;\n\nexport auto show_context_menu(Core::State::AppState& state) -> void;\n\n}  // namespace UI::TrayIcon"
  },
  {
    "path": "src/ui/tray_icon/types.ixx",
    "content": "module;\n\nexport module UI.TrayIcon.Types;\n\nimport std;\nimport Vendor.Windows;\n\nnamespace UI::TrayIcon::Types {\n\n// 托盘图标相关常量\nexport constexpr Vendor::Windows::UINT WM_TRAYICON = Vendor::Windows::kWM_USER + 1;  // WM_USER + 1\nexport constexpr Vendor::Windows::UINT HOTKEY_ID = 1;\nexport constexpr int IDI_ICON1 = 101;\nexport const std::wstring APP_NAME = L\"SpinningMomo\";\n\n}  // namespace UI::TrayIcon::Types"
  },
  {
    "path": "src/ui/webview_window/webview_window.cpp",
    "content": "module;\n\nmodule UI.WebViewWindow;\n\nimport std;\nimport Core.State;\nimport Core.WebView;\nimport Core.WebView.State;\nimport Features.Settings;\nimport Features.Settings.State;\nimport Features.Settings.Types;\nimport UI.FloatingWindow.State;\nimport UI.TrayIcon.Types;\nimport Utils.Logger;\nimport Vendor.Windows;\nimport Vendor.ShellApi;\nimport Core.State.RuntimeInfo;\nimport Core.HttpServer.State;\nimport <dwmapi.h>;\nimport <windows.h>;\nimport <windowsx.h>;\n\nnamespace UI::WebViewWindow {\n\nauto is_transparent_background_enabled(Core::State::AppState& state) -> bool {\n  if (!state.settings) {\n    return false;\n  }\n  return state.settings->raw.ui.webview_window.enable_transparent_background;\n}\n\nauto desired_window_ex_style(Core::State::AppState& state) -> DWORD {\n  DWORD ex_style = WS_EX_APPWINDOW;\n  if (is_transparent_background_enabled(state)) {\n    ex_style |= WS_EX_NOREDIRECTIONBITMAP;\n  }\n  return ex_style;\n}\n\nauto apply_window_ex_style_from_settings(Core::State::AppState& state) -> void {\n  auto hwnd = state.webview->window.webview_hwnd;\n  if (!hwnd) {\n    return;\n  }\n\n  auto current_style = static_cast<DWORD>(GetWindowLongPtrW(hwnd, GWL_EXSTYLE));\n  auto updated_style = current_style;\n  if (is_transparent_background_enabled(state)) {\n    updated_style |= WS_EX_NOREDIRECTIONBITMAP;\n  } else {\n    updated_style &= ~WS_EX_NOREDIRECTIONBITMAP;\n  }\n\n  if (updated_style == current_style) {\n    return;\n  }\n\n  SetWindowLongPtrW(hwnd, GWL_EXSTYLE, static_cast<LONG_PTR>(updated_style));\n  SetWindowPos(hwnd, nullptr, 0, 0, 0, 0,\n               SWP_FRAMECHANGED | SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE);\n}\n\nauto default_window_style() -> DWORD { return WS_OVERLAPPEDWINDOW; }\n\nauto fullscreen_window_style(DWORD base_style) -> DWORD {\n  return base_style & ~(WS_THICKFRAME | WS_SYSMENU | WS_MAXIMIZEBOX | WS_MINIMIZEBOX);\n}\n\nauto get_window_rect_or_fallback(HWND hwnd, RECT fallback_rect) -> RECT {\n  RECT rect = fallback_rect;\n  GetWindowRect(hwnd, &rect);\n  return rect;\n}\n\nstruct WindowFrameInsets {\n  int x = 0;\n  int y = 0;\n};\n\nauto get_window_frame_insets_for_dpi(UINT dpi) -> WindowFrameInsets {\n  auto frame_x = GetSystemMetricsForDpi(SM_CXSIZEFRAME, dpi);\n  auto frame_y = GetSystemMetricsForDpi(SM_CYSIZEFRAME, dpi);\n  auto padded_border = GetSystemMetricsForDpi(SM_CXPADDEDBORDER, dpi);\n\n  return WindowFrameInsets{\n      .x = frame_x + padded_border,\n      .y = frame_y + padded_border,\n  };\n}\n\nauto send_window_state_changed_notification(Core::State::AppState& state) -> void {\n  auto payload = std::format(\n      R\"({{\"jsonrpc\":\"2.0\",\"method\":\"window.stateChanged\",\"params\":{{\"maximized\":{},\"fullscreen\":{}}}}})\",\n      state.webview->window.is_maximized ? \"true\" : \"false\",\n      state.webview->window.is_fullscreen ? \"true\" : \"false\");\n  Core::WebView::post_message(state, payload);\n}\n\nauto sync_window_state(Core::State::AppState& state, bool notify) -> void {\n  if (!state.webview) {\n    return;\n  }\n\n  auto& window = state.webview->window;\n  auto hwnd = window.webview_hwnd;\n  auto is_maximized = hwnd && IsZoomed(hwnd) == TRUE;\n\n  if (window.is_maximized == is_maximized) {\n    return;\n  }\n\n  window.is_maximized = is_maximized;\n  Logger().debug(\"WebView window maximize state changed: {}\", is_maximized);\n\n  if (notify) {\n    send_window_state_changed_notification(state);\n  }\n}\n\nauto should_paint_loading_background(Core::State::AppState* state) -> bool {\n  return state && state->webview && !state->webview->has_initial_content;\n}\n\nauto paint_loading_background(Core::State::AppState& state, HDC hdc, const RECT& rect) -> void {\n  auto background = CreateSolidBrush(Core::WebView::get_loading_background_color(state));\n  FillRect(hdc, &rect, background);\n  DeleteObject(background);\n}\n\nauto show(Core::State::AppState& state) -> std::expected<void, std::string> {\n  // 如果 WebView 还未初始化，则进行初始化\n  if (!state.webview->is_initialized) {\n    if (auto result = initialize(state); !result) {\n      return std::unexpected(result.error());\n    }\n  }\n\n  if (!state.webview->window.webview_hwnd) {\n    return std::unexpected(\"WebView window not created\");\n  }\n\n  ShowWindow(state.webview->window.webview_hwnd, SW_SHOW);\n  UpdateWindow(state.webview->window.webview_hwnd);\n  state.webview->window.is_visible = true;\n\n  Logger().info(\"WebView window shown\");\n  return {};\n}\n\nauto hide(Core::State::AppState& state) -> void {\n  if (state.webview->window.webview_hwnd) {\n    ShowWindow(state.webview->window.webview_hwnd, SW_HIDE);\n    state.webview->window.is_visible = false;\n    Logger().info(\"WebView window hidden\");\n  }\n}\n\nauto activate_window(Core::State::AppState& state) -> void {\n  if (state.runtime_info && !state.runtime_info->is_webview2_available) {\n    Logger().warn(\"WebView2 runtime is unavailable. Opening in browser.\");\n    std::string url = std::format(\"http://localhost:{}/\", state.http_server->port);\n    std::wstring wurl(url.begin(), url.end());  // URL is ASCII\n\n    Vendor::ShellApi::SHELLEXECUTEINFOW exec_info{.cbSize = sizeof(exec_info),\n                                                  .fMask = Vendor::ShellApi::kSEE_MASK_NOASYNC,\n                                                  .lpVerb = L\"open\",\n                                                  .lpFile = wurl.c_str(),\n                                                  .nShow = Vendor::ShellApi::kSW_SHOWNORMAL};\n    Vendor::ShellApi::ShellExecuteExW(&exec_info);\n    return;\n  }\n\n  if (auto result = show(state); !result) {\n    Logger().error(\"Failed to activate WebView window: {}\", result.error());\n    return;\n  }\n\n  auto hwnd = state.webview->window.webview_hwnd;\n  if (!hwnd) {\n    Logger().error(\"Failed to activate WebView window: WebView window not created\");\n    return;\n  }\n\n  if (IsIconic(hwnd)) {\n    ShowWindow(hwnd, SW_RESTORE);\n  } else {\n    ShowWindow(hwnd, SW_SHOW);\n  }\n\n  SetForegroundWindow(hwnd);\n  state.webview->window.is_visible = true;\n  Logger().info(\"WebView window activated\");\n}\n\n// Window control helpers\nauto minimize_window(Core::State::AppState& state) -> std::expected<void, std::string> {\n  auto& webview_state = *state.webview;\n\n  if (!webview_state.window.webview_hwnd) {\n    return std::unexpected(\"WebView window not created\");\n  }\n\n  ShowWindow(webview_state.window.webview_hwnd, SW_MINIMIZE);\n  Logger().debug(\"WebView window minimized\");\n  return {};\n}\n\nauto toggle_maximize_window(Core::State::AppState& state) -> std::expected<void, std::string> {\n  auto& webview_state = *state.webview;\n\n  if (!webview_state.window.webview_hwnd) {\n    return std::unexpected(\"WebView window not created\");\n  }\n\n  if (webview_state.window.is_fullscreen) {\n    if (auto result = set_fullscreen_window(state, false); !result) {\n      return result;\n    }\n  }\n\n  auto hwnd = webview_state.window.webview_hwnd;\n  auto was_maximized = IsZoomed(hwnd) == TRUE;\n  ShowWindow(hwnd, was_maximized ? SW_RESTORE : SW_MAXIMIZE);\n\n  Logger().debug(\"WebView window toggled maximize state\");\n  return {};\n}\n\nauto set_fullscreen_window(Core::State::AppState& state, bool fullscreen)\n    -> std::expected<void, std::string> {\n  auto& window = state.webview->window;\n  auto hwnd = window.webview_hwnd;\n  if (!hwnd) {\n    return std::unexpected(\"WebView window not created\");\n  }\n\n  if (window.is_fullscreen == fullscreen) {\n    return {};\n  }\n\n  if (fullscreen) {\n    window.fullscreen_restore_placement = WINDOWPLACEMENT{sizeof(WINDOWPLACEMENT)};\n    if (!GetWindowPlacement(hwnd, &window.fullscreen_restore_placement)) {\n      return std::unexpected(\"Failed to get WebView window placement\");\n    }\n\n    window.fullscreen_restore_style = static_cast<DWORD>(GetWindowLongPtrW(hwnd, GWL_STYLE));\n    window.has_fullscreen_restore_state = true;\n\n    HMONITOR monitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST);\n    MONITORINFO monitor_info = {sizeof(monitor_info)};\n    if (!GetMonitorInfoW(monitor, &monitor_info)) {\n      return std::unexpected(\"Failed to get monitor info for WebView window\");\n    }\n\n    SetWindowLongPtrW(\n        hwnd, GWL_STYLE,\n        static_cast<LONG_PTR>(fullscreen_window_style(window.fullscreen_restore_style)));\n    SetWindowPos(hwnd, HWND_TOPMOST, monitor_info.rcMonitor.left, monitor_info.rcMonitor.top,\n                 monitor_info.rcMonitor.right - monitor_info.rcMonitor.left,\n                 monitor_info.rcMonitor.bottom - monitor_info.rcMonitor.top,\n                 SWP_FRAMECHANGED | SWP_SHOWWINDOW);\n\n    window.is_fullscreen = true;\n    sync_window_state(state, false);\n    send_window_state_changed_notification(state);\n    Logger().debug(\"WebView window entered fullscreen\");\n    return {};\n  }\n\n  if (!window.has_fullscreen_restore_state) {\n    return std::unexpected(\"WebView fullscreen restore state is unavailable\");\n  }\n\n  SetWindowLongPtrW(hwnd, GWL_STYLE, static_cast<LONG_PTR>(window.fullscreen_restore_style));\n  SetWindowPos(hwnd, HWND_NOTOPMOST, 0, 0, 0, 0,\n               SWP_FRAMECHANGED | SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE);\n\n  if (!SetWindowPlacement(hwnd, &window.fullscreen_restore_placement)) {\n    return std::unexpected(\"Failed to restore WebView window placement\");\n  }\n\n  auto show_cmd = window.fullscreen_restore_placement.showCmd;\n  ShowWindow(hwnd, show_cmd == SW_SHOWMINIMIZED ? SW_RESTORE : show_cmd);\n\n  window.is_fullscreen = false;\n  window.has_fullscreen_restore_state = false;\n  sync_window_state(state, false);\n  send_window_state_changed_notification(state);\n  Logger().debug(\"WebView window exited fullscreen\");\n  return {};\n}\n\nauto close_window(Core::State::AppState& state) -> std::expected<void, std::string> {\n  auto& webview_state = *state.webview;\n\n  if (!webview_state.window.webview_hwnd) {\n    return std::unexpected(\"WebView window not created\");\n  }\n\n  // ??WM_CLOSE??????\n  PostMessage(webview_state.window.webview_hwnd, WM_CLOSE, 0, 0);\n  Logger().debug(\"WebView window close requested\");\n  return {};\n}\n\nauto window_proc(Vendor::Windows::HWND hwnd, Vendor::Windows::UINT msg,\n                 Vendor::Windows::WPARAM wparam, Vendor::Windows::LPARAM lparam)\n    -> Vendor::Windows::LRESULT {\n  Core::State::AppState* state = nullptr;\n\n  if (msg == WM_NCCREATE) {\n    // 获取创建参数中的状态指针\n    CREATESTRUCTW* cs = reinterpret_cast<CREATESTRUCTW*>(lparam);\n    state = static_cast<Core::State::AppState*>(cs->lpCreateParams);\n    SetWindowLongPtrW(hwnd, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(state));\n  } else {\n    // 从窗口数据中获取状态指针\n    state = reinterpret_cast<Core::State::AppState*>(GetWindowLongPtrW(hwnd, GWLP_USERDATA));\n  }\n\n  switch (msg) {\n    case Core::WebView::State::kWM_APP_BEGIN_RESIZE: {\n      if (!state || !state->webview || !state->webview->window.webview_hwnd) {\n        return 0;\n      }\n\n      auto resize_edge = static_cast<WPARAM>(wparam);\n      auto target_hwnd = state->webview->window.webview_hwnd;\n      if (state->webview->window.is_fullscreen || IsZoomed(target_hwnd) == TRUE) {\n        return 0;\n      }\n\n      ReleaseCapture();\n      SendMessageW(target_hwnd, WM_SYSCOMMAND, SC_SIZE | resize_edge, 0);\n      Logger().debug(\"WebView window entered deferred resize loop from edge code: {}\", resize_edge);\n      return 0;\n    }\n\n    // 处理虚拟主机映射协调请求，确保 WebView COM 调用在窗口线程中执行\n    case Core::WebView::State::kWM_APP_RECONCILE_VIRTUAL_HOST_MAPPINGS: {\n      if (!state || !state->webview || !state->webview->window.webview_hwnd) {\n        return 0;\n      }\n\n      Core::WebView::reconcile_virtual_host_folder_mappings(*state);\n      return 0;\n    }\n\n    case WM_GETMINMAXINFO: {\n      MINMAXINFO* mmi = reinterpret_cast<MINMAXINFO*>(lparam);\n\n      // 设置最小尺寸\n      mmi->ptMinTrackSize.x = 320;\n      mmi->ptMinTrackSize.y = 240;\n      return 0;\n    }\n\n    case WM_NCCALCSIZE: {\n      // 保留标准顶层窗口语义（最大化/还原动画、Snap 等），\n      // 同时移除系统默认标题栏和边框绘制，由 Web 头部承载标题栏内容。\n      if (state && state->webview && !state->webview->window.is_fullscreen && wparam == TRUE) {\n        auto* nc_calc_size_params = reinterpret_cast<NCCALCSIZE_PARAMS*>(lparam);\n\n        if (IsZoomed(hwnd) == TRUE) {\n          auto dpi = GetDpiForWindow(hwnd);\n          auto insets = get_window_frame_insets_for_dpi(dpi);\n\n          nc_calc_size_params->rgrc[0].left += insets.x;\n          nc_calc_size_params->rgrc[0].right -= insets.x;\n          nc_calc_size_params->rgrc[0].top += insets.y;\n          nc_calc_size_params->rgrc[0].bottom -= insets.y;\n        }\n\n        return 0;\n      }\n      break;\n    }\n\n    case WM_DPICHANGED: {\n      if (state) {\n        RECT* suggested_rect = reinterpret_cast<RECT*>(lparam);\n        SetWindowPos(hwnd, nullptr, suggested_rect->left, suggested_rect->top,\n                     suggested_rect->right - suggested_rect->left,\n                     suggested_rect->bottom - suggested_rect->top, SWP_NOZORDER | SWP_NOACTIVATE);\n        state->webview->window.x = suggested_rect->left;\n        state->webview->window.y = suggested_rect->top;\n      }\n      return 0;\n    }\n\n    case WM_NCHITTEST: {\n      if (state && Core::WebView::is_composition_active(*state)) {\n        if (auto non_client_hit = Core::WebView::hit_test_non_client_region(*state, hwnd, lparam)) {\n          return *non_client_hit;\n        }\n      }\n      return HTCLIENT;\n    }\n\n    case WM_SIZE: {\n      if (state) {\n        sync_window_state(*state, true);\n\n        if (wparam == SIZE_MINIMIZED) {\n          break;\n        }\n\n        int width = LOWORD(lparam);\n        int height = HIWORD(lparam);\n        state->webview->window.width = width;\n        state->webview->window.height = height;\n\n        // 如果WebView已经初始化，同步调整大小\n        if (state->webview->is_ready) {\n          // 调用WebView的resize函数来调整WebView控件大小\n          Core::WebView::resize_webview(*state, width, height);\n        }\n      }\n      break;\n    }\n\n    case WM_NCRBUTTONDOWN:\n    case WM_NCRBUTTONUP: {\n      if (state && Core::WebView::is_composition_active(*state) &&\n          Core::WebView::forward_non_client_right_button_message(*state, hwnd, msg, wparam,\n                                                                 lparam)) {\n        return 0;\n      }\n      break;\n    }\n\n    case WM_MOVE: {\n      if (state) {\n        int x = GET_X_LPARAM(lparam);\n        int y = GET_Y_LPARAM(lparam);\n        state->webview->window.x = x;\n        state->webview->window.y = y;\n      }\n      break;\n    }\n\n    case WM_ERASEBKGND: {\n      if (should_paint_loading_background(state)) {\n        if (auto hdc = reinterpret_cast<HDC>(wparam); hdc) {\n          RECT client_rect{};\n          GetClientRect(hwnd, &client_rect);\n          paint_loading_background(*state, hdc, client_rect);\n        }\n        return 1;\n      }\n\n      if (state && Core::WebView::is_composition_active(*state)) {\n        return 1;\n      }\n      break;\n    }\n\n    case WM_PAINT: {\n      if (should_paint_loading_background(state)) {\n        PAINTSTRUCT ps{};\n        auto hdc = BeginPaint(hwnd, &ps);\n        paint_loading_background(*state, hdc, ps.rcPaint);\n        EndPaint(hwnd, &ps);\n        return 0;\n      }\n      break;\n    }\n\n    case WM_SETFOCUS: {\n      if (state && state->webview->resources.controller) {\n        state->webview->resources.controller->MoveFocus(\n            COREWEBVIEW2_MOVE_FOCUS_REASON_PROGRAMMATIC);\n      }\n      break;\n    }\n\n    case WM_MOUSEMOVE:\n    case WM_MOUSEWHEEL:\n    case WM_MOUSEHWHEEL:\n    case WM_LBUTTONDOWN:\n    case WM_LBUTTONUP:\n    case WM_LBUTTONDBLCLK:\n    case WM_RBUTTONDOWN:\n    case WM_RBUTTONUP:\n    case WM_RBUTTONDBLCLK:\n    case WM_MBUTTONDOWN:\n    case WM_MBUTTONUP:\n    case WM_MBUTTONDBLCLK:\n    case WM_XBUTTONDOWN:\n    case WM_XBUTTONUP:\n    case WM_XBUTTONDBLCLK: {\n      if (state && Core::WebView::is_composition_active(*state)) {\n        Core::WebView::forward_mouse_message(*state, hwnd, msg, wparam, lparam);\n      }\n      break;\n    }\n\n    case WM_CLOSE: {\n      if (state) {\n        cleanup(*state);\n        return 0;\n      }\n      break;\n    }\n\n    case WM_DESTROY: {\n      if (state) {\n        state->webview->window.is_visible = false;\n      }\n      break;\n    }\n  }\n\n  return DefWindowProcW(hwnd, msg, wparam, lparam);\n}\n\nauto register_window_class(Vendor::Windows::HINSTANCE instance) -> void {\n  WNDCLASSEXW wc{};\n  wc.cbSize = sizeof(WNDCLASSEXW);\n  wc.lpfnWndProc = window_proc;\n  wc.hInstance = instance;\n  wc.lpszClassName = L\"SpinningMomoWebViewWindowClass\";\n  wc.hbrBackground = nullptr;\n  wc.style = CS_HREDRAW | CS_VREDRAW;\n  wc.hCursor = LoadCursorW(nullptr, IDC_ARROW);\n  // 大图标：Alt+Tab、窗口标题栏等\n  wc.hIcon = static_cast<HICON>(\n      LoadImageW(instance, MAKEINTRESOURCEW(UI::TrayIcon::Types::IDI_ICON1), IMAGE_ICON,\n                 GetSystemMetrics(SM_CXICON), GetSystemMetrics(SM_CYICON), LR_DEFAULTCOLOR));\n  if (!wc.hIcon) wc.hIcon = LoadIconW(nullptr, IDI_APPLICATION);\n\n  // 小图标：任务栏\n  wc.hIconSm = static_cast<HICON>(\n      LoadImageW(instance, MAKEINTRESOURCEW(UI::TrayIcon::Types::IDI_ICON1), IMAGE_ICON,\n                 GetSystemMetrics(SM_CXSMICON), GetSystemMetrics(SM_CYSMICON), LR_DEFAULTCOLOR));\n  if (!wc.hIconSm) wc.hIconSm = LoadIconW(nullptr, IDI_APPLICATION);\n\n  RegisterClassExW(&wc);\n}\n\nauto apply_window_style(HWND hwnd) -> void {\n  // 设置 Win11 圆角样式\n  DWM_WINDOW_CORNER_PREFERENCE corner = DWMWCP_ROUND;\n  DwmSetWindowAttribute(hwnd, DWMWA_WINDOW_CORNER_PREFERENCE, &corner, sizeof(corner));\n\n  // 强制让 DWM/窗口样式立即生效\n  SetWindowPos(hwnd, nullptr, 0, 0, 0, 0,\n               SWP_FRAMECHANGED | SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE);\n}\n\nauto create(Core::State::AppState& state) -> std::expected<void, std::string> {\n  // 注册窗口类\n  register_window_class(state.floating_window->window.instance);\n\n  auto style = default_window_style();\n  auto ex_style = desired_window_ex_style(state);\n\n  // 从设置读取持久化的尺寸和位置，位置居中\n  int width = state.settings->raw.ui.webview_window.width;\n  int height = state.settings->raw.ui.webview_window.height;\n  int x = state.settings->raw.ui.webview_window.x;\n  int y = state.settings->raw.ui.webview_window.y;\n\n  // 首次启动时将默认 96 DPI 逻辑客户区尺寸缩放到当前系统 DPI，\n  // 再换算为窗口外框尺寸，避免高缩放下初始窗口看起来过小。\n  if (x < 0 || y < 0) {\n    UINT dpi = GetDpiForSystem();\n    RECT desired_client_rect = {0, 0, MulDiv(width, dpi, 96), MulDiv(height, dpi, 96)};\n    if (AdjustWindowRectExForDpi(&desired_client_rect, style, FALSE, ex_style, dpi)) {\n      width = desired_client_rect.right - desired_client_rect.left;\n      height = desired_client_rect.bottom - desired_client_rect.top;\n    } else {\n      width = desired_client_rect.right;\n      height = desired_client_rect.bottom;\n    }\n  }\n\n  // 限制在合理范围内（最小 320×240，最大为工作区）\n  constexpr int kMinWidth = 320;\n  constexpr int kMinHeight = 240;\n  width = std::max(kMinWidth, width);\n  height = std::max(kMinHeight, height);\n\n  HMONITOR monitor = MonitorFromPoint(POINT{0, 0}, MONITOR_DEFAULTTOPRIMARY);\n  MONITORINFO mi = {sizeof(mi)};\n  if (GetMonitorInfoW(monitor, &mi)) {\n    int work_width = mi.rcWork.right - mi.rcWork.left;\n    int work_height = mi.rcWork.bottom - mi.rcWork.top;\n    width = std::min(width, work_width);\n    height = std::min(height, work_height);\n  }\n\n  // 位置：x/y < 0 表示未保存过，居中；否则使用保存的位置\n  // 若保存的位置完全不在当前显示器上，则回退到居中\n  bool use_center = (x < 0 || y < 0);\n  if (!use_center && GetMonitorInfoW(monitor, &mi)) {\n    RECT work = mi.rcWork;\n    // 窗口至少有一部分在工作区内\n    bool visible =\n        (x + width > work.left && x < work.right && y + height > work.top && y < work.bottom);\n    if (!visible) {\n      use_center = true;\n    }\n  }\n  if (use_center && GetMonitorInfoW(monitor, &mi)) {\n    int work_width = mi.rcWork.right - mi.rcWork.left;\n    int work_height = mi.rcWork.bottom - mi.rcWork.top;\n    x = mi.rcWork.left + (work_width - width) / 2;\n    y = mi.rcWork.top + (work_height - height) / 2;\n  }\n\n  // 创建独立窗口\n  // 窗口样式：\n  // - WS_POPUP: 无边框窗口\n  // - WS_SYSMENU: 保留系统菜单（Alt+Space）\n  // - WS_MAXIMIZEBOX/WS_MINIMIZEBOX: 支持最大化/最小化\n  HWND hwnd = CreateWindowExW(ex_style,                                // 扩展样式\n                              L\"SpinningMomoWebViewWindowClass\",       // 窗口类名\n                              L\"SpinningMomo WebView\",                 // 窗口标题\n                              style,                                   // 窗口样式\n                              x, y, width, height,                     // 位置和大小\n                              nullptr,                                 // 父窗口\n                              nullptr,                                 // 菜单\n                              state.floating_window->window.instance,  // 实例句柄\n                              &state                                   // 用户数据\n  );\n\n  if (!hwnd) {\n    return std::unexpected(\"Failed to create WebView window\");\n  }\n\n  // 保存窗口句柄到 WebView 状态中\n  state.webview->window.webview_hwnd = hwnd;\n  state.webview->window.is_visible = false;\n\n  // 应用窗口样式（圆角 + 触发边框重算）\n  apply_window_style(hwnd);\n\n  RECT client_rect{};\n  GetClientRect(hwnd, &client_rect);\n  auto window_rect = get_window_rect_or_fallback(hwnd, RECT{x, y, x + width, y + height});\n  state.webview->window.width = client_rect.right - client_rect.left;\n  state.webview->window.height = client_rect.bottom - client_rect.top;\n  state.webview->window.x = window_rect.left;\n  state.webview->window.y = window_rect.top;\n\n  Logger().info(\"WebView window created successfully\");\n  return {};\n}\n\nauto recreate_webview_host(Core::State::AppState& state) -> std::expected<void, std::string> {\n  auto hwnd = state.webview->window.webview_hwnd;\n  if (!hwnd) {\n    return {};\n  }\n\n  apply_window_ex_style_from_settings(state);\n\n  if (!state.webview->is_initialized) {\n    Logger().info(\"Skipped WebView host recreation: WebView is not initialized\");\n    return {};\n  }\n\n  bool was_visible = IsWindowVisible(hwnd) == TRUE;\n\n  Core::WebView::shutdown(state);\n  if (auto result = Core::WebView::initialize(state, hwnd); !result) {\n    return std::unexpected(\"Failed to recreate WebView host: \" + result.error());\n  }\n\n  state.webview->window.is_visible = was_visible;\n  Logger().info(\"WebView host recreated successfully\");\n  return {};\n}\n\nauto cleanup(Core::State::AppState& state) -> void {\n  // 关闭 WebView\n  Core::WebView::shutdown(state);\n\n  if (state.webview->window.webview_hwnd) {\n    HWND hwnd = state.webview->window.webview_hwnd;\n\n    // Persist window bounds; when maximized, minimized, or fullscreen, save restore bounds.\n    int width_to_save = state.webview->window.width;\n    int height_to_save = state.webview->window.height;\n    int x_to_save = state.webview->window.x;\n    int y_to_save = state.webview->window.y;\n    if (state.webview->window.is_fullscreen && state.webview->window.has_fullscreen_restore_state) {\n      const auto& restore = state.webview->window.fullscreen_restore_placement;\n      width_to_save = restore.rcNormalPosition.right - restore.rcNormalPosition.left;\n      height_to_save = restore.rcNormalPosition.bottom - restore.rcNormalPosition.top;\n      x_to_save = restore.rcNormalPosition.left;\n      y_to_save = restore.rcNormalPosition.top;\n    } else if (IsZoomed(hwnd) || IsIconic(hwnd)) {\n      WINDOWPLACEMENT wp = {sizeof(wp)};\n      if (GetWindowPlacement(hwnd, &wp)) {\n        width_to_save = wp.rcNormalPosition.right - wp.rcNormalPosition.left;\n        height_to_save = wp.rcNormalPosition.bottom - wp.rcNormalPosition.top;\n        x_to_save = wp.rcNormalPosition.left;\n        y_to_save = wp.rcNormalPosition.top;\n      }\n    } else {\n      RECT rect = get_window_rect_or_fallback(\n          hwnd, RECT{x_to_save, y_to_save, x_to_save + width_to_save, y_to_save + height_to_save});\n      width_to_save = rect.right - rect.left;\n      height_to_save = rect.bottom - rect.top;\n      x_to_save = rect.left;\n      y_to_save = rect.top;\n    }\n\n    constexpr int kMinWidth = 320;\n    constexpr int kMinHeight = 240;\n    width_to_save = std::max(kMinWidth, width_to_save);\n    height_to_save = std::max(kMinHeight, height_to_save);\n\n    auto old_settings = state.settings->raw;\n    state.settings->raw.ui.webview_window.width = width_to_save;\n    state.settings->raw.ui.webview_window.height = height_to_save;\n    state.settings->raw.ui.webview_window.x = x_to_save;\n    state.settings->raw.ui.webview_window.y = y_to_save;\n\n    auto settings_path = Features::Settings::get_settings_path();\n    if (settings_path) {\n      if (auto save_result =\n              Features::Settings::save_settings_to_file(settings_path.value(), state.settings->raw);\n          !save_result) {\n        Logger().warn(\"Failed to persist WebView window bounds: {}\", save_result.error());\n      } else {\n        Features::Settings::notify_settings_changed(state, old_settings,\n                                                    \"Settings updated via WebView window bounds\");\n      }\n    }\n\n    DestroyWindow(hwnd);\n    state.webview->window.webview_hwnd = nullptr;\n    state.webview->window.is_visible = false;\n    state.webview->window.is_maximized = false;\n    state.webview->window.is_fullscreen = false;\n    state.webview->window.has_fullscreen_restore_state = false;\n    Logger().info(\"WebView window destroyed\");\n  }\n}\n\nauto initialize(Core::State::AppState& state) -> std::expected<void, std::string> {\n  // 创建窗口\n  if (auto result = create(state); !result) {\n    return std::unexpected(\"Failed to create WebView window: \" + result.error());\n  }\n\n  // 初始化 WebView\n  if (auto result = Core::WebView::initialize(state, state.webview->window.webview_hwnd); !result) {\n    return std::unexpected(\"Failed to initialize WebView: \" + result.error());\n  }\n\n  Logger().info(\"WebView window initialized\");\n  return {};\n}\n\n}  // namespace UI::WebViewWindow\n"
  },
  {
    "path": "src/ui/webview_window/webview_window.ixx",
    "content": "module;\n\nexport module UI.WebViewWindow;\n\nimport std;\nimport Core.State;\nimport Vendor.Windows;\n\nnamespace UI::WebViewWindow {\n\n// 窗口初始化和清理\nexport auto initialize(Core::State::AppState& state) -> std::expected<void, std::string>;\nexport auto recreate_webview_host(Core::State::AppState& state) -> std::expected<void, std::string>;\nexport auto cleanup(Core::State::AppState& state) -> void;\n\n// 窗口显示控制\nexport auto activate_window(Core::State::AppState& state) -> void;\n\n// 窗口控制功能\nexport auto minimize_window(Core::State::AppState& state) -> std::expected<void, std::string>;\nexport auto toggle_maximize_window(Core::State::AppState& state)\n    -> std::expected<void, std::string>;\nexport auto set_fullscreen_window(Core::State::AppState& state, bool fullscreen)\n    -> std::expected<void, std::string>;\nexport auto close_window(Core::State::AppState& state) -> std::expected<void, std::string>;\n\n}  // namespace UI::WebViewWindow\n"
  },
  {
    "path": "src/utils/crash_dump/crash_dump.cpp",
    "content": "module;\n\n#include <Windows.h>\n\n#include <DbgHelp.h>\n\n#include <cctype>\n#include <cstdlib>\n\nmodule Utils.CrashDump;\n\nimport std;\nimport Utils.Path;\n\nnamespace Utils::CrashDump::Detail {\n\nstd::atomic_bool g_installed{false};\nstd::atomic_flag g_dump_writing{};\n\nstruct DumpWriteScope {\n  ~DumpWriteScope() { g_dump_writing.clear(std::memory_order_release); }\n};\n\nconstexpr MINIDUMP_TYPE kDefaultDumpType = static_cast<MINIDUMP_TYPE>(\n    MiniDumpWithThreadInfo | MiniDumpWithUnloadedModules | MiniDumpWithIndirectlyReferencedMemory);\n\nauto current_timestamp() -> std::string {\n  SYSTEMTIME now{};\n  GetLocalTime(&now);\n\n  return std::format(\"{:04}{:02}{:02}_{:02}{:02}{:02}\", now.wYear, now.wMonth, now.wDay, now.wHour,\n                     now.wMinute, now.wSecond);\n}\n\nauto sanitize_reason(std::string_view reason) -> std::string {\n  std::string safe;\n  safe.reserve(reason.size());\n\n  for (const auto ch : reason) {\n    const auto uch = static_cast<unsigned char>(ch);\n    safe.push_back(std::isalnum(uch) != 0 ? static_cast<char>(uch) : '_');\n  }\n\n  return safe.empty() ? \"unknown\" : safe;\n}\n\nauto make_dump_dir() -> std::expected<std::filesystem::path, std::string> {\n  auto logs_dir_result = Utils::Path::GetAppDataSubdirectory(\"logs\");\n  if (!logs_dir_result) {\n    return std::unexpected(\"Failed to get logs directory: \" + logs_dir_result.error());\n  }\n\n  const auto dump_dir = logs_dir_result.value() / \"dumps\";\n  if (auto ensure_result = Utils::Path::EnsureDirectoryExists(dump_dir); !ensure_result) {\n    return std::unexpected(\"Failed to create dump directory: \" + ensure_result.error());\n  }\n\n  return dump_dir;\n}\n\nauto build_dump_path(void* exception_pointers, std::string_view reason)\n    -> std::expected<std::filesystem::path, std::string> {\n  auto dump_dir_result = make_dump_dir();\n  if (!dump_dir_result) {\n    return std::unexpected(dump_dir_result.error());\n  }\n\n  auto* exception = static_cast<EXCEPTION_POINTERS*>(exception_pointers);\n  const auto code = (exception && exception->ExceptionRecord)\n                        ? exception->ExceptionRecord->ExceptionCode\n                        : static_cast<DWORD>(0);\n\n  const auto filename =\n      std::format(\"crash_{}_pid{}_tid{}_{}_0x{:08X}.dmp\", current_timestamp(),\n                  GetCurrentProcessId(), GetCurrentThreadId(), sanitize_reason(reason), code);\n\n  return dump_dir_result.value() / filename;\n}\n\nauto write_dump_internal(void* exception_pointers, std::string_view reason)\n    -> std::expected<std::filesystem::path, std::string> {\n  if (g_dump_writing.test_and_set(std::memory_order_acquire)) {\n    return std::unexpected(\"Dump writing is already in progress\");\n  }\n  DumpWriteScope scope_guard{};\n\n  auto dump_path_result = build_dump_path(exception_pointers, reason);\n  if (!dump_path_result) {\n    return std::unexpected(dump_path_result.error());\n  }\n\n  const auto dump_path = dump_path_result.value();\n  const auto dump_path_w = dump_path.wstring();\n\n  HANDLE dump_file = CreateFileW(dump_path_w.c_str(), GENERIC_WRITE, FILE_SHARE_READ, nullptr,\n                                 CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);\n  if (dump_file == INVALID_HANDLE_VALUE) {\n    return std::unexpected(\"CreateFileW failed with error: \" + std::to_string(GetLastError()));\n  }\n\n  auto* exception = static_cast<EXCEPTION_POINTERS*>(exception_pointers);\n  MINIDUMP_EXCEPTION_INFORMATION exception_info{\n      .ThreadId = GetCurrentThreadId(),\n      .ExceptionPointers = exception,\n      .ClientPointers = FALSE,\n  };\n\n  BOOL ok =\n      MiniDumpWriteDump(GetCurrentProcess(), GetCurrentProcessId(), dump_file, kDefaultDumpType,\n                        exception ? &exception_info : nullptr, nullptr, nullptr);\n\n  const auto last_error = GetLastError();\n  CloseHandle(dump_file);\n\n  if (ok == FALSE) {\n    return std::unexpected(\"MiniDumpWriteDump failed with error: \" + std::to_string(last_error));\n  }\n\n  return dump_path;\n}\n\nauto __stdcall unhandled_exception_filter(EXCEPTION_POINTERS* exception_pointers) -> LONG {\n  auto result = write_dump_internal(exception_pointers, \"seh\");\n  if (!result) {\n    auto message = \"MiniDump failed: \" + result.error() + \"\\n\";\n    OutputDebugStringA(message.c_str());\n  }\n  return EXCEPTION_EXECUTE_HANDLER;\n}\n\n[[noreturn]] auto terminate_handler() -> void {\n  auto result = write_dump_internal(nullptr, \"terminate\");\n  if (!result) {\n    auto message = \"MiniDump failed in terminate: \" + result.error() + \"\\n\";\n    OutputDebugStringA(message.c_str());\n  }\n  std::abort();\n}\n\n}  // namespace Utils::CrashDump::Detail\n\nnamespace Utils::CrashDump {\n\nauto install() -> void {\n  if (Detail::g_installed.exchange(true, std::memory_order_acq_rel)) {\n    return;\n  }\n\n  SetUnhandledExceptionFilter(Detail::unhandled_exception_filter);\n  std::set_terminate(Detail::terminate_handler);\n}\n\nauto write_dump(void* exception_pointers, std::string_view reason)\n    -> std::expected<std::filesystem::path, std::string> {\n  return Detail::write_dump_internal(exception_pointers, reason);\n}\n\n}  // namespace Utils::CrashDump\n"
  },
  {
    "path": "src/utils/crash_dump/crash_dump.ixx",
    "content": "module;\n\nexport module Utils.CrashDump;\n\nimport std;\n\nnamespace Utils::CrashDump {\n\n// 安装崩溃转储处理器（SEH + terminate）\nexport auto install() -> void;\n\n// 手动写入转储（exception_pointers 可传 nullptr）\nexport auto write_dump(void* exception_pointers, std::string_view reason)\n    -> std::expected<std::filesystem::path, std::string>;\n\n}  // namespace Utils::CrashDump\n"
  },
  {
    "path": "src/utils/crypto/crypto.cpp",
    "content": "module;\n\n#include <windows.h>\n\n#include <bcrypt.h>\n\nmodule Utils.Crypto;\n\nimport std;\n\nnamespace Utils::Crypto {\n\nauto is_nt_success(NTSTATUS status) -> bool { return status >= 0; }\n\nauto make_ntstatus_error(std::string_view api_name, NTSTATUS status) -> std::string {\n  return std::format(\"{} failed, NTSTATUS=0x{:08X}\", api_name, static_cast<unsigned long>(status));\n}\n\nauto sha256_file(const std::filesystem::path& file_path)\n    -> std::expected<std::string, std::string> {\n  BCRYPT_ALG_HANDLE algorithm_handle = nullptr;\n  BCRYPT_HASH_HANDLE hash_handle = nullptr;\n\n  auto cleanup = [&]() {\n    if (hash_handle != nullptr) {\n      BCryptDestroyHash(hash_handle);\n      hash_handle = nullptr;\n    }\n    if (algorithm_handle != nullptr) {\n      BCryptCloseAlgorithmProvider(algorithm_handle, 0);\n      algorithm_handle = nullptr;\n    }\n  };\n\n  auto open_status =\n      BCryptOpenAlgorithmProvider(&algorithm_handle, BCRYPT_SHA256_ALGORITHM, nullptr, 0);\n  if (!is_nt_success(open_status)) {\n    cleanup();\n    return std::unexpected(make_ntstatus_error(\"BCryptOpenAlgorithmProvider\", open_status));\n  }\n\n  DWORD hash_object_size = 0;\n  DWORD bytes_result = 0;\n  auto object_size_status = BCryptGetProperty(algorithm_handle, BCRYPT_OBJECT_LENGTH,\n                                              reinterpret_cast<PUCHAR>(&hash_object_size),\n                                              sizeof(hash_object_size), &bytes_result, 0);\n  if (!is_nt_success(object_size_status)) {\n    cleanup();\n    return std::unexpected(\n        make_ntstatus_error(\"BCryptGetProperty(BCRYPT_OBJECT_LENGTH)\", object_size_status));\n  }\n\n  DWORD hash_value_size = 0;\n  auto hash_size_status = BCryptGetProperty(algorithm_handle, BCRYPT_HASH_LENGTH,\n                                            reinterpret_cast<PUCHAR>(&hash_value_size),\n                                            sizeof(hash_value_size), &bytes_result, 0);\n  if (!is_nt_success(hash_size_status)) {\n    cleanup();\n    return std::unexpected(\n        make_ntstatus_error(\"BCryptGetProperty(BCRYPT_HASH_LENGTH)\", hash_size_status));\n  }\n\n  std::vector<UCHAR> hash_object(hash_object_size);\n  std::vector<UCHAR> hash_value(hash_value_size);\n\n  auto create_hash_status = BCryptCreateHash(algorithm_handle, &hash_handle, hash_object.data(),\n                                             static_cast<ULONG>(hash_object.size()), nullptr, 0, 0);\n  if (!is_nt_success(create_hash_status)) {\n    cleanup();\n    return std::unexpected(make_ntstatus_error(\"BCryptCreateHash\", create_hash_status));\n  }\n\n  std::ifstream file(file_path, std::ios::binary);\n  if (!file) {\n    cleanup();\n    return std::unexpected(\"Failed to open file for hashing: \" + file_path.string());\n  }\n\n  std::array<char, 64 * 1024> buffer{};\n  while (file.good()) {\n    file.read(buffer.data(), static_cast<std::streamsize>(buffer.size()));\n    auto bytes_read = file.gcount();\n    if (bytes_read <= 0) {\n      break;\n    }\n\n    auto hash_data_status = BCryptHashData(hash_handle, reinterpret_cast<PUCHAR>(buffer.data()),\n                                           static_cast<ULONG>(bytes_read), 0);\n    if (!is_nt_success(hash_data_status)) {\n      cleanup();\n      return std::unexpected(make_ntstatus_error(\"BCryptHashData\", hash_data_status));\n    }\n  }\n\n  if (file.bad()) {\n    cleanup();\n    return std::unexpected(\"Failed while reading file for hashing: \" + file_path.string());\n  }\n\n  auto finish_status =\n      BCryptFinishHash(hash_handle, hash_value.data(), static_cast<ULONG>(hash_value.size()), 0);\n  if (!is_nt_success(finish_status)) {\n    cleanup();\n    return std::unexpected(make_ntstatus_error(\"BCryptFinishHash\", finish_status));\n  }\n\n  cleanup();\n\n  std::ostringstream output;\n  output << std::hex << std::setfill('0');\n  for (auto byte : hash_value) {\n    output << std::setw(2) << static_cast<int>(byte);\n  }\n  return output.str();\n}\n\n}  // namespace Utils::Crypto\n"
  },
  {
    "path": "src/utils/crypto/crypto.ixx",
    "content": "module;\n\nexport module Utils.Crypto;\n\nimport std;\n\nnamespace Utils::Crypto {\n\n// 计算文件 SHA-256（小写十六进制字符串）\nexport auto sha256_file(const std::filesystem::path& file_path)\n    -> std::expected<std::string, std::string>;\n\n}  // namespace Utils::Crypto\n"
  },
  {
    "path": "src/utils/dialog/dialog.cpp",
    "content": "module;\n\n#include <wil/com.h>\n\nmodule Utils.Dialog;\n\nimport std;\nimport Utils.Logger;\nimport Utils.String;\nimport Vendor.WIL;\nimport Vendor.Windows;\nimport <shobjidl.h>;\n\nnamespace Utils::Dialog {\n\n// 辅助函数：解析文件过滤器 - 修复解析逻辑\nauto parse_file_filter(const std::string& filter)\n    -> std::pair<std::vector<std::wstring>, std::vector<std::wstring>> {\n  std::vector<std::wstring> filter_names;\n  std::vector<std::wstring> filter_patterns;\n\n  if (filter.empty()) {\n    return {filter_names, filter_patterns};\n  }\n\n  std::wstring filter_wide = Utils::String::FromUtf8(filter);\n\n  // 按'|'分割，确保成对出现\n  std::vector<std::wstring> segments;\n  size_t start = 0;\n  size_t pos = 0;\n\n  while ((pos = filter_wide.find(L'|', start)) != std::wstring::npos) {\n    segments.push_back(filter_wide.substr(start, pos - start));\n    start = pos + 1;\n  }\n\n  // 添加最后一个段\n  if (start < filter_wide.length()) {\n    segments.push_back(filter_wide.substr(start));\n  }\n\n  // 确保是偶数个段（name-pattern对）\n  if (segments.size() % 2 != 0) {\n    Logger().warn(\"Filter string has odd number of segments, ignoring last segment\");\n    segments.pop_back();\n  }\n\n  // 分配到name和pattern数组\n  for (size_t i = 0; i < segments.size(); i += 2) {\n    filter_names.push_back(segments[i]);\n    filter_patterns.push_back(segments[i + 1]);\n  }\n\n  return {filter_names, filter_patterns};\n}\n\n// 选择文件夹\nauto select_folder(const FolderSelectorParams& params, HWND hwnd)\n    -> std::expected<FolderSelectorResult, std::string> {\n  try {\n    // COM初始化\n    auto com_init = wil::CoInitializeEx(COINIT_APARTMENTTHREADED | COINIT_DISABLE_OLE1DDE);\n\n    // 创建对话框 - 智能指针自动管理\n    wil::com_ptr<IFileDialog> pFileDialog;\n    Vendor::WIL::throw_if_failed(\n        CoCreateInstance(CLSID_FileOpenDialog, nullptr, CLSCTX_ALL, IID_PPV_ARGS(&pFileDialog)));\n\n    // 设置选项\n    DWORD dwOptions;\n    Vendor::WIL::throw_if_failed(pFileDialog->GetOptions(&dwOptions));\n    Vendor::WIL::throw_if_failed(pFileDialog->SetOptions(dwOptions | FOS_PICKFOLDERS));\n\n    // 设置标题\n    if (!params.title.empty()) {\n      std::wstring title_wide = Utils::String::FromUtf8(params.title);\n      Vendor::WIL::throw_if_failed(pFileDialog->SetTitle(title_wide.c_str()));\n    } else {\n      Vendor::WIL::throw_if_failed(pFileDialog->SetTitle(L\"选择文件夹\"));\n    }\n\n    // 显示对话框\n    Vendor::WIL::throw_if_failed(pFileDialog->Show(hwnd));\n\n    // 获取结果\n    wil::com_ptr<IShellItem> pItem;\n    Vendor::WIL::throw_if_failed(pFileDialog->GetResult(&pItem));\n\n    // 获取路径 - 自动内存管理\n    wil::unique_cotaskmem_string file_path;\n    Vendor::WIL::throw_if_failed(pItem->GetDisplayName(SIGDN_FILESYSPATH, &file_path));\n\n    // 返回结果\n    std::filesystem::path result(file_path.get());\n    Logger().info(\"User selected folder: {}\", result.string());\n\n    FolderSelectorResult folder_result;\n    folder_result.path = result.string();\n    return folder_result;\n\n  } catch (const wil::ResultException& ex) {\n    if (ex.GetErrorCode() ==\n        Vendor::Windows::_HRESULT_FROM_WIN32(Vendor::Windows::kERROR_CANCELLED)) {\n      return std::unexpected(\"User cancelled the operation\");\n    }\n    return std::unexpected(std::string(\"Dialog operation failed: \") + ex.what());\n  }\n}\n\n// 选择文件\nauto select_file(const FileSelectorParams& params, HWND hwnd)\n    -> std::expected<FileSelectorResult, std::string> {\n  try {\n    // COM初始化\n    auto com_init = wil::CoInitializeEx(COINIT_APARTMENTTHREADED | COINIT_DISABLE_OLE1DDE);\n\n    // 创建对话框 - 智能指针自动管理\n    wil::com_ptr<IFileOpenDialog> pFileDialog;\n    Vendor::WIL::throw_if_failed(\n        CoCreateInstance(CLSID_FileOpenDialog, nullptr, CLSCTX_ALL, IID_PPV_ARGS(&pFileDialog)));\n\n    // 设置选项\n    DWORD dwOptions;\n    Vendor::WIL::throw_if_failed(pFileDialog->GetOptions(&dwOptions));\n    dwOptions |= FOS_FILEMUSTEXIST;  // 文件必须存在\n    if (params.allow_multiple) {\n      dwOptions |= FOS_ALLOWMULTISELECT;  // 允许多选\n    }\n    Vendor::WIL::throw_if_failed(pFileDialog->SetOptions(dwOptions));\n\n    // 设置过滤器 - 使用修复后的解析逻辑\n    if (!params.filter.empty()) {\n      auto [filter_names, filter_patterns] = parse_file_filter(params.filter);\n\n      // 创建COMDLG_FILTERSPEC数组\n      if (!filter_names.empty() && !filter_patterns.empty()) {\n        std::vector<COMDLG_FILTERSPEC> filter_specs;\n        filter_specs.resize(filter_names.size());\n\n        for (size_t i = 0; i < filter_names.size(); ++i) {\n          filter_specs[i].pszName = filter_names[i].c_str();\n          filter_specs[i].pszSpec = filter_patterns[i].c_str();\n        }\n\n        // 设置文件类型过滤器\n        HRESULT hr =\n            pFileDialog->SetFileTypes(static_cast<UINT>(filter_specs.size()), filter_specs.data());\n        if (FAILED(hr)) {\n          Logger().warn(\"Failed to set file filters (HRESULT: 0x{}), continuing without filter\",\n                        std::to_string(static_cast<unsigned>(hr)));\n        }\n      }\n    }\n\n    // 设置标题\n    if (!params.title.empty()) {\n      std::wstring title_wide = Utils::String::FromUtf8(params.title);\n      Vendor::WIL::throw_if_failed(pFileDialog->SetTitle(title_wide.c_str()));\n    }\n\n    // 显示对话框\n    Vendor::WIL::throw_if_failed(pFileDialog->Show(hwnd));\n\n    // 获取结果\n    std::vector<std::filesystem::path> selected_paths;\n\n    if (params.allow_multiple) {\n      // 多文件选择\n      wil::com_ptr<IShellItemArray> pItemArray;\n      Vendor::WIL::throw_if_failed(pFileDialog->GetResults(&pItemArray));\n\n      // 获取文件数量\n      DWORD count = 0;\n      Vendor::WIL::throw_if_failed(pItemArray->GetCount(&count));\n      selected_paths.reserve(count);\n\n      for (DWORD i = 0; i < count; ++i) {\n        wil::com_ptr<IShellItem> pItem;\n        if (SUCCEEDED(pItemArray->GetItemAt(i, &pItem))) {\n          wil::unique_cotaskmem_string file_path;\n          if (SUCCEEDED(pItem->GetDisplayName(SIGDN_FILESYSPATH, &file_path))) {\n            selected_paths.emplace_back(file_path.get());\n          } else {\n            Logger().warn(\"Failed to get file path for item at index {}, skipping\", i);\n          }\n        } else {\n          Logger().warn(\"Failed to get item at index {}, skipping\", i);\n        }\n      }\n    } else {\n      // 单文件选择\n      wil::com_ptr<IShellItem> pItem;\n      Vendor::WIL::throw_if_failed(pFileDialog->GetResult(&pItem));\n\n      wil::unique_cotaskmem_string file_path;\n      Vendor::WIL::throw_if_failed(pItem->GetDisplayName(SIGDN_FILESYSPATH, &file_path));\n      selected_paths.emplace_back(file_path.get());\n    }\n\n    if (selected_paths.empty()) {\n      return std::unexpected(\"No valid files were selected\");\n    }\n\n    Logger().info(\"Selected {} file(s)\", selected_paths.size());\n\n    // 转换为FileSelectorResult\n    FileSelectorResult file_selector_result;\n    file_selector_result.paths.reserve(selected_paths.size());\n    for (const auto& path : selected_paths) {\n      file_selector_result.paths.push_back(path.string());\n    }\n\n    return file_selector_result;\n\n  } catch (const wil::ResultException& ex) {\n    if (ex.GetErrorCode() ==\n        Vendor::Windows::_HRESULT_FROM_WIN32(Vendor::Windows::kERROR_CANCELLED)) {\n      return std::unexpected(\"User cancelled the operation\");\n    }\n    return std::unexpected(std::string(\"Dialog operation failed: \") + ex.what());\n  }\n}\n\n}  // namespace Utils::Dialog"
  },
  {
    "path": "src/utils/dialog/dialog.ixx",
    "content": "module;\n\nexport module Utils.Dialog;\n\nimport std;\nimport Vendor.Windows;\n\nnamespace Utils::Dialog {\n\nexport struct FileSelectorParams {\n  std::string title;\n  std::string filter;\n  bool allow_multiple{false};\n  std::int8_t parent_window_mode{0};  // 0 = 无父窗口, 1 = webview2, 2 = 激活窗口\n};\n\nexport struct FileSelectorResult {\n  std::vector<std::string> paths;\n};\n\nexport struct FolderSelectorParams {\n  std::string title;\n  std::int8_t parent_window_mode{0};  // 0 = 无父窗口, 1 = webview2, 2 = 激活窗口\n};\n\nexport struct FolderSelectorResult {\n  std::string path;\n};\n\nexport auto select_folder(const FolderSelectorParams& params, Vendor::Windows::HWND hwnd = nullptr)\n    -> std::expected<FolderSelectorResult, std::string>;\n\nexport auto select_file(const FileSelectorParams& params, Vendor::Windows::HWND hwnd = nullptr)\n    -> std::expected<FileSelectorResult, std::string>;\n\n}  // namespace Utils::Dialog"
  },
  {
    "path": "src/utils/file/file.cpp",
    "content": "module;\n\n#include <windows.h>\n#include <asio.hpp>\n\nmodule Utils.File;\n\nimport std;\nimport Utils.Logger;\nimport Utils.File.Mime;\nimport Utils.String;\nimport Utils.Time;\n\nnamespace Utils::File {\n\nauto is_cross_device_error(const std::error_code& error_code) -> bool {\n  if (!error_code) {\n    return false;\n  }\n  return error_code == std::errc::cross_device_link;\n}\n\nauto copy_file_with_copyfile2(const std::filesystem::path& source_path,\n                              const std::filesystem::path& destination_path, bool overwrite)\n    -> std::expected<void, std::string> {\n  COPYFILE2_EXTENDED_PARAMETERS parameters{};\n  parameters.dwSize = sizeof(COPYFILE2_EXTENDED_PARAMETERS);\n  parameters.dwCopyFlags = overwrite ? 0 : COPY_FILE_FAIL_IF_EXISTS;\n\n  auto source_wide = source_path.wstring();\n  auto destination_wide = destination_path.wstring();\n  auto hr = ::CopyFile2(source_wide.c_str(), destination_wide.c_str(), &parameters);\n  if (FAILED(hr)) {\n    return std::unexpected(std::format(\"CopyFile2 failed {} -> {} (HRESULT: 0x{:08X})\",\n                                       source_path.string(), destination_path.string(),\n                                       static_cast<unsigned int>(hr)));\n  }\n\n  return {};\n}\n\nauto copy_directory_with_copyfile2(const std::filesystem::path& source_path,\n                                   const std::filesystem::path& destination_path, bool overwrite)\n    -> std::expected<void, std::string> {\n  std::error_code ec;\n  std::filesystem::create_directories(destination_path, ec);\n  if (ec) {\n    return std::unexpected(\"Failed to create destination directory: \" + ec.message());\n  }\n\n  for (const auto& entry : std::filesystem::recursive_directory_iterator(source_path)) {\n    auto relative_path = std::filesystem::relative(entry.path(), source_path, ec);\n    if (ec) {\n      return std::unexpected(\"Failed to build relative path while moving directory: \" +\n                             ec.message());\n    }\n\n    auto target_path = destination_path / relative_path;\n    if (entry.is_directory()) {\n      std::filesystem::create_directories(target_path, ec);\n      if (ec) {\n        return std::unexpected(\"Failed to create directory while moving: \" + ec.message());\n      }\n      continue;\n    }\n\n    if (!entry.is_regular_file()) {\n      return std::unexpected(\"Unsupported filesystem entry while moving directory: \" +\n                             entry.path().string());\n    }\n\n    auto parent_path = target_path.parent_path();\n    if (!parent_path.empty()) {\n      std::filesystem::create_directories(parent_path, ec);\n      if (ec) {\n        return std::unexpected(\"Failed to prepare destination parent directory: \" + ec.message());\n      }\n    }\n\n    auto copy_result = copy_file_with_copyfile2(entry.path(), target_path, overwrite);\n    if (!copy_result) {\n      return std::unexpected(copy_result.error());\n    }\n  }\n\n  return {};\n}\n\nauto format_file_error(const std::string& operation, const std::filesystem::path& path,\n                       const std::exception& e) -> std::string {\n  std::string error_msg = std::format(\"{} {}: {}\", operation, path.string(), e.what());\n  Logger().error(\"{} {}: {}\", operation, path.string(), e.what());\n  return error_msg;\n}\n\n// 判断MIME类型是否为文本类型\nauto is_text_mime_type(const std::string& mime_type) -> bool {\n  return mime_type.starts_with(\"text/\") || mime_type.starts_with(\"application/json\") ||\n         mime_type.starts_with(\"application/xml\") ||\n         mime_type.starts_with(\"application/javascript\") || mime_type.ends_with(\"; charset=utf-8\");\n}\n\nauto read_file(const std::filesystem::path& file_path)\n    -> asio::awaitable<std::expected<FileReadResult, std::string>> {\n  try {\n    // 获取当前executor\n    auto executor = co_await asio::this_coro::executor;\n\n    asio::stream_file file(executor, file_path.string(), asio::file_base::read_only);\n    auto file_size = file.size();\n\n    FileReadResult result;\n    result.path = file_path.string();\n    result.mime_type = Mime::get_mime_type(file_path);\n    result.original_size = file_size;\n\n    if (file_size == 0) {\n      co_return result;\n    }\n\n    // 先读取为二进制数据\n    std::vector<char> binary_data(file_size);\n    auto bytes_read =\n        co_await asio::async_read(file, asio::buffer(binary_data), asio::use_awaitable);\n\n    // 调整实际读取大小\n    if (bytes_read < file_size) {\n      binary_data.resize(bytes_read);\n      result.original_size = bytes_read;\n    }\n\n    // 设置原始数据\n    result.data = std::move(binary_data);\n\n    Logger().debug(\"Read file: {}, size: {} bytes, mime: {}\", file_path.string(),\n                   result.original_size, result.mime_type);\n\n    co_return result;\n  } catch (const std::exception& e) {\n    co_return std::unexpected(format_file_error(\"Error reading\", file_path, e));\n  }\n}\n\nauto read_file_and_encode(const std::filesystem::path& file_path)\n    -> asio::awaitable<std::expected<EncodedFileReadResult, std::string>> {\n  // 首先读取原始数据\n  auto raw_result = co_await read_file(file_path);\n  if (!raw_result) {\n    // 如果读取失败，直接返回错误\n    co_return std::unexpected(raw_result.error());\n  }\n\n  // 获取原始数据\n  auto raw_data = std::move(raw_result.value());\n\n  // 创建编码后的结果结构\n  EncodedFileReadResult result;\n  result.path = raw_data.path;\n  result.mime_type = raw_data.mime_type;\n  result.original_size = raw_data.original_size;\n\n  if (raw_data.original_size == 0) {\n    co_return result;\n  }\n\n  // 判断文件类型\n  bool is_text = is_text_mime_type(result.mime_type);\n  if (!is_text) {\n    // 对于未知类型，尝试UTF-8验证\n    is_text = Utils::String::IsValidUtf8(raw_data.data);\n  }\n\n  if (is_text) {\n    // 文本文件：直接转换为字符串\n    result.content = std::string(raw_data.data.begin(), raw_data.data.end());\n    result.is_binary = false;\n    Logger().debug(\"Read text file: {}, size: {} chars, mime: {}\", file_path.string(),\n                   result.content.size(), result.mime_type);\n  } else {\n    // 二进制文件：Base64编码\n    result.content = Utils::String::ToBase64(raw_data.data);\n    result.is_binary = true;\n    Logger().debug(\n        \"Read binary file: {}, original size: {} bytes, encoded size: {} chars, mime: {}\",\n        file_path.string(), result.original_size, result.content.size(), result.mime_type);\n  }\n\n  co_return result;\n}\n\nauto write_file(const std::filesystem::path& file_path, const std::string& content, bool is_binary,\n                bool overwrite) -> asio::awaitable<std::expected<FileWriteResult, std::string>> {\n  try {\n    auto executor = co_await asio::this_coro::executor;\n\n    // 确保父目录存在\n    auto parent_path = file_path.parent_path();\n    if (!parent_path.empty() && !std::filesystem::exists(parent_path)) {\n      std::filesystem::create_directories(parent_path);\n      Logger().debug(\"Created directories: {}\", parent_path.string());\n    }\n\n    // 检查文件是否存在且不允许覆盖\n    if (std::filesystem::exists(file_path) && !overwrite) {\n      co_return std::unexpected(\"File already exists and overwrite is disabled: \" +\n                                file_path.string());\n    }\n\n    asio::stream_file file(\n        executor, file_path.string(),\n        asio::file_base::write_only | asio::file_base::create | asio::file_base::truncate);\n\n    if (content.empty()) {\n      co_return FileWriteResult{};\n    }\n\n    size_t bytes_written = 0;\n    if (is_binary) {\n      // 二进制文件：从base64解码后写入\n      auto binary_data = Utils::String::FromBase64(content);\n      if (!binary_data.empty()) {\n        bytes_written =\n            co_await asio::async_write(file, asio::buffer(binary_data), asio::use_awaitable);\n      }\n      Logger().debug(\"Successfully wrote binary file: {}, decoded size: {} bytes\",\n                     file_path.string(), bytes_written);\n    } else {\n      // 文本文件：直接写入字符串\n      bytes_written = co_await asio::async_write(file, asio::buffer(content), asio::use_awaitable);\n      Logger().debug(\"Successfully wrote text file: {}, size: {} bytes\", file_path.string(),\n                     bytes_written);\n    }\n\n    FileWriteResult result{.path = file_path.string(), .bytes_written = bytes_written};\n    co_return result;\n  } catch (const std::exception& e) {\n    co_return std::unexpected(format_file_error(\"Error writing\", file_path, e));\n  }\n}\n\nauto list_directory(const std::filesystem::path& dir_path,\n                    const std::vector<std::string>& extensions)\n    -> asio::awaitable<std::expected<DirectoryListResult, std::string>> {\n  try {\n    if (!std::filesystem::exists(dir_path)) {\n      co_return std::unexpected(\"Directory not found: \" + dir_path.string());\n    }\n\n    if (!std::filesystem::is_directory(dir_path)) {\n      co_return std::unexpected(\"Path is not a directory: \" + dir_path.string());\n    }\n\n    DirectoryListResult result{.path = dir_path.string()};\n\n    // 创建扩展名过滤函数\n    auto should_include_file = [&extensions](const std::string& file_ext) {\n      if (extensions.empty()) return true;\n      return std::ranges::any_of(extensions,\n                                 [&file_ext](const auto& ext) { return file_ext == ext; });\n    };\n\n    // 遍历目录\n    for (const auto& entry : std::filesystem::directory_iterator(dir_path)) {\n      DirectoryEntry dir_entry{.name = entry.path().filename().string(),\n                               .path = entry.path().string()};\n\n      auto last_write_time = std::filesystem::last_write_time(entry.path());\n      dir_entry.last_modified = Utils::Time::file_time_to_millis(last_write_time);\n\n      if (entry.is_directory()) {\n        dir_entry.type = \"directory\";\n        result.directories.push_back(std::move(dir_entry));\n      } else if (entry.is_regular_file()) {\n        std::string file_ext = entry.path().extension().string();\n        if (should_include_file(file_ext)) {\n          dir_entry.type = \"file\";\n          dir_entry.size = entry.file_size();\n          dir_entry.extension = file_ext;\n          result.files.push_back(std::move(dir_entry));\n        }\n      }\n    }\n\n    Logger().debug(\"Successfully listed directory: {}, {} directories, {} files\", dir_path.string(),\n                   result.directories.size(), result.files.size());\n    co_return result;\n  } catch (const std::exception& e) {\n    co_return std::unexpected(format_file_error(\"Error listing directory\", dir_path, e));\n  }\n}\n\nauto get_file_info(const std::filesystem::path& file_path)\n    -> asio::awaitable<std::expected<FileInfoResult, std::string>> {\n  try {\n    if (!std::filesystem::exists(file_path)) {\n      co_return FileInfoResult{.path = file_path.string(), .exists = false};\n    }\n\n    std::filesystem::file_status status = std::filesystem::status(file_path);\n\n    FileInfoResult result{.path = file_path.string(),\n                          .exists = true,\n                          .is_directory = std::filesystem::is_directory(status),\n                          .is_regular_file = std::filesystem::is_regular_file(status),\n                          .is_symlink = std::filesystem::is_symlink(status)};\n\n    if (result.is_regular_file) {\n      result.size = std::filesystem::file_size(file_path);\n      result.extension = file_path.extension().string();\n      result.filename = file_path.filename().string();\n    }\n\n    auto last_write_time = std::filesystem::last_write_time(file_path);\n    result.last_modified = Utils::Time::file_time_to_millis(last_write_time);\n\n    Logger().debug(\"Successfully got file info: {}\", file_path.string());\n    co_return result;\n  } catch (const std::exception& e) {\n    co_return std::unexpected(format_file_error(\"Error getting file info\", file_path, e));\n  }\n}\n\nauto delete_path(const std::filesystem::path& path, bool recursive)\n    -> asio::awaitable<std::expected<DeleteResult, std::string>> {\n  try {\n    DeleteResult result{.path = path.string()};\n\n    if (!std::filesystem::exists(path)) {\n      co_return std::unexpected(\"Path does not exist: \" + path.string());\n    }\n\n    // 递归计算要删除的内容统计\n    std::function<void(const std::filesystem::path&)> calculate_stats =\n        [&](const std::filesystem::path& p) {\n          if (std::filesystem::is_regular_file(p)) {\n            result.files_deleted++;\n            try {\n              result.total_bytes_freed += std::filesystem::file_size(p);\n            } catch (...) {\n              // 忽略文件大小获取错误\n            }\n          } else if (std::filesystem::is_directory(p)) {\n            result.directories_deleted++;\n            if (recursive) {\n              for (const auto& entry : std::filesystem::directory_iterator(p)) {\n                calculate_stats(entry.path());\n              }\n            }\n          }\n        };\n\n    // 先统计\n    calculate_stats(path);\n\n    // 执行删除\n    if (std::filesystem::is_regular_file(path)) {\n      std::filesystem::remove(path);\n      Logger().debug(\"Successfully deleted file: {}\", path.string());\n    } else if (std::filesystem::is_directory(path)) {\n      if (recursive) {\n        std::filesystem::remove_all(path);\n        Logger().debug(\"Successfully deleted directory recursively: {}\", path.string());\n      } else {\n        if (!std::filesystem::is_empty(path)) {\n          co_return std::unexpected(\"Directory is not empty and recursive=false: \" + path.string());\n        }\n        std::filesystem::remove(path);\n        Logger().debug(\"Successfully deleted empty directory: {}\", path.string());\n      }\n    }\n\n    Logger().debug(\"Delete operation completed: {} files, {} directories, {} bytes freed\",\n                   result.files_deleted, result.directories_deleted, result.total_bytes_freed);\n    co_return result;\n  } catch (const std::exception& e) {\n    co_return std::unexpected(format_file_error(\"Error deleting\", path, e));\n  }\n}\n\nauto move_path(const std::filesystem::path& source_path,\n               const std::filesystem::path& destination_path, bool overwrite)\n    -> asio::awaitable<std::expected<MoveResult, std::string>> {\n  co_return move_path_blocking(source_path, destination_path, overwrite);\n}\n\nauto move_path_blocking(const std::filesystem::path& source_path,\n                        const std::filesystem::path& destination_path, bool overwrite)\n    -> std::expected<MoveResult, std::string> {\n  try {\n    if (!std::filesystem::exists(source_path)) {\n      return std::unexpected(\"Source path does not exist: \" + source_path.string());\n    }\n\n    if (std::filesystem::exists(destination_path) && !overwrite) {\n      return std::unexpected(\"Destination already exists and overwrite is disabled: \" +\n                             destination_path.string());\n    }\n\n    // 确保目标目录存在\n    auto parent_path = destination_path.parent_path();\n    if (!parent_path.empty() && !std::filesystem::exists(parent_path)) {\n      std::filesystem::create_directories(parent_path);\n      Logger().debug(\"Created parent directories: {}\", parent_path.string());\n    }\n\n    MoveResult result{.source_path = source_path.string(),\n                      .destination_path = destination_path.string()};\n\n    // 获取文件/目录大小\n    if (std::filesystem::is_regular_file(source_path)) {\n      result.size = std::filesystem::file_size(source_path);\n    }\n\n    // 判断是重命名还是移动\n    auto source_parent = source_path.parent_path();\n    auto dest_parent = destination_path.parent_path();\n\n    if (source_parent == dest_parent) {\n      result.was_renamed = true;\n    } else {\n      result.was_moved = true;\n    }\n\n    // 先走 rename：同卷通常是元数据级移动，成本最低且行为最接近“原子移动”。\n    std::error_code rename_error;\n    std::filesystem::rename(source_path, destination_path, rename_error);\n    if (rename_error) {\n      if (!is_cross_device_error(rename_error)) {\n        return std::unexpected(\"Failed to move/rename path: \" + rename_error.message());\n      }\n\n      // 跨卷回退到 CopyFile2，再删除源路径；这是当前项目约定的高性能兼容路径。\n      if (std::filesystem::is_regular_file(source_path)) {\n        auto copy_result = copy_file_with_copyfile2(source_path, destination_path, overwrite);\n        if (!copy_result) {\n          return std::unexpected(copy_result.error());\n        }\n      } else if (std::filesystem::is_directory(source_path)) {\n        auto copy_result = copy_directory_with_copyfile2(source_path, destination_path, overwrite);\n        if (!copy_result) {\n          return std::unexpected(copy_result.error());\n        }\n      } else {\n        return std::unexpected(\"Unsupported source path type for move: \" + source_path.string());\n      }\n\n      std::error_code remove_error;\n      if (std::filesystem::is_directory(source_path)) {\n        std::filesystem::remove_all(source_path, remove_error);\n      } else {\n        std::filesystem::remove(source_path, remove_error);\n      }\n      if (remove_error) {\n        // copy 成功但 delete 失败时返回失败，避免上层误认为“已完整搬迁”。\n        return std::unexpected(\"Failed to remove source after CopyFile2 fallback: \" +\n                               remove_error.message());\n      }\n    }\n\n    if (result.was_renamed) {\n      Logger().debug(\"Successfully renamed: {} -> {}\", source_path.string(),\n                     destination_path.string());\n    } else {\n      Logger().debug(\"Successfully moved: {} -> {}\", source_path.string(),\n                     destination_path.string());\n    }\n\n    return result;\n  } catch (const std::exception& e) {\n    return std::unexpected(format_file_error(\"Error moving/renaming\", source_path, e));\n  }\n}\n\nauto copy_path(const std::filesystem::path& source_path,\n               const std::filesystem::path& destination_path, bool recursive, bool overwrite)\n    -> asio::awaitable<std::expected<CopyResult, std::string>> {\n  try {\n    if (!std::filesystem::exists(source_path)) {\n      co_return std::unexpected(\"Source path does not exist: \" + source_path.string());\n    }\n\n    if (std::filesystem::exists(destination_path) && !overwrite) {\n      co_return std::unexpected(\"Destination already exists and overwrite is disabled: \" +\n                                destination_path.string());\n    }\n\n    // 确保目标目录存在\n    auto parent_path = destination_path.parent_path();\n    if (!parent_path.empty() && !std::filesystem::exists(parent_path)) {\n      std::filesystem::create_directories(parent_path);\n      Logger().debug(\"Created parent directories: {}\", parent_path.string());\n    }\n\n    CopyResult result{.source_path = source_path.string(),\n                      .destination_path = destination_path.string(),\n                      .is_recursive_copy = recursive};\n\n    if (std::filesystem::is_regular_file(source_path)) {\n      // 复制文件\n      std::filesystem::copy_file(source_path, destination_path,\n                                 overwrite ? std::filesystem::copy_options::overwrite_existing\n                                           : std::filesystem::copy_options::none);\n      result.files_copied = 1;\n      result.total_bytes_copied = std::filesystem::file_size(source_path);\n      Logger().debug(\"Successfully copied file: {} -> {}\", source_path.string(),\n                     destination_path.string());\n    } else if (std::filesystem::is_directory(source_path)) {\n      if (recursive) {\n        // 递归复制目录\n        std::filesystem::copy_options options = std::filesystem::copy_options::recursive;\n        if (overwrite) {\n          options |= std::filesystem::copy_options::overwrite_existing;\n        }\n\n        std::filesystem::copy(source_path, destination_path, options);\n\n        // 统计复制的文件和目录\n        std::function<void(const std::filesystem::path&)> count_items =\n            [&](const std::filesystem::path& p) {\n              for (const auto& entry : std::filesystem::recursive_directory_iterator(p)) {\n                if (entry.is_regular_file()) {\n                  result.files_copied++;\n                  try {\n                    result.total_bytes_copied += entry.file_size();\n                  } catch (...) {\n                    // 忽略文件大小获取错误\n                  }\n                } else if (entry.is_directory()) {\n                  result.directories_copied++;\n                }\n              }\n            };\n\n        count_items(destination_path);\n        Logger().debug(\"Successfully copied directory recursively: {} -> {}\", source_path.string(),\n                       destination_path.string());\n      } else {\n        // 只创建目录，不复制内容\n        std::filesystem::create_directory(destination_path);\n        result.directories_copied = 1;\n        Logger().debug(\"Successfully created directory: {}\", destination_path.string());\n      }\n    }\n\n    Logger().debug(\"Copy operation completed: {} files, {} directories, {} bytes copied\",\n                   result.files_copied, result.directories_copied, result.total_bytes_copied);\n    co_return result;\n  } catch (const std::exception& e) {\n    co_return std::unexpected(format_file_error(\"Error copying\", source_path, e));\n  }\n}\n\n}  // namespace Utils::File"
  },
  {
    "path": "src/utils/file/file.ixx",
    "content": "module;\n\n#include <asio.hpp>\n\nexport module Utils.File;\n\nimport std;\n\nnamespace Utils::File {\n\n// 文件读取结果结构（原始数据）\nexport struct FileReadResult {\n  std::string path;         // 文件路径\n  std::vector<char> data;   // 原始文件数据\n  std::string mime_type;    // MIME类型\n  size_t original_size{0};  // 原始文件大小（字节）\n};\n\n// 文件读取结果结构（编码后，用于RPC等需要文本传输的场景）\nexport struct EncodedFileReadResult {\n  std::string path;         // 文件路径\n  std::string content;      // 文件内容（文本文件直接存储，二进制文件base64编码）\n  std::string mime_type;    // MIME类型\n  bool is_binary{false};    // 是否为二进制文件\n  size_t original_size{0};  // 原始文件大小（字节）\n};\n\n// 文件写入结果结构\nexport struct FileWriteResult {\n  std::string path;         // 文件路径\n  size_t bytes_written{0};  // 实际写入字节数\n};\n\n// 目录项结构\nexport struct DirectoryEntry {\n  std::string name;\n  std::string path;\n  std::string type;  // \"file\" or \"directory\"\n  size_t size{0};    // 文件大小，目录为0\n  std::string extension;\n  int64_t last_modified;  // Unix 时间戳（毫秒）\n};\n\n// 目录列表结果结构\nexport struct DirectoryListResult {\n  std::vector<DirectoryEntry> directories;\n  std::vector<DirectoryEntry> files;\n  std::string path;\n};\n\n// 文件信息结果结构\nexport struct FileInfoResult {\n  std::string path;\n  bool exists{false};\n  bool is_directory{false};\n  bool is_regular_file{false};\n  bool is_symlink{false};\n  size_t size{0};\n  std::string extension;\n  std::string filename;\n  int64_t last_modified;  // Unix 时间戳（毫秒）\n};\n\n// 删除操作结果结构\nexport struct DeleteResult {\n  size_t files_deleted{0};        // 删除的文件数量\n  size_t directories_deleted{0};  // 删除的目录数量\n  size_t total_bytes_freed{0};    // 释放的总字节数\n  std::string path;               // 被删除的路径\n};\n\n// 移动/重命名操作结果结构\nexport struct MoveResult {\n  std::string source_path;       // 源路径\n  std::string destination_path;  // 目标路径\n  bool was_renamed{false};       // 是否为重命名（同目录下）\n  bool was_moved{false};         // 是否为移动（跨目录）\n  size_t size{0};                // 移动的文件/目录大小\n};\n\n// 复制操作结果结构\nexport struct CopyResult {\n  std::string source_path;        // 源路径\n  std::string destination_path;   // 目标路径\n  size_t files_copied{0};         // 复制的文件数量\n  size_t directories_copied{0};   // 复制的目录数量\n  size_t total_bytes_copied{0};   // 复制的总字节数\n  bool is_recursive_copy{false};  // 是否为递归复制\n};\n\n// 异步读取文件（原始数据）\nexport auto read_file(const std::filesystem::path& file_path)\n    -> asio::awaitable<std::expected<FileReadResult, std::string>>;\n\n// 异步读取文件并编码（用于RPC等需要文本传输的场景）\nexport auto read_file_and_encode(const std::filesystem::path& file_path)\n    -> asio::awaitable<std::expected<EncodedFileReadResult, std::string>>;\n\n// 异步写入文件（支持文本和二进制/base64解码）\nexport auto write_file(const std::filesystem::path& file_path, const std::string& content,\n                       bool is_binary = false, bool overwrite = true)\n    -> asio::awaitable<std::expected<FileWriteResult, std::string>>;\n\n// 异步列出目录内容\nexport auto list_directory(const std::filesystem::path& dir_path,\n                           const std::vector<std::string>& extensions = {})\n    -> asio::awaitable<std::expected<DirectoryListResult, std::string>>;\n\n// 异步获取文件信息\nexport auto get_file_info(const std::filesystem::path& file_path)\n    -> asio::awaitable<std::expected<FileInfoResult, std::string>>;\n\n// 异步删除文件或目录\nexport auto delete_path(const std::filesystem::path& path, bool recursive = false)\n    -> asio::awaitable<std::expected<DeleteResult, std::string>>;\n\n// 异步移动或重命名文件/目录\nexport auto move_path(const std::filesystem::path& source_path,\n                      const std::filesystem::path& destination_path, bool overwrite = false)\n    -> asio::awaitable<std::expected<MoveResult, std::string>>;\n// 异步移动或重命名文件/目录（阻塞）\nexport auto move_path_blocking(const std::filesystem::path& source_path,\n                               const std::filesystem::path& destination_path,\n                               bool overwrite = false) -> std::expected<MoveResult, std::string>;\n\n// 异步复制文件或目录\nexport auto copy_path(const std::filesystem::path& source_path,\n                      const std::filesystem::path& destination_path, bool recursive = false,\n                      bool overwrite = false)\n    -> asio::awaitable<std::expected<CopyResult, std::string>>;\n\n}  // namespace Utils::File"
  },
  {
    "path": "src/utils/file/mime.cpp",
    "content": "module;\n\nmodule Utils.File.Mime;\n\nimport std;\nimport Utils.String;\n\nnamespace Utils::File::Mime {\n\n// MIME类型映射表\nconst std::unordered_map<std::string, std::string> mime_map = {\n    // 文本类型\n    {\".html\", \"text/html; charset=utf-8\"},\n    {\".htm\", \"text/html; charset=utf-8\"},\n    {\".css\", \"text/css; charset=utf-8\"},\n    {\".js\", \"application/javascript; charset=utf-8\"},\n    {\".json\", \"application/json; charset=utf-8\"},\n    {\".txt\", \"text/plain; charset=utf-8\"},\n    {\".xml\", \"application/xml; charset=utf-8\"},\n    {\".csv\", \"text/csv; charset=utf-8\"},\n\n    // 图像类型\n    {\".jpg\", \"image/jpeg\"},\n    {\".jpeg\", \"image/jpeg\"},\n    {\".png\", \"image/png\"},\n    {\".gif\", \"image/gif\"},\n    {\".svg\", \"image/svg+xml\"},\n    {\".webp\", \"image/webp\"},\n    {\".bmp\", \"image/bmp\"},\n    {\".ico\", \"image/x-icon\"},\n    {\".tiff\", \"image/tiff\"},\n    {\".tif\", \"image/tiff\"},\n\n    // 视频类型\n    {\".mp4\", \"video/mp4\"},\n    {\".webm\", \"video/webm\"},\n    {\".avi\", \"video/x-msvideo\"},\n    {\".mov\", \"video/quicktime\"},\n    {\".wmv\", \"video/x-ms-wmv\"},\n    {\".flv\", \"video/x-flv\"},\n    {\".mkv\", \"video/x-matroska\"},\n\n    // 音频类型\n    {\".mp3\", \"audio/mpeg\"},\n    {\".wav\", \"audio/wav\"},\n    {\".ogg\", \"audio/ogg\"},\n    {\".aac\", \"audio/aac\"},\n    {\".flac\", \"audio/flac\"},\n    {\".m4a\", \"audio/mp4\"},\n\n    // 文档类型\n    {\".pdf\", \"application/pdf\"},\n    {\".doc\", \"application/msword\"},\n    {\".docx\", \"application/vnd.openxmlformats-officedocument.wordprocessingml.document\"},\n    {\".xls\", \"application/vnd.ms-excel\"},\n    {\".xlsx\", \"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\"},\n    {\".ppt\", \"application/vnd.ms-powerpoint\"},\n    {\".pptx\", \"application/vnd.openxmlformats-officedocument.presentationml.presentation\"},\n\n    // 压缩文件\n    {\".zip\", \"application/zip\"},\n    {\".rar\", \"application/vnd.rar\"},\n    {\".7z\", \"application/x-7z-compressed\"},\n    {\".tar\", \"application/x-tar\"},\n    {\".gz\", \"application/gzip\"},\n\n    // 字体文件\n    {\".ttf\", \"font/ttf\"},\n    {\".otf\", \"font/otf\"},\n    {\".woff\", \"font/woff\"},\n    {\".woff2\", \"font/woff2\"},\n    {\".eot\", \"application/vnd.ms-fontobject\"},\n\n    // 其他常见类型\n    {\".exe\", \"application/octet-stream\"},\n    {\".dll\", \"application/octet-stream\"},\n    {\".bin\", \"application/octet-stream\"},\n    {\".iso\", \"application/octet-stream\"}};\n\nauto get_mime_type_by_extension(const std::string& extension) -> std::string {\n  auto lowercase_ext = Utils::String::ToLowerAscii(extension);\n\n  auto it = mime_map.find(lowercase_ext);\n  if (it != mime_map.end()) {\n    return it->second;\n  }\n\n  // 默认返回二进制流类型\n  return \"application/octet-stream\";\n}\n\nauto get_mime_type(const std::filesystem::path& file_path) -> std::string {\n  std::string extension = file_path.extension().string();\n  return get_mime_type_by_extension(extension);\n}\n\nauto get_mime_type(const std::string& file_path) -> std::string {\n  std::filesystem::path fs_path(file_path);\n  return get_mime_type(fs_path);\n}\n\n}  // namespace Utils::File::Mime\n"
  },
  {
    "path": "src/utils/file/mime.ixx",
    "content": "module;\n\nexport module Utils.File.Mime;\n\nimport std;\n\nnamespace Utils::File::Mime {\n\n// 根据文件扩展名获取MIME类型，未知类型返回\"application/octet-stream\"\nexport auto get_mime_type_by_extension(const std::string &extension) -> std::string;\n\n// 根据文件路径获取MIME类型，未知类型返回\"application/octet-stream\"\nexport auto get_mime_type(const std::filesystem::path &file_path) -> std::string;\n\n// 根据文件路径字符串获取MIME类型，未知类型返回\"application/octet-stream\"\nexport auto get_mime_type(const std::string &file_path) -> std::string;\n\n}  // namespace Utils::File::Mime"
  },
  {
    "path": "src/utils/graphics/capture.cpp",
    "content": "module;\n\n#include <windows.graphics.capture.interop.h>\n#include <windows.graphics.directx.direct3d11.interop.h>\n#include <winrt/Windows.Foundation.Metadata.h>\n#include <winrt/Windows.Foundation.h>\n#include <winrt/Windows.Graphics.Capture.h>\n#include <winrt/Windows.Graphics.DirectX.Direct3D11.h>\n\nmodule Utils.Graphics.Capture;\n\nimport std;\nimport Utils.Logger;\nimport <d3d11.h>;\nimport <wil/com.h>;\nimport <windows.h>;\n\nnamespace Utils::Graphics::Capture {\n\nauto create_capture_item_for_window(HWND target_window)\n    -> std::expected<winrt::Windows::Graphics::Capture::GraphicsCaptureItem, std::string> {\n  if (!target_window || !IsWindow(target_window)) {\n    return std::unexpected(\"Target window is invalid\");\n  }\n\n  auto interop =\n      winrt::get_activation_factory<winrt::Windows::Graphics::Capture::GraphicsCaptureItem,\n                                    IGraphicsCaptureItemInterop>();\n\n  winrt::Windows::Graphics::Capture::GraphicsCaptureItem capture_item{nullptr};\n  HRESULT hr = interop->CreateForWindow(\n      target_window, winrt::guid_of<ABI::Windows::Graphics::Capture::IGraphicsCaptureItem>(),\n      reinterpret_cast<void**>(winrt::put_abi(capture_item)));\n\n  if (FAILED(hr) || !capture_item) {\n    auto error_msg = std::format(\"Failed to create capture item, HRESULT: 0x{:08X}\",\n                                 static_cast<unsigned int>(hr));\n    Logger().error(error_msg);\n    return std::unexpected(error_msg);\n  }\n\n  return capture_item;\n}\n\nauto is_cursor_capture_control_supported() -> bool {\n  try {\n    return winrt::Windows::Foundation::Metadata::ApiInformation::IsPropertyPresent(\n        winrt::name_of<winrt::Windows::Graphics::Capture::GraphicsCaptureSession>(),\n        L\"IsCursorCaptureEnabled\");\n  } catch (...) {\n    return false;\n  }\n}\n\nauto is_border_control_supported() -> bool {\n  try {\n    return winrt::Windows::Foundation::Metadata::ApiInformation::IsPropertyPresent(\n        winrt::name_of<winrt::Windows::Graphics::Capture::GraphicsCaptureSession>(),\n        L\"IsBorderRequired\");\n  } catch (...) {\n    return false;\n  }\n}\n\nauto create_winrt_device(ID3D11Device* d3d_device)\n    -> std::expected<winrt::Windows::Graphics::DirectX::Direct3D11::IDirect3DDevice, std::string> {\n  if (!d3d_device) {\n    return std::unexpected(\"D3D device is null\");\n  }\n\n  // 获取DXGI设备接口\n  wil::com_ptr<IDXGIDevice> dxgi_device;\n  HRESULT hr = d3d_device->QueryInterface(IID_PPV_ARGS(dxgi_device.put()));\n  if (FAILED(hr)) {\n    auto error_msg =\n        std::format(\"Failed to get DXGI device, HRESULT: 0x{:08X}\", static_cast<unsigned int>(hr));\n    Logger().error(error_msg);\n    return std::unexpected(error_msg);\n  }\n\n  // 创建WinRT设备\n  winrt::com_ptr<::IInspectable> inspectable;\n  hr = CreateDirect3D11DeviceFromDXGIDevice(dxgi_device.get(), inspectable.put());\n  if (FAILED(hr)) {\n    auto error_msg = std::format(\"Failed to create WinRT device, HRESULT: 0x{:08X}\",\n                                 static_cast<unsigned int>(hr));\n    Logger().error(error_msg);\n    return std::unexpected(error_msg);\n  }\n\n  auto winrt_device =\n      inspectable.as<winrt::Windows::Graphics::DirectX::Direct3D11::IDirect3DDevice>();\n  if (!winrt_device) {\n    auto error_msg = \"Failed to get WinRT Direct3D device interface\";\n    Logger().error(error_msg);\n    return std::unexpected(error_msg);\n  }\n\n  return winrt_device;\n}\n\nauto get_capture_item_size(HWND target_window) -> std::expected<std::pair<int, int>, std::string> {\n  // 这里只创建 capture item 并读取 Size，不启动真正的捕获会话。\n  auto capture_item_result = create_capture_item_for_window(target_window);\n  if (!capture_item_result) {\n    return std::unexpected(capture_item_result.error());\n  }\n\n  auto size = capture_item_result->Size();\n  if (size.Width <= 0 || size.Height <= 0) {\n    return std::unexpected(\"Capture item size is invalid\");\n  }\n\n  return std::make_pair(size.Width, size.Height);\n}\n\nauto create_capture_session(\n    HWND target_window,\n    const winrt::Windows::Graphics::DirectX::Direct3D11::IDirect3DDevice& device, int width,\n    int height, FrameCallback frame_callback, int frame_pool_size,\n    const CaptureSessionOptions& options) -> std::expected<CaptureSession, std::string> {\n  if (!target_window || !IsWindow(target_window)) {\n    return std::unexpected(\"Target window is invalid\");\n  }\n\n  if (!frame_callback) {\n    return std::unexpected(\"Frame callback is null\");\n  }\n\n  CaptureSession session;\n  session.winrt_device = device;\n  session.frame_pool_size = std::max(frame_pool_size, 1);\n\n  auto capture_item_result = create_capture_item_for_window(target_window);\n  if (!capture_item_result) {\n    return std::unexpected(capture_item_result.error());\n  }\n  session.capture_item = std::move(*capture_item_result);\n\n  // 创建帧池\n  session.frame_pool =\n      winrt::Windows::Graphics::Capture::Direct3D11CaptureFramePool::CreateFreeThreaded(\n          device, winrt::Windows::Graphics::DirectX::DirectXPixelFormat::B8G8R8A8UIntNormalized,\n          session.frame_pool_size, {width, height});\n\n  if (!session.frame_pool) {\n    auto error_msg = \"Failed to create frame pool\";\n    Logger().error(error_msg);\n    return std::unexpected(error_msg);\n  }\n\n  // 设置帧到达回调\n  session.frame_token = session.frame_pool.FrameArrived([frame_callback](auto&& sender, auto&&) {\n    if (auto frame = sender.TryGetNextFrame()) {\n      frame_callback(frame);\n    }\n  });\n\n  // 创建捕获会话\n  session.session = session.frame_pool.CreateCaptureSession(session.capture_item);\n  if (!session.session) {\n    auto error_msg = \"Failed to create capture session\";\n    Logger().error(error_msg);\n    return std::unexpected(error_msg);\n  }\n\n  // 尝试禁用光标捕获（如果支持）\n  if (is_cursor_capture_control_supported()) {\n    session.session.IsCursorCaptureEnabled(options.capture_cursor);\n  } else if (!options.capture_cursor) {\n    // 如果不支持 IsCursorCaptureEnabled，且要求隐藏光标，则标记需要手动隐藏光标\n    session.need_hide_cursor = true;\n    Logger().warn(\n        \"IsCursorCaptureEnabled is not available, cursor visibility cannot be controlled \"\n        \"without manual fallback\");\n  }\n\n  // 尝试禁用边框（如果支持）\n  if (is_border_control_supported()) {\n    session.session.IsBorderRequired(options.border_required);\n  }\n\n  return session;\n}\n\nauto start_capture(CaptureSession& session) -> std::expected<void, std::string> {\n  if (!session.session) {\n    return std::unexpected(\"Capture session is null\");\n  }\n\n  try {\n    session.session.StartCapture();\n    return {};\n  } catch (const winrt::hresult_error& e) {\n    auto error_msg = std::format(\"WinRT error occurred while starting capture: {}\",\n                                 winrt::to_string(e.message()));\n    Logger().error(error_msg);\n    return std::unexpected(error_msg);\n  } catch (...) {\n    auto error_msg = \"Unknown error occurred while starting capture\";\n    Logger().error(error_msg);\n    return std::unexpected(error_msg);\n  }\n}\n\nauto stop_capture(CaptureSession& session) -> void {\n  if (session.session) {\n    session.session.Close();\n    session.session = nullptr;\n  }\n\n  if (session.frame_pool) {\n    session.frame_pool.FrameArrived(session.frame_token);\n    session.frame_pool.Close();\n    session.frame_pool = nullptr;\n  }\n\n  session.capture_item = nullptr;\n}\n\nauto cleanup_capture_session(CaptureSession& session) -> void {\n  stop_capture(session);\n\n  session.winrt_device = nullptr;\n}\n\nauto recreate_frame_pool(CaptureSession& session, int width, int height) -> void {\n  if (session.frame_pool) {\n    session.frame_pool.Recreate(\n        session.winrt_device,\n        winrt::Windows::Graphics::DirectX::DirectXPixelFormat::B8G8R8A8UIntNormalized,\n        std::max(session.frame_pool_size, 1), {width, height});\n  }\n}\n\ntemplate <typename T>\nauto get_dxgi_interface_from_object(const winrt::Windows::Foundation::IInspectable& object)\n    -> wil::com_ptr<T> {\n  auto access = object.as<Windows::Graphics::DirectX::Direct3D11::IDirect3DDxgiInterfaceAccess>();\n  wil::com_ptr<T> result;\n  winrt::check_hresult(access->GetInterface(IID_PPV_ARGS(result.put())));\n  return result;\n}\n\n// 显式实例化模板函数\ntemplate auto get_dxgi_interface_from_object<ID3D11Texture2D>(\n    const winrt::Windows::Foundation::IInspectable&) -> wil::com_ptr<ID3D11Texture2D>;\n\n}  // namespace Utils::Graphics::Capture\n"
  },
  {
    "path": "src/utils/graphics/capture.ixx",
    "content": "module;\n\n#include <winrt/Windows.Foundation.h>\n#include <winrt/Windows.Graphics.Capture.h>\n#include <winrt/Windows.Graphics.DirectX.Direct3D11.h>\n\nexport module Utils.Graphics.Capture;\n\nimport std;\nimport <d3d11.h>;\nimport <wil/com.h>;\nimport <windows.h>;\n\nnamespace Utils::Graphics::Capture {\n\n// 捕获会话\nexport struct CaptureSession {\n  winrt::Windows::Graphics::Capture::GraphicsCaptureItem capture_item{nullptr};\n  winrt::Windows::Graphics::Capture::Direct3D11CaptureFramePool frame_pool{nullptr};\n  winrt::Windows::Graphics::Capture::GraphicsCaptureSession session{nullptr};\n  winrt::Windows::Graphics::DirectX::Direct3D11::IDirect3DDevice winrt_device{nullptr};\n  winrt::event_token frame_token;\n  int frame_pool_size = 1;\n  bool need_hide_cursor = false;\n};\n\n// 捕获会话配置\nexport struct CaptureSessionOptions {\n  bool capture_cursor = false;\n  bool border_required = false;\n};\n\nexport using Direct3D11CaptureFrame = winrt::Windows::Graphics::Capture::Direct3D11CaptureFrame;\n\n// 帧回调函数类型\nusing FrameCallback = std::function<void(Direct3D11CaptureFrame)>;\n\n// 创建WinRT设备\nexport auto create_winrt_device(ID3D11Device* d3d_device)\n    -> std::expected<winrt::Windows::Graphics::DirectX::Direct3D11::IDirect3DDevice, std::string>;\n\n// 检测 WGC 会话属性支持能力\nexport auto is_cursor_capture_control_supported() -> bool;\nexport auto is_border_control_supported() -> bool;\n\n// 获取目标窗口对应的 WGC 实际捕获尺寸。\n// 录制模块用它作为“源帧真相”，避免只靠窗口矩形推算导致差 1px 的错配。\nexport auto get_capture_item_size(HWND target_window)\n    -> std::expected<std::pair<int, int>, std::string>;\n\n// 创建捕获会话\nexport auto create_capture_session(\n    HWND target_window,\n    const winrt::Windows::Graphics::DirectX::Direct3D11::IDirect3DDevice& device, int width,\n    int height, FrameCallback frame_callback, int frame_pool_size = 1,\n    const CaptureSessionOptions& options = {}) -> std::expected<CaptureSession, std::string>;\n\n// 开始捕获\nexport auto start_capture(CaptureSession& session) -> std::expected<void, std::string>;\n\n// 停止捕获\nexport auto stop_capture(CaptureSession& session) -> void;\n\n// 清理捕获资源\nexport auto cleanup_capture_session(CaptureSession& session) -> void;\n\n// 重建帧池\nexport auto recreate_frame_pool(CaptureSession& session, int width, int height) -> void;\n\n// 从WinRT对象获取DXGI接口的辅助函数\nexport template <typename T>\nauto get_dxgi_interface_from_object(const winrt::Windows::Foundation::IInspectable& object)\n    -> wil::com_ptr<T>;\n\n}  // namespace Utils::Graphics::Capture\n"
  },
  {
    "path": "src/utils/graphics/capture_region.cpp",
    "content": "module;\n\n#include <d3d11.h>\n\nmodule Utils.Graphics.CaptureRegion;\n\nimport std;\nimport <dwmapi.h>;\nimport <windows.h>;\n\nnamespace Utils::Graphics::CaptureRegion {\n\nauto get_capture_window_rect(HWND target_window, RECT& window_rect) -> bool {\n  return SUCCEEDED(DwmGetWindowAttribute(target_window, DWMWA_EXTENDED_FRAME_BOUNDS, &window_rect,\n                                         sizeof(window_rect))) ||\n         GetWindowRect(target_window, &window_rect);\n}\n\nauto calculate_client_crop_region(HWND target_window, UINT texture_width, UINT texture_height)\n    -> std::expected<CropRegion, std::string> {\n  if (!target_window || !IsWindow(target_window)) {\n    return std::unexpected(\"Target window is invalid\");\n  }\n\n  if (texture_width == 0 || texture_height == 0) {\n    return std::unexpected(\"Texture size is invalid\");\n  }\n\n  RECT window_rect{};\n  if (!get_capture_window_rect(target_window, window_rect)) {\n    return std::unexpected(\"Failed to get capture window rect\");\n  }\n\n  RECT client_rect{};\n  if (!GetClientRect(target_window, &client_rect)) {\n    return std::unexpected(\"Failed to get client rect\");\n  }\n\n  POINT client_origin{0, 0};\n  if (!ClientToScreen(target_window, &client_origin)) {\n    return std::unexpected(\"Failed to convert client origin to screen coordinates\");\n  }\n\n  const int client_width = client_rect.right - client_rect.left;\n  const int client_height = client_rect.bottom - client_rect.top;\n  if (client_width <= 0 || client_height <= 0) {\n    return std::unexpected(\"Computed client crop region is invalid\");\n  }\n\n  // 与基于 GetClientRect 且将宽高 floor 到偶数的编码输出尺寸一致，避免与编码器差 1px。\n  const int client_extent_w = (client_width / 2) * 2;\n  const int client_extent_h = (client_height / 2) * 2;\n  if (client_extent_w <= 0 || client_extent_h <= 0) {\n    return std::unexpected(\"Computed client crop region is invalid\");\n  }\n\n  const UINT left = client_origin.x > window_rect.left\n                        ? static_cast<UINT>(client_origin.x - window_rect.left)\n                        : 0;\n  const UINT top =\n      client_origin.y > window_rect.top ? static_cast<UINT>(client_origin.y - window_rect.top) : 0;\n\n  UINT width = 1;\n  if (texture_width > left) {\n    width = std::min(texture_width - left, static_cast<UINT>(client_extent_w));\n  }\n\n  UINT height = 1;\n  if (texture_height > top) {\n    height = std::min(texture_height - top, static_cast<UINT>(client_extent_h));\n  }\n\n  if (left + width > texture_width || top + height > texture_height) {\n    return std::unexpected(\"Computed client crop region is invalid\");\n  }\n\n  return CropRegion{\n      .left = left,\n      .top = top,\n      .width = width,\n      .height = height,\n  };\n}\n\nauto crop_texture_to_region(ID3D11Device* device, ID3D11DeviceContext* context,\n                            ID3D11Texture2D* source_texture, const CropRegion& region,\n                            wil::com_ptr<ID3D11Texture2D>& output_texture)\n    -> std::expected<ID3D11Texture2D*, std::string> {\n  if (!device || !context || !source_texture) {\n    return std::unexpected(\"Invalid D3D resources for texture crop\");\n  }\n\n  if (region.width == 0 || region.height == 0) {\n    return std::unexpected(\"Crop region size is invalid\");\n  }\n\n  D3D11_TEXTURE2D_DESC source_desc{};\n  source_texture->GetDesc(&source_desc);\n\n  if (region.left >= source_desc.Width || region.top >= source_desc.Height) {\n    return std::unexpected(\"Crop region origin is out of source bounds\");\n  }\n\n  UINT right = std::min(region.left + region.width, source_desc.Width);\n  UINT bottom = std::min(region.top + region.height, source_desc.Height);\n  UINT cropped_width = right - region.left;\n  UINT cropped_height = bottom - region.top;\n\n  if (cropped_width == 0 || cropped_height == 0) {\n    return std::unexpected(\"Crop region is empty after clamping\");\n  }\n\n  bool need_recreate = !output_texture;\n  if (!need_recreate) {\n    D3D11_TEXTURE2D_DESC output_desc{};\n    output_texture->GetDesc(&output_desc);\n    need_recreate = output_desc.Width != cropped_width || output_desc.Height != cropped_height ||\n                    output_desc.Format != source_desc.Format;\n  }\n\n  if (need_recreate) {\n    D3D11_TEXTURE2D_DESC target_desc = source_desc;\n    target_desc.Width = cropped_width;\n    target_desc.Height = cropped_height;\n    target_desc.Usage = D3D11_USAGE_DEFAULT;\n    target_desc.BindFlags = 0;\n    target_desc.CPUAccessFlags = 0;\n    target_desc.MiscFlags = 0;\n\n    output_texture = nullptr;\n    if (FAILED(device->CreateTexture2D(&target_desc, nullptr, output_texture.put()))) {\n      return std::unexpected(\"Failed to create cropped output texture\");\n    }\n  }\n\n  D3D11_BOX source_box{};\n  source_box.left = region.left;\n  source_box.top = region.top;\n  source_box.right = right;\n  source_box.bottom = bottom;\n  source_box.front = 0;\n  source_box.back = 1;\n\n  context->CopySubresourceRegion(output_texture.get(), 0, 0, 0, 0, source_texture, 0, &source_box);\n\n  return output_texture.get();\n}\n\n}  // namespace Utils::Graphics::CaptureRegion\n"
  },
  {
    "path": "src/utils/graphics/capture_region.ixx",
    "content": "module;\n\nexport module Utils.Graphics.CaptureRegion;\n\nimport std;\nimport <d3d11.h>;\nimport <wil/com.h>;\nimport <windows.h>;\n\nnamespace Utils::Graphics::CaptureRegion {\n\n// 捕获区域（以窗口纹理左上角为原点）\nexport struct CropRegion {\n  UINT left = 0;\n  UINT top = 0;\n  UINT width = 0;\n  UINT height = 0;\n};\n\n// 基于当前捕获纹理尺寸，计算目标窗口客户区在窗口纹理中的区域。\n// 客户区宽高按偶数向下对齐（与录制模块 calculate_capture_dimensions\n// 一致），便于与视频编码尺寸一致。\nexport auto calculate_client_crop_region(HWND target_window, UINT texture_width,\n                                         UINT texture_height)\n    -> std::expected<CropRegion, std::string>;\n\n// 将源纹理按指定区域裁剪到输出纹理（输出纹理可复用）\nexport auto crop_texture_to_region(ID3D11Device* device, ID3D11DeviceContext* context,\n                                   ID3D11Texture2D* source_texture, const CropRegion& region,\n                                   wil::com_ptr<ID3D11Texture2D>& output_texture)\n    -> std::expected<ID3D11Texture2D*, std::string>;\n\n}  // namespace Utils::Graphics::CaptureRegion\n"
  },
  {
    "path": "src/utils/graphics/d3d.cpp",
    "content": "module;\r\n\r\n#include <d3d11.h>\r\n#include <d3dcompiler.h>\r\n#include <dxgi.h>\r\n#include <wil/com.h>\r\n#include <windows.h>\r\n\r\n#include <iostream>\r\n\r\nmodule Utils.Graphics.D3D;\r\n\r\nimport std;\r\nimport Utils.Logger;\r\n\r\nnamespace Utils::Graphics::D3D {\r\n\r\nauto create_d3d_context(HWND hwnd, int width, int height)\r\n    -> std::expected<D3DContext, std::string> {\r\n  D3DContext context;\r\n\r\n  // 创建交换链描述\r\n  DXGI_SWAP_CHAIN_DESC scd = {};\r\n  scd.BufferCount = 2;\r\n  scd.BufferDesc.Width = width;\r\n  scd.BufferDesc.Height = height;\r\n  scd.BufferDesc.Format = DXGI_FORMAT_R8G8B8A8_UNORM;\r\n  scd.BufferDesc.RefreshRate.Numerator = 0;\r\n  scd.BufferDesc.RefreshRate.Denominator = 1;\r\n  scd.BufferUsage = DXGI_USAGE_RENDER_TARGET_OUTPUT;\r\n  scd.OutputWindow = hwnd;\r\n  scd.SampleDesc.Count = 1;\r\n  scd.SampleDesc.Quality = 0;\r\n  scd.Windowed = TRUE;\r\n  scd.SwapEffect = DXGI_SWAP_EFFECT_FLIP_DISCARD;\r\n  scd.Flags = DXGI_SWAP_CHAIN_FLAG_ALLOW_MODE_SWITCH;\r\n\r\n  // 创建设备和交换链\r\n  D3D_FEATURE_LEVEL featureLevel;\r\n  UINT createDeviceFlags = 0;\r\n#ifdef _DEBUG\r\n  createDeviceFlags |= D3D11_CREATE_DEVICE_DEBUG;\r\n#endif\r\n\r\n  HRESULT hr = D3D11CreateDeviceAndSwapChain(\r\n      nullptr, D3D_DRIVER_TYPE_HARDWARE, nullptr, createDeviceFlags, nullptr, 0, D3D11_SDK_VERSION,\r\n      &scd, context.swap_chain.put(), context.device.put(), &featureLevel, context.context.put());\r\n\r\n  if (FAILED(hr)) {\r\n    auto error_msg = std::format(\"Failed to create D3D device and swap chain, HRESULT: 0x{:08X}\",\r\n                                 static_cast<unsigned int>(hr));\r\n    Logger().error(error_msg);\r\n    return std::unexpected(error_msg);\r\n  }\r\n\r\n  // 创建渲染目标\r\n  if (auto result = create_render_target(context); !result) {\r\n    return std::unexpected(result.error());\r\n  }\r\n\r\n  return context;\r\n}\r\n\r\nauto create_headless_d3d_device()\r\n    -> std::expected<std::pair<wil::com_ptr<ID3D11Device>, wil::com_ptr<ID3D11DeviceContext>>,\r\n                     std::string> {\r\n  wil::com_ptr<ID3D11Device> device;\r\n  wil::com_ptr<ID3D11DeviceContext> context;\r\n\r\n  UINT createDeviceFlags = D3D11_CREATE_DEVICE_BGRA_SUPPORT;\r\n#ifdef _DEBUG\r\n  createDeviceFlags |= D3D11_CREATE_DEVICE_DEBUG;\r\n#endif\r\n\r\n  // 创建无头D3D设备（参考旧代码的实现）\r\n  HRESULT hr = D3D11CreateDevice(nullptr,                   // 使用默认适配器\r\n                                 D3D_DRIVER_TYPE_HARDWARE,  // 硬件驱动\r\n                                 nullptr,                   // 软件光栅化器句柄\r\n                                 createDeviceFlags,         // 创建标志\r\n                                 nullptr,                   // 功能级别数组\r\n                                 0,                         // 功能级别数组大小\r\n                                 D3D11_SDK_VERSION,         // SDK版本\r\n                                 device.put(),              // 输出设备\r\n                                 nullptr,                   // 输出功能级别\r\n                                 context.put()              // 输出设备上下文\r\n  );\r\n\r\n  if (FAILED(hr)) {\r\n    auto error_msg = std::format(\"Failed to create headless D3D device, HRESULT: 0x{:08X}\",\r\n                                 static_cast<unsigned int>(hr));\r\n    Logger().error(error_msg);\r\n    return std::unexpected(error_msg);\r\n  }\r\n\r\n  Logger().debug(\"Headless D3D device created successfully\");\r\n  return std::make_pair(device, context);\r\n}\r\n\r\nauto create_render_target(D3DContext& context) -> std::expected<void, std::string> {\r\n  // 获取后缓冲\r\n  wil::com_ptr<ID3D11Texture2D> backBuffer;\r\n  HRESULT hr = context.swap_chain->GetBuffer(0, IID_PPV_ARGS(&backBuffer));\r\n  if (FAILED(hr)) {\r\n    auto error_msg =\r\n        std::format(\"Failed to get back buffer, HRESULT: 0x{:08X}\", static_cast<unsigned int>(hr));\r\n    Logger().error(error_msg);\r\n    return std::unexpected(error_msg);\r\n  }\r\n\r\n  // 创建渲染目标视图\r\n  hr = context.device->CreateRenderTargetView(backBuffer.get(), nullptr, &context.render_target);\r\n  if (FAILED(hr)) {\r\n    auto error_msg = std::format(\"Failed to create render target view, HRESULT: 0x{:08X}\",\r\n                                 static_cast<unsigned int>(hr));\r\n    Logger().error(error_msg);\r\n    return std::unexpected(error_msg);\r\n  }\r\n\r\n  return {};\r\n}\r\n\r\nauto compile_shader(const std::string& shader_code, const std::string& entry_point,\r\n                    const std::string& target)\r\n    -> std::expected<wil::com_ptr<ID3DBlob>, std::string> {\r\n  wil::com_ptr<ID3DBlob> blob;\r\n  wil::com_ptr<ID3DBlob> errorBlob;\r\n\r\n  UINT compileFlags = D3DCOMPILE_ENABLE_STRICTNESS;\r\n#ifdef _DEBUG\r\n  compileFlags |= D3DCOMPILE_DEBUG;\r\n#endif\r\n\r\n  HRESULT hr = D3DCompile(shader_code.c_str(), shader_code.length(), nullptr, nullptr, nullptr,\r\n                          entry_point.c_str(), target.c_str(), compileFlags, 0, &blob, &errorBlob);\r\n\r\n  if (FAILED(hr)) {\r\n    std::string error_msg =\r\n        std::format(\"Shader compilation failed, HRESULT: 0x{:08X}\", static_cast<unsigned int>(hr));\r\n    if (errorBlob) {\r\n      std::string compiler_error(static_cast<char*>(errorBlob->GetBufferPointer()),\r\n                                 errorBlob->GetBufferSize());\r\n      error_msg += std::format(\" - Compiler error: {}\", compiler_error);\r\n    }\r\n    Logger().error(error_msg);\r\n    return std::unexpected(error_msg);\r\n  }\r\n\r\n  return blob;\r\n}\r\n\r\nauto create_basic_shader_resources(ID3D11Device* device, const std::string& vertex_code,\r\n                                   const std::string& pixel_code)\r\n    -> std::expected<ShaderResources, std::string> {\r\n  ShaderResources resources;\r\n\r\n  // 编译顶点着色器\r\n  auto vs_result = compile_shader(vertex_code, \"main\", \"vs_4_0\");\r\n  if (!vs_result) {\r\n    return std::unexpected(vs_result.error());\r\n  }\r\n  auto vsBlob = vs_result.value();\r\n\r\n  // 编译像素着色器\r\n  auto ps_result = compile_shader(pixel_code, \"main\", \"ps_4_0\");\r\n  if (!ps_result) {\r\n    return std::unexpected(ps_result.error());\r\n  }\r\n  auto psBlob = ps_result.value();\r\n\r\n  // 创建着色器\r\n  HRESULT hr = device->CreateVertexShader(vsBlob->GetBufferPointer(), vsBlob->GetBufferSize(),\r\n                                          nullptr, &resources.vertex_shader);\r\n  if (FAILED(hr)) {\r\n    auto error_msg = std::format(\"Failed to create vertex shader, HRESULT: 0x{:08X}\",\r\n                                 static_cast<unsigned int>(hr));\r\n    return std::unexpected(error_msg);\r\n  }\r\n\r\n  hr = device->CreatePixelShader(psBlob->GetBufferPointer(), psBlob->GetBufferSize(), nullptr,\r\n                                 &resources.pixel_shader);\r\n  if (FAILED(hr)) {\r\n    auto error_msg = std::format(\"Failed to create pixel shader, HRESULT: 0x{:08X}\",\r\n                                 static_cast<unsigned int>(hr));\r\n    return std::unexpected(error_msg);\r\n  }\r\n\r\n  // 创建输入布局（基本的位置+纹理坐标）\r\n  D3D11_INPUT_ELEMENT_DESC layout[] = {\r\n      {\"POSITION\", 0, DXGI_FORMAT_R32G32_FLOAT, 0, 0, D3D11_INPUT_PER_VERTEX_DATA, 0},\r\n      {\"TEXCOORD\", 0, DXGI_FORMAT_R32G32_FLOAT, 0, 8, D3D11_INPUT_PER_VERTEX_DATA, 0}};\r\n\r\n  hr = device->CreateInputLayout(layout, 2, vsBlob->GetBufferPointer(), vsBlob->GetBufferSize(),\r\n                                 &resources.input_layout);\r\n  if (FAILED(hr)) {\r\n    auto error_msg = std::format(\"Failed to create input layout, HRESULT: 0x{:08X}\",\r\n                                 static_cast<unsigned int>(hr));\r\n    return std::unexpected(error_msg);\r\n  }\r\n\r\n  // 创建基本的采样器和混合状态\r\n  auto sampler_result = create_linear_sampler(device);\r\n  if (!sampler_result) {\r\n    return std::unexpected(sampler_result.error());\r\n  }\r\n  resources.sampler = sampler_result.value();\r\n\r\n  auto blend_result = create_alpha_blend_state(device);\r\n  if (!blend_result) {\r\n    return std::unexpected(blend_result.error());\r\n  }\r\n  resources.blend_state = blend_result.value();\r\n\r\n  return resources;\r\n}\r\n\r\nauto create_viewport_shader_resources(ID3D11Device* device, const std::string& vertex_code,\r\n                                      const std::string& pixel_code)\r\n    -> std::expected<ShaderResources, std::string> {\r\n  ShaderResources resources;\r\n\r\n  // 编译着色器\r\n  auto vs_result = compile_shader(vertex_code, \"main\", \"vs_4_0\");\r\n  if (!vs_result) {\r\n    return std::unexpected(vs_result.error());\r\n  }\r\n  auto vsBlob = vs_result.value();\r\n\r\n  auto ps_result = compile_shader(pixel_code, \"main\", \"ps_4_0\");\r\n  if (!ps_result) {\r\n    return std::unexpected(ps_result.error());\r\n  }\r\n  auto psBlob = ps_result.value();\r\n\r\n  // 创建着色器\r\n  HRESULT hr = device->CreateVertexShader(vsBlob->GetBufferPointer(), vsBlob->GetBufferSize(),\r\n                                          nullptr, &resources.vertex_shader);\r\n  if (FAILED(hr)) {\r\n    auto error_msg = std::format(\"Failed to create vertex shader, HRESULT: 0x{:08X}\",\r\n                                 static_cast<unsigned int>(hr));\r\n    return std::unexpected(error_msg);\r\n  }\r\n\r\n  hr = device->CreatePixelShader(psBlob->GetBufferPointer(), psBlob->GetBufferSize(), nullptr,\r\n                                 &resources.pixel_shader);\r\n  if (FAILED(hr)) {\r\n    auto error_msg = std::format(\"Failed to create pixel shader, HRESULT: 0x{:08X}\",\r\n                                 static_cast<unsigned int>(hr));\r\n    return std::unexpected(error_msg);\r\n  }\r\n\r\n  // 创建视口框的输入布局（位置+颜色）\r\n  D3D11_INPUT_ELEMENT_DESC layout[] = {\r\n      {\"POSITION\", 0, DXGI_FORMAT_R32G32_FLOAT, 0, 0, D3D11_INPUT_PER_VERTEX_DATA, 0},\r\n      {\"COLOR\", 0, DXGI_FORMAT_R32G32B32A32_FLOAT, 0, 8, D3D11_INPUT_PER_VERTEX_DATA, 0}};\r\n\r\n  hr = device->CreateInputLayout(layout, 2, vsBlob->GetBufferPointer(), vsBlob->GetBufferSize(),\r\n                                 &resources.input_layout);\r\n  if (FAILED(hr)) {\r\n    auto error_msg = std::format(\"Failed to create input layout, HRESULT: 0x{:08X}\",\r\n                                 static_cast<unsigned int>(hr));\r\n    return std::unexpected(error_msg);\r\n  }\r\n\r\n  return resources;\r\n}\r\n\r\nauto create_vertex_buffer(ID3D11Device* device, const void* vertices, size_t vertex_count,\r\n                          size_t vertex_size, bool dynamic)\r\n    -> std::expected<wil::com_ptr<ID3D11Buffer>, std::string> {\r\n  D3D11_BUFFER_DESC bd = {};\r\n  bd.Usage = dynamic ? D3D11_USAGE_DYNAMIC : D3D11_USAGE_DEFAULT;\r\n  bd.ByteWidth = static_cast<UINT>(vertex_count * vertex_size);\r\n  bd.BindFlags = D3D11_BIND_VERTEX_BUFFER;\r\n  bd.CPUAccessFlags = dynamic ? D3D11_CPU_ACCESS_WRITE : 0;\r\n\r\n  D3D11_SUBRESOURCE_DATA initData = {};\r\n  initData.pSysMem = vertices;\r\n\r\n  wil::com_ptr<ID3D11Buffer> buffer;\r\n  HRESULT hr = device->CreateBuffer(&bd, vertices ? &initData : nullptr, &buffer);\r\n  if (FAILED(hr)) {\r\n    auto error_msg = std::format(\"Failed to create vertex buffer, HRESULT: 0x{:08X}\",\r\n                                 static_cast<unsigned int>(hr));\r\n    Logger().error(error_msg);\r\n    return std::unexpected(error_msg);\r\n  }\r\n\r\n  return buffer;\r\n}\r\n\r\nauto create_linear_sampler(ID3D11Device* device)\r\n    -> std::expected<wil::com_ptr<ID3D11SamplerState>, std::string> {\r\n  D3D11_SAMPLER_DESC samplerDesc = {};\r\n  samplerDesc.Filter = D3D11_FILTER_MIN_MAG_MIP_LINEAR;\r\n  samplerDesc.AddressU = D3D11_TEXTURE_ADDRESS_CLAMP;\r\n  samplerDesc.AddressV = D3D11_TEXTURE_ADDRESS_CLAMP;\r\n  samplerDesc.AddressW = D3D11_TEXTURE_ADDRESS_CLAMP;\r\n  samplerDesc.ComparisonFunc = D3D11_COMPARISON_NEVER;\r\n  samplerDesc.MinLOD = 0;\r\n  samplerDesc.MaxLOD = D3D11_FLOAT32_MAX;\r\n\r\n  wil::com_ptr<ID3D11SamplerState> sampler;\r\n  HRESULT hr = device->CreateSamplerState(&samplerDesc, &sampler);\r\n  if (FAILED(hr)) {\r\n    auto error_msg = std::format(\"Failed to create sampler state, HRESULT: 0x{:08X}\",\r\n                                 static_cast<unsigned int>(hr));\r\n    return std::unexpected(error_msg);\r\n  }\r\n\r\n  return sampler;\r\n}\r\n\r\nauto create_alpha_blend_state(ID3D11Device* device)\r\n    -> std::expected<wil::com_ptr<ID3D11BlendState>, std::string> {\r\n  D3D11_BLEND_DESC blendDesc = {};\r\n  blendDesc.RenderTarget[0].BlendEnable = TRUE;\r\n  blendDesc.RenderTarget[0].SrcBlend = D3D11_BLEND_SRC_ALPHA;\r\n  blendDesc.RenderTarget[0].DestBlend = D3D11_BLEND_INV_SRC_ALPHA;\r\n  blendDesc.RenderTarget[0].BlendOp = D3D11_BLEND_OP_ADD;\r\n  blendDesc.RenderTarget[0].SrcBlendAlpha = D3D11_BLEND_ONE;\r\n  blendDesc.RenderTarget[0].DestBlendAlpha = D3D11_BLEND_ZERO;\r\n  blendDesc.RenderTarget[0].BlendOpAlpha = D3D11_BLEND_OP_ADD;\r\n  blendDesc.RenderTarget[0].RenderTargetWriteMask = D3D11_COLOR_WRITE_ENABLE_ALL;\r\n\r\n  wil::com_ptr<ID3D11BlendState> blendState;\r\n  HRESULT hr = device->CreateBlendState(&blendDesc, &blendState);\r\n  if (FAILED(hr)) {\r\n    auto error_msg = std::format(\"Failed to create blend state, HRESULT: 0x{:08X}\",\r\n                                 static_cast<unsigned int>(hr));\r\n    return std::unexpected(error_msg);\r\n  }\r\n\r\n  return blendState;\r\n}\r\n\r\nauto resize_swap_chain(D3DContext& context, int width, int height)\r\n    -> std::expected<void, std::string> {\r\n  // 释放渲染目标\r\n  context.render_target.reset();\r\n\r\n  // 调整交换链大小\r\n  HRESULT hr = context.swap_chain->ResizeBuffers(0, width, height, DXGI_FORMAT_UNKNOWN, 0);\r\n  if (FAILED(hr)) {\r\n    auto error_msg = std::format(\"Failed to resize swap chain, HRESULT: 0x{:08X}\",\r\n                                 static_cast<unsigned int>(hr));\r\n    Logger().error(error_msg);\r\n    return std::unexpected(error_msg);\r\n  }\r\n\r\n  // 重新创建渲染目标\r\n  return create_render_target(context);\r\n}\r\n\r\nauto cleanup_d3d_context(D3DContext& context) -> void {\r\n  if (context.context) {\r\n    context.context->ClearState();\r\n    context.context->Flush();\r\n  }\r\n\r\n  context.render_target.reset();\r\n  context.swap_chain.reset();\r\n  context.context.reset();\r\n  context.device.reset();\r\n}\r\n\r\nauto cleanup_shader_resources(ShaderResources& resources) -> void {\r\n  resources.vertex_shader.reset();\r\n  resources.pixel_shader.reset();\r\n  resources.input_layout.reset();\r\n  resources.vertex_buffer.reset();\r\n  resources.sampler.reset();\r\n  resources.blend_state.reset();\r\n}\r\n\r\n}  // namespace Utils::Graphics::D3D"
  },
  {
    "path": "src/utils/graphics/d3d.ixx",
    "content": "module;\r\n\r\n#include <d3d11.h>\r\n#include <d3dcompiler.h>\r\n#include <dxgi.h>\r\n#include <wil/com.h>\r\n#include <windows.h>\r\n\r\nexport module Utils.Graphics.D3D;\r\n\r\nimport std;\r\n\r\nnamespace Utils::Graphics::D3D {\r\n\r\n// D3D设备上下文\r\nexport struct D3DContext {\r\n  wil::com_ptr<ID3D11Device> device;\r\n  wil::com_ptr<ID3D11DeviceContext> context;\r\n  wil::com_ptr<IDXGISwapChain> swap_chain;\r\n  wil::com_ptr<ID3D11RenderTargetView> render_target;\r\n};\r\n\r\n// 着色器资源\r\nexport struct ShaderResources {\r\n  wil::com_ptr<ID3D11VertexShader> vertex_shader;\r\n  wil::com_ptr<ID3D11PixelShader> pixel_shader;\r\n  wil::com_ptr<ID3D11InputLayout> input_layout;\r\n  wil::com_ptr<ID3D11Buffer> vertex_buffer;\r\n  wil::com_ptr<ID3D11SamplerState> sampler;\r\n  wil::com_ptr<ID3D11BlendState> blend_state;\r\n};\r\n\r\n// 创建D3D设备和交换链\r\nexport auto create_d3d_context(HWND hwnd, int width, int height)\r\n    -> std::expected<D3DContext, std::string>;\r\n\r\n// 创建无头D3D设备（仅设备和上下文，无交换链）\r\nexport auto create_headless_d3d_device()\r\n    -> std::expected<std::pair<wil::com_ptr<ID3D11Device>, wil::com_ptr<ID3D11DeviceContext>>,\r\n                     std::string>;\r\n\r\n// 创建渲染目标\r\nexport auto create_render_target(D3DContext& context) -> std::expected<void, std::string>;\r\n\r\n// 编译着色器\r\nexport auto compile_shader(const std::string& shader_code, const std::string& entry_point,\r\n                           const std::string& target)\r\n    -> std::expected<wil::com_ptr<ID3DBlob>, std::string>;\r\n\r\n// 创建基本的着色器资源\r\nexport auto create_basic_shader_resources(ID3D11Device* device, const std::string& vertex_code,\r\n                                          const std::string& pixel_code)\r\n    -> std::expected<ShaderResources, std::string>;\r\n\r\n// 创建视口框着色器资源\r\nexport auto create_viewport_shader_resources(ID3D11Device* device, const std::string& vertex_code,\r\n                                             const std::string& pixel_code)\r\n    -> std::expected<ShaderResources, std::string>;\r\n\r\n// 创建顶点缓冲区\r\nexport auto create_vertex_buffer(ID3D11Device* device, const void* vertices, size_t vertex_count,\r\n                                 size_t vertex_size, bool dynamic = false)\r\n    -> std::expected<wil::com_ptr<ID3D11Buffer>, std::string>;\r\n\r\n// 创建采样器状态\r\nexport auto create_linear_sampler(ID3D11Device* device)\r\n    -> std::expected<wil::com_ptr<ID3D11SamplerState>, std::string>;\r\n\r\n// 创建混合状态\r\nexport auto create_alpha_blend_state(ID3D11Device* device)\r\n    -> std::expected<wil::com_ptr<ID3D11BlendState>, std::string>;\r\n\r\n// 调整交换链大小\r\nexport auto resize_swap_chain(D3DContext& context, int width, int height)\r\n    -> std::expected<void, std::string>;\r\n\r\n// 清理D3D上下文\r\nexport auto cleanup_d3d_context(D3DContext& context) -> void;\r\n\r\n// 清理着色器资源\r\nexport auto cleanup_shader_resources(ShaderResources& resources) -> void;\r\n\r\n}  // namespace Utils::Graphics::D3D"
  },
  {
    "path": "src/utils/image/image.cpp",
    "content": "module;\n\n#include <wil/com.h>\n\nmodule Utils.Image;\n\nimport std;\nimport Utils.Logger;\nimport <shlwapi.h>;\nimport <webp/encode.h>;\nimport <webp/types.h>;\nimport <wincodec.h>;\nimport <windows.h>;\nimport <winerror.h>;\n\nnamespace Utils::Image {\n\n// 格式化HRESULT错误信息\nauto format_hresult(HRESULT hr, const std::string& context) -> std::string {\n  char* message_buffer = nullptr;\n  auto size = FormatMessageA(\n      FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS,\n      nullptr, static_cast<DWORD>(hr), MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT),\n      reinterpret_cast<LPSTR>(&message_buffer), 0, nullptr);\n\n  std::string message;\n  if (size > 0 && message_buffer) {\n    message = std::format(\"{} (HRESULT: 0x{:08X}): {}\", context, static_cast<unsigned int>(hr),\n                          message_buffer);\n    LocalFree(message_buffer);\n  } else {\n    message = std::format(\"{} (HRESULT: 0x{:08X})\", context, static_cast<unsigned int>(hr));\n  }\n  return message;\n}\n\n// 获取MIME类型\nauto get_mime_type(IWICBitmapDecoder* decoder) -> std::string {\n  GUID container_format;\n  if (FAILED(decoder->GetContainerFormat(&container_format))) {\n    return \"application/octet-stream\";\n  }\n\n  if (IsEqualGUID(container_format, GUID_ContainerFormatBmp)) {\n    return \"image/bmp\";\n  }\n  if (IsEqualGUID(container_format, GUID_ContainerFormatPng)) {\n    return \"image/png\";\n  }\n  if (IsEqualGUID(container_format, GUID_ContainerFormatIco)) {\n    return \"image/x-icon\";\n  }\n  if (IsEqualGUID(container_format, GUID_ContainerFormatJpeg)) {\n    return \"image/jpeg\";\n  }\n  if (IsEqualGUID(container_format, GUID_ContainerFormatGif)) {\n    return \"image/gif\";\n  }\n  if (IsEqualGUID(container_format, GUID_ContainerFormatTiff)) {\n    return \"image/tiff\";\n  }\n  if (IsEqualGUID(container_format, GUID_ContainerFormatWmp)) {\n    return \"image/vnd.ms-photo\";\n  }\n  if (IsEqualGUID(container_format, GUID_ContainerFormatDds)) {\n    return \"image/vnd.ms-dds\";\n  }\n  if (IsEqualGUID(container_format, GUID_ContainerFormatAdng)) {\n    return \"image/apng\";\n  }\n  if (IsEqualGUID(container_format, GUID_ContainerFormatHeif)) {\n    return \"image/heif\";\n  }\n  if (IsEqualGUID(container_format, GUID_ContainerFormatWebp)) {\n    return \"image/webp\";\n  }\n  if (IsEqualGUID(container_format, GUID_ContainerFormatRaw)) {\n    return \"image/x-raw\";\n  }\n\n  return \"application/octet-stream\";\n}\n\n// 计算缩放尺寸（按短边等比例缩放）\nauto calculate_scaled_size(uint32_t original_width, uint32_t original_height,\n                           uint32_t short_edge_size) -> std::pair<uint32_t, uint32_t> {\n  // 判断哪边是短边\n  uint32_t short_edge = std::min(original_width, original_height);\n\n  // 如果短边已经小于或等于目标尺寸，不缩放\n  if (short_edge <= short_edge_size) {\n    return {original_width, original_height};\n  }\n\n  // 计算缩放比例（基于短边）\n  double scale = static_cast<double>(short_edge_size) / short_edge;\n\n  // 等比例计算两边\n  uint32_t new_width = static_cast<uint32_t>(original_width * scale);\n  uint32_t new_height = static_cast<uint32_t>(original_height * scale);\n\n  // 确保至少为1像素\n  new_width = std::max(new_width, 1u);\n  new_height = std::max(new_height, 1u);\n\n  return {new_width, new_height};\n}\n\n// 创建WIC工厂\nauto create_factory() -> std::expected<WICFactory, std::string> {\n  try {\n    auto factory = wil::CoCreateInstance<IWICImagingFactory>(CLSID_WICImagingFactory);\n    return factory;\n  } catch (const wil::ResultException& e) {\n    return std::unexpected(format_hresult(e.GetErrorCode(), \"Failed to create WIC factory\"));\n  }\n}\n\n// 获取当前线程的WIC工厂\nauto get_thread_wic_factory() -> std::expected<WICFactory, std::string> {\n  if (!thread_wic_factory) {\n    if (!thread_com_init.has_value()) {\n      thread_com_init = wil::CoInitializeEx(COINIT_MULTITHREADED);\n    }\n\n    auto factory_result = create_factory();\n    if (!factory_result) {\n      return std::unexpected(factory_result.error());\n    }\n    thread_wic_factory = std::move(factory_result.value());\n  }\n\n  return thread_wic_factory;\n}\n\n// 获取图像信息\nauto get_image_info(IWICImagingFactory* factory, const std::filesystem::path& path)\n    -> std::expected<ImageInfo, std::string> {\n  if (!factory) {\n    return std::unexpected(\"WIC factory is null\");\n  }\n\n  if (!std::filesystem::exists(path)) {\n    return std::unexpected(\"File does not exist: \" + path.string());\n  }\n\n  try {\n    // 创建解码器\n    wil::com_ptr<IWICBitmapDecoder> decoder;\n    THROW_IF_FAILED(factory->CreateDecoderFromFilename(\n        path.c_str(), nullptr, GENERIC_READ, WICDecodeMetadataCacheOnDemand, decoder.put()));\n\n    // 获取帧\n    wil::com_ptr<IWICBitmapFrameDecode> frame;\n    THROW_IF_FAILED(decoder->GetFrame(0, frame.put()));\n\n    // 获取尺寸\n    UINT width, height;\n    THROW_IF_FAILED(frame->GetSize(&width, &height));\n\n    // 获取像素格式\n    GUID pixel_format;\n    THROW_IF_FAILED(frame->GetPixelFormat(&pixel_format));\n\n    // 获取MIME类型\n    auto mime_type = get_mime_type(decoder.get());\n\n    return ImageInfo{static_cast<uint32_t>(width), static_cast<uint32_t>(height), pixel_format,\n                     mime_type};\n  } catch (const wil::ResultException& e) {\n    return std::unexpected(format_hresult(e.GetErrorCode(), \"WIC operation failed\"));\n  } catch (const std::exception& e) {\n    return std::unexpected(std::string(\"Exception: \") + e.what());\n  }\n}\n\n// 加载图像帧\nauto load_bitmap_frame(IWICImagingFactory* factory, const std::filesystem::path& path)\n    -> std::expected<wil::com_ptr<IWICBitmapFrameDecode>, std::string> {\n  if (!factory) {\n    return std::unexpected(\"WIC factory is null\");\n  }\n\n  if (!std::filesystem::exists(path)) {\n    return std::unexpected(\"File does not exist: \" + path.string());\n  }\n\n  try {\n    // 创建解码器\n    wil::com_ptr<IWICBitmapDecoder> decoder;\n    THROW_IF_FAILED(factory->CreateDecoderFromFilename(\n        path.c_str(), nullptr, GENERIC_READ, WICDecodeMetadataCacheOnDemand, decoder.put()));\n\n    // 获取帧\n    wil::com_ptr<IWICBitmapFrameDecode> frame;\n    THROW_IF_FAILED(decoder->GetFrame(0, frame.put()));\n\n    return frame;\n  } catch (const wil::ResultException& e) {\n    return std::unexpected(format_hresult(e.GetErrorCode(), \"WIC operation failed\"));\n  } catch (const std::exception& e) {\n    return std::unexpected(std::string(\"Exception: \") + e.what());\n  }\n}\n\n// 转换为WIC位图\nauto convert_to_bitmap(IWICImagingFactory* factory, IWICBitmapSource* source)\n    -> std::expected<wil::com_ptr<IWICBitmap>, std::string> {\n  if (!factory) {\n    return std::unexpected(\"WIC factory is null\");\n  }\n\n  if (!source) {\n    return std::unexpected(\"Source bitmap is null\");\n  }\n\n  try {\n    wil::com_ptr<IWICBitmap> bitmap;\n    THROW_IF_FAILED(factory->CreateBitmapFromSource(source, WICBitmapCacheOnDemand, bitmap.put()));\n\n    return bitmap;\n  } catch (const wil::ResultException& e) {\n    return std::unexpected(format_hresult(e.GetErrorCode(), \"WIC operation failed\"));\n  } catch (const std::exception& e) {\n    return std::unexpected(std::string(\"Exception: \") + e.what());\n  }\n}\n\n// 缩放图像\nauto scale_bitmap(IWICImagingFactory* factory, IWICBitmapSource* source, uint32_t short_edge_size)\n    -> std::expected<wil::com_ptr<IWICBitmap>, std::string> {\n  if (!factory) {\n    return std::unexpected(\"WIC factory is null\");\n  }\n\n  if (!source) {\n    return std::unexpected(\"Source bitmap is null\");\n  }\n\n  try {\n    // 获取原始尺寸\n    UINT original_width, original_height;\n    THROW_IF_FAILED(source->GetSize(&original_width, &original_height));\n\n    // 计算缩放后尺寸\n    auto [new_width, new_height] =\n        calculate_scaled_size(original_width, original_height, short_edge_size);\n\n    // 如果尺寸相同，直接转换\n    if (new_width == original_width && new_height == original_height) {\n      return convert_to_bitmap(factory, source);\n    }\n\n    // 创建缩放器\n    wil::com_ptr<IWICBitmapScaler> scaler;\n    THROW_IF_FAILED(factory->CreateBitmapScaler(scaler.put()));\n\n    // 初始化缩放器\n    THROW_IF_FAILED(\n        scaler->Initialize(source, new_width, new_height, WICBitmapInterpolationModeFant));\n\n    // 转换为位图\n    return convert_to_bitmap(factory, scaler.get());\n  } catch (const wil::ResultException& e) {\n    return std::unexpected(format_hresult(e.GetErrorCode(), \"WIC operation failed\"));\n  } catch (const std::exception& e) {\n    return std::unexpected(std::string(\"Exception: \") + e.what());\n  }\n}\n\nauto convert_to_bgra_bitmap(IWICImagingFactory* factory, IWICBitmapSource* source)\n    -> std::expected<wil::com_ptr<IWICBitmap>, std::string> {\n  if (!factory) {\n    return std::unexpected(\"WIC factory is null\");\n  }\n\n  if (!source) {\n    return std::unexpected(\"Source bitmap is null\");\n  }\n\n  try {\n    wil::com_ptr<IWICFormatConverter> converter;\n    THROW_IF_FAILED(factory->CreateFormatConverter(converter.put()));\n    THROW_IF_FAILED(converter->Initialize(source, GUID_WICPixelFormat32bppBGRA,\n                                          WICBitmapDitherTypeNone, nullptr, 0.0,\n                                          WICBitmapPaletteTypeCustom));\n\n    return convert_to_bitmap(factory, converter.get());\n  } catch (const wil::ResultException& e) {\n    return std::unexpected(format_hresult(e.GetErrorCode(), \"WIC operation failed\"));\n  } catch (const std::exception& e) {\n    return std::unexpected(std::string(\"Exception: \") + e.what());\n  }\n}\n\nauto copy_bgra_bitmap_data(IWICBitmap* bitmap) -> std::expected<BGRABitmapData, std::string> {\n  if (!bitmap) {\n    return std::unexpected(\"Bitmap is null\");\n  }\n\n  try {\n    UINT width = 0;\n    UINT height = 0;\n    THROW_IF_FAILED(bitmap->GetSize(&width, &height));\n\n    WICRect rect = {0, 0, static_cast<INT>(width), static_cast<INT>(height)};\n    wil::com_ptr<IWICBitmapLock> bitmap_lock;\n    THROW_IF_FAILED(bitmap->Lock(&rect, WICBitmapLockRead, bitmap_lock.put()));\n\n    UINT stride = 0;\n    UINT data_size = 0;\n    BYTE* data = nullptr;\n    THROW_IF_FAILED(bitmap_lock->GetStride(&stride));\n    THROW_IF_FAILED(bitmap_lock->GetDataPointer(&data_size, &data));\n\n    if (!data || data_size == 0) {\n      return std::unexpected(\"Bitmap data pointer is empty\");\n    }\n\n    BGRABitmapData result;\n    result.width = static_cast<uint32_t>(width);\n    result.height = static_cast<uint32_t>(height);\n    result.stride = static_cast<uint32_t>(stride);\n    result.pixels.assign(data, data + data_size);\n    return result;\n  } catch (const wil::ResultException& e) {\n    return std::unexpected(format_hresult(e.GetErrorCode(), \"WIC operation failed\"));\n  } catch (const std::exception& e) {\n    return std::unexpected(std::string(\"Exception: \") + e.what());\n  }\n}\n\n// 将WIC位图编码为WebP\nauto encode_bitmap_to_webp(IWICBitmap* bitmap, const WebPEncodeOptions& options)\n    -> std::expected<WebPEncodedResult, std::string> {\n  if (!bitmap) {\n    return std::unexpected(\"Bitmap is null\");\n  }\n\n  try {\n    // 获取位图尺寸\n    UINT width, height;\n    THROW_IF_FAILED(bitmap->GetSize(&width, &height));\n\n    // 进行格式转换，确保是BGRA格式\n    wil::com_ptr<IWICImagingFactory> factory;\n    THROW_IF_FAILED(CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER,\n                                     IID_PPV_ARGS(&factory)));\n\n    wil::com_ptr<IWICFormatConverter> converter;\n    THROW_IF_FAILED(factory->CreateFormatConverter(converter.put()));\n\n    THROW_IF_FAILED(converter->Initialize(bitmap, GUID_WICPixelFormat32bppBGRA,\n                                          WICBitmapDitherTypeNone, nullptr, 0.0,\n                                          WICBitmapPaletteTypeCustom));\n\n    wil::com_ptr<IWICBitmap> bgra_bitmap;\n    THROW_IF_FAILED(factory->CreateBitmapFromSource(converter.get(), WICBitmapCacheOnDemand,\n                                                    bgra_bitmap.put()));\n    bitmap = bgra_bitmap.get();\n\n    // 获取位图锁\n    WICRect rect = {0, 0, static_cast<INT>(width), static_cast<INT>(height)};\n    wil::com_ptr<IWICBitmapLock> bitmap_lock;\n    THROW_IF_FAILED(bitmap->Lock(&rect, WICBitmapLockRead, bitmap_lock.put()));\n\n    // 获取数据指针\n    UINT stride, datasize;\n    BYTE* data;\n    THROW_IF_FAILED(bitmap_lock->GetDataPointer(&datasize, &data));\n    THROW_IF_FAILED(bitmap_lock->GetStride(&stride));\n\n    // 编码为WebP\n    uint8_t* output = nullptr;\n    size_t output_size = 0;\n\n    if (options.lossless) {\n      output_size = WebPEncodeLosslessBGRA(data, width, height, stride, &output);\n    } else {\n      output_size = WebPEncodeBGRA(data, width, height, stride, options.quality, &output);\n    }\n\n    if (output_size == 0 || output == nullptr) {\n      return std::unexpected(\"Failed to encode WebP image\");\n    }\n\n    // 将结果复制到vector\n    std::vector<uint8_t> result_data(output, output + output_size);\n    WebPFree(output);\n\n    return WebPEncodedResult{std::move(result_data), static_cast<uint32_t>(width),\n                             static_cast<uint32_t>(height)};\n  } catch (const wil::ResultException& e) {\n    return std::unexpected(format_hresult(e.GetErrorCode(), \"WIC operation failed\"));\n  } catch (const std::exception& e) {\n    return std::unexpected(std::string(\"Exception: \") + e.what());\n  }\n}\n\n// 视频封面：MF 已解码为 RGB32 内存帧，无需落盘即可走与照片相同的缩放 + WebP 编码。\nauto generate_webp_thumbnail_from_bgra(IWICImagingFactory* factory,\n                                       const BGRABitmapData& bitmap_data, uint32_t short_edge_size,\n                                       const WebPEncodeOptions& options)\n    -> std::expected<WebPEncodedResult, std::string> {\n  if (!factory) {\n    return std::unexpected(\"WIC factory is null\");\n  }\n\n  if (bitmap_data.width == 0 || bitmap_data.height == 0 || bitmap_data.stride == 0) {\n    return std::unexpected(\"Bitmap data is empty\");\n  }\n\n  if (bitmap_data.pixels.empty()) {\n    return std::unexpected(\"Bitmap pixels are empty\");\n  }\n\n  try {\n    wil::com_ptr<IWICBitmap> bitmap;\n    THROW_IF_FAILED(factory->CreateBitmapFromMemory(\n        bitmap_data.width, bitmap_data.height, GUID_WICPixelFormat32bppBGRA, bitmap_data.stride,\n        static_cast<UINT>(bitmap_data.pixels.size()), const_cast<BYTE*>(bitmap_data.pixels.data()),\n        bitmap.put()));\n\n    auto scaled_result = scale_bitmap(factory, bitmap.get(), short_edge_size);\n    if (!scaled_result) {\n      return std::unexpected(scaled_result.error());\n    }\n\n    return encode_bitmap_to_webp(scaled_result->get(), options);\n  } catch (const wil::ResultException& e) {\n    return std::unexpected(format_hresult(e.GetErrorCode(), \"WIC operation failed\"));\n  } catch (const std::exception& e) {\n    return std::unexpected(std::string(\"Exception: \") + e.what());\n  }\n}\n\n// 直接从文件生成WebP缩略图\nauto generate_webp_thumbnail(WICFactory& factory, const std::filesystem::path& path,\n                             uint32_t short_edge_size, const WebPEncodeOptions& options)\n    -> std::expected<WebPEncodedResult, std::string> {\n  if (!factory) {\n    return std::unexpected(\"WIC factory is null\");\n  }\n\n  // 加载图像帧\n  auto frame_result = load_bitmap_frame(factory.get(), path);\n  if (!frame_result) {\n    return std::unexpected(frame_result.error());\n  }\n  auto frame = std::move(frame_result.value());\n\n  // 缩放图像\n  auto scaled_result = scale_bitmap(factory.get(), frame.get(), short_edge_size);\n  if (!scaled_result) {\n    return std::unexpected(scaled_result.error());\n  }\n  auto scaled_bitmap = std::move(scaled_result.value());\n\n  // 编码为WebP\n  return encode_bitmap_to_webp(scaled_bitmap.get(), options);\n}\n\n// 保存像素数据到文件\nauto save_pixel_data_to_file(IWICImagingFactory* factory, const uint8_t* pixel_data, uint32_t width,\n                             uint32_t height, uint32_t row_pitch, const std::wstring& file_path,\n                             ImageFormat format, float jpeg_quality)\n    -> std::expected<void, std::string> {\n  if (!factory) {\n    return std::unexpected(\"WIC factory is null\");\n  }\n\n  if (!pixel_data) {\n    return std::unexpected(\"Pixel data is null\");\n  }\n\n  try {\n    // 根据格式选择编码器 GUID\n    GUID container_format =\n        (format == ImageFormat::JPEG) ? GUID_ContainerFormatJpeg : GUID_ContainerFormatPng;\n\n    wil::com_ptr<IWICBitmapEncoder> encoder;\n    THROW_IF_FAILED(factory->CreateEncoder(container_format, nullptr, encoder.put()));\n\n    // 创建流\n    wil::com_ptr<IWICStream> stream;\n    THROW_IF_FAILED(factory->CreateStream(stream.put()));\n    THROW_IF_FAILED(stream->InitializeFromFilename(file_path.c_str(), GENERIC_WRITE));\n\n    // 初始化编码器\n    THROW_IF_FAILED(encoder->Initialize(stream.get(), WICBitmapEncoderNoCache));\n\n    // 创建新帧（带属性包用于 JPEG 质量设置）\n    wil::com_ptr<IWICBitmapFrameEncode> frame;\n    wil::com_ptr<IPropertyBag2> property_bag;\n    THROW_IF_FAILED(encoder->CreateNewFrame(frame.put(), property_bag.put()));\n\n    // JPEG 格式时设置质量参数\n    if (format == ImageFormat::JPEG && property_bag) {\n      PROPBAG2 quality_option = {};\n      quality_option.pstrName = const_cast<LPOLESTR>(L\"ImageQuality\");\n      VARIANT quality_value;\n      VariantInit(&quality_value);\n      quality_value.vt = VT_R4;\n      quality_value.fltVal = jpeg_quality;\n      property_bag->Write(1, &quality_option, &quality_value);\n    }\n\n    // 初始化帧\n    THROW_IF_FAILED(frame->Initialize(property_bag.get()));\n\n    // 设置帧尺寸\n    THROW_IF_FAILED(frame->SetSize(width, height));\n\n    // 用原始像素数据创建 WIC 位图（声明为 32bppBGRA）\n    wil::com_ptr<IWICBitmap> bitmap;\n    THROW_IF_FAILED(factory->CreateBitmapFromMemory(width, height, GUID_WICPixelFormat32bppBGRA,\n                                                    row_pitch, row_pitch * height,\n                                                    const_cast<BYTE*>(pixel_data), bitmap.put()));\n\n    // 设置像素格式（让编码器协商：JPEG→24bppBGR, PNG→32bppBGRA）\n    WICPixelFormatGUID pixel_format = GUID_WICPixelFormat32bppBGRA;\n    THROW_IF_FAILED(frame->SetPixelFormat(&pixel_format));\n\n    // WriteSource 自动将 32bppBGRA 转换为编码器协商的格式\n    THROW_IF_FAILED(frame->WriteSource(bitmap.get(), nullptr));\n\n    // 提交帧\n    THROW_IF_FAILED(frame->Commit());\n\n    // 提交编码器\n    THROW_IF_FAILED(encoder->Commit());\n\n    return {};\n  } catch (const wil::ResultException& e) {\n    return std::unexpected(format_hresult(e.GetErrorCode(), \"WIC operation failed\"));\n  } catch (const std::exception& e) {\n    return std::unexpected(std::string(\"Exception: \") + e.what());\n  }\n}\n}  // namespace Utils::Image\n"
  },
  {
    "path": "src/utils/image/image.ixx",
    "content": "module;\n\nexport module Utils.Image;\n\nimport std;\nimport <wincodec.h>;\nimport <wil/com.h>;\n\nnamespace Utils::Image {\n// WIC工厂类型别名\nexport using WICFactory = wil::com_ptr<IWICImagingFactory>;\n\n// 线程局部存储，为每个线程维护独立的COM环境和WIC工厂\nthread_local std::optional<wil::unique_couninitialize_call> thread_com_init;\nthread_local WICFactory thread_wic_factory;\n\n// 图像信息结构\nexport struct ImageInfo {\n  uint32_t width;\n  uint32_t height;\n  GUID pixel_format;\n  std::string mime_type;\n};\n\n// WebP编码选项\nexport struct WebPEncodeOptions {\n  float quality = 75.0f;  // 0-100\n  bool lossless = false;\n};\n\n// WebP编码结果\nexport struct WebPEncodedResult {\n  std::vector<uint8_t> data;\n  uint32_t width;\n  uint32_t height;\n};\n\nexport struct BGRABitmapData {\n  uint32_t width = 0;\n  uint32_t height = 0;\n  uint32_t stride = 0;\n  std::vector<uint8_t> pixels;\n};\n\n// 创建WIC工厂\nexport auto create_factory() -> std::expected<WICFactory, std::string>;\n\n// 获取当前线程的WIC工厂。如果工厂不存在，则创建它。\nexport auto get_thread_wic_factory() -> std::expected<WICFactory, std::string>;\n\n// 获取图像信息（需要传递工厂）\nexport auto get_image_info(IWICImagingFactory* factory, const std::filesystem::path& path)\n    -> std::expected<ImageInfo, std::string>;\n\nexport auto load_bitmap_frame(IWICImagingFactory* factory, const std::filesystem::path& path)\n    -> std::expected<wil::com_ptr<IWICBitmapFrameDecode>, std::string>;\n\nexport auto scale_bitmap(IWICImagingFactory* factory, IWICBitmapSource* source,\n                         uint32_t short_edge_size)\n    -> std::expected<wil::com_ptr<IWICBitmap>, std::string>;\n\nexport auto convert_to_bgra_bitmap(IWICImagingFactory* factory, IWICBitmapSource* source)\n    -> std::expected<wil::com_ptr<IWICBitmap>, std::string>;\n\nexport auto copy_bgra_bitmap_data(IWICBitmap* bitmap) -> std::expected<BGRABitmapData, std::string>;\n\n// 从内存 BGRA（如视频单帧）生成 WebP 缩略图；与 generate_webp_thumbnail(路径) 共享缩放/编码路径。\nexport auto generate_webp_thumbnail_from_bgra(IWICImagingFactory* factory,\n                                              const BGRABitmapData& bitmap_data,\n                                              uint32_t short_edge_size,\n                                              const WebPEncodeOptions& options = {})\n    -> std::expected<WebPEncodedResult, std::string>;\n\n// 直接从文件生成WebP缩略图（按短边等比例缩放）\nexport auto generate_webp_thumbnail(WICFactory& factory, const std::filesystem::path& path,\n                                    uint32_t short_edge_size, const WebPEncodeOptions& options = {})\n    -> std::expected<WebPEncodedResult, std::string>;\n\n// 图像输出格式\nexport enum class ImageFormat { PNG, JPEG };\n\n// 保存像素数据到文件\nexport auto save_pixel_data_to_file(IWICImagingFactory* factory, const uint8_t* pixel_data,\n                                    uint32_t width, uint32_t height, uint32_t row_pitch,\n                                    const std::wstring& file_path,\n                                    ImageFormat format = ImageFormat::PNG,\n                                    float jpeg_quality = 1.0f) -> std::expected<void, std::string>;\n}  // namespace Utils::Image\n"
  },
  {
    "path": "src/utils/logger/logger.cpp",
    "content": "module;\n\nmodule Utils.Logger;\n\nimport std;\nimport Utils.Path;\nimport Vendor.BuildConfig;\nimport <spdlog/sinks/msvc_sink.h>;\nimport <spdlog/sinks/rotating_file_sink.h>;\nimport <spdlog/spdlog.h>;\n\nnamespace Utils::Logging::Detail {\n\nauto default_level() -> spdlog::level::level_enum {\n  return Vendor::BuildConfig::is_debug_build() ? spdlog::level::trace : spdlog::level::info;\n}\n\nauto normalize_level_string(std::string_view level) -> std::string {\n  std::string normalized;\n  normalized.reserve(level.size());\n\n  for (const auto ch : level) {\n    if (!std::isspace(static_cast<unsigned char>(ch))) {\n      normalized.push_back(static_cast<char>(std::toupper(static_cast<unsigned char>(ch))));\n    }\n  }\n\n  return normalized;\n}\n\nauto parse_level(std::string_view level) -> std::expected<spdlog::level::level_enum, std::string> {\n  const auto normalized = normalize_level_string(level);\n\n  if (normalized.empty()) {\n    return std::unexpected(\"Logger level is empty\");\n  }\n\n  if (normalized == \"TRACE\") {\n    return spdlog::level::trace;\n  }\n  if (normalized == \"DEBUG\") {\n    return spdlog::level::debug;\n  }\n  if (normalized == \"INFO\") {\n    return spdlog::level::info;\n  }\n  if (normalized == \"WARN\" || normalized == \"WARNING\") {\n    return spdlog::level::warn;\n  }\n  if (normalized == \"ERROR\" || normalized == \"ERR\") {\n    return spdlog::level::err;\n  }\n  if (normalized == \"CRITICAL\") {\n    return spdlog::level::critical;\n  }\n  if (normalized == \"OFF\") {\n    return spdlog::level::off;\n  }\n\n  return std::unexpected(\"Unsupported logger level: \" + std::string(level));\n}\n\n}  // namespace Utils::Logging::Detail\n\n// 构造函数实现\nLogger::Logger(std::source_location loc) : loc_(std::move(loc)) {}\n\n// 简单字符串日志函数实现\nauto Logger::trace(std::string_view msg) const -> void {\n  spdlog::default_logger()->log(\n      spdlog::source_loc{loc_.file_name(), static_cast<int>(loc_.line()), loc_.function_name()},\n      spdlog::level::trace, msg);\n}\n\nauto Logger::debug(std::string_view msg) const -> void {\n  spdlog::default_logger()->log(\n      spdlog::source_loc{loc_.file_name(), static_cast<int>(loc_.line()), loc_.function_name()},\n      spdlog::level::debug, msg);\n}\n\nauto Logger::info(std::string_view msg) const -> void {\n  spdlog::default_logger()->log(\n      spdlog::source_loc{loc_.file_name(), static_cast<int>(loc_.line()), loc_.function_name()},\n      spdlog::level::info, msg);\n}\n\nauto Logger::warn(std::string_view msg) const -> void {\n  spdlog::default_logger()->log(\n      spdlog::source_loc{loc_.file_name(), static_cast<int>(loc_.line()), loc_.function_name()},\n      spdlog::level::warn, msg);\n}\n\nauto Logger::error(std::string_view msg) const -> void {\n  spdlog::default_logger()->log(\n      spdlog::source_loc{loc_.file_name(), static_cast<int>(loc_.line()), loc_.function_name()},\n      spdlog::level::err, msg);\n}\n\nauto Logger::critical(std::string_view msg) const -> void {\n  spdlog::default_logger()->log(\n      spdlog::source_loc{loc_.file_name(), static_cast<int>(loc_.line()), loc_.function_name()},\n      spdlog::level::critical, msg);\n}\n\n// 日志管理函数实现\nnamespace Utils::Logging {\n\nauto initialize(const std::optional<std::string>& configured_level)\n    -> std::expected<void, std::string> {\n  try {\n    auto logs_dir_result = Utils::Path::GetAppDataSubdirectory(\"logs\");\n    if (!logs_dir_result) {\n      return std::unexpected(\"Failed to get log directory: \" + logs_dir_result.error());\n    }\n\n    auto log_file_path = logs_dir_result.value() / \"app.log\";\n\n    std::vector<spdlog::sink_ptr> sinks;\n    sinks.push_back(std::make_shared<spdlog::sinks::msvc_sink_mt>());\n    sinks.push_back(std::make_shared<spdlog::sinks::rotating_file_sink_mt>(log_file_path.string(),\n                                                                           5 * 1024 * 1024, 3));\n\n    auto logger = std::make_shared<spdlog::logger>(\"spinning_momo\", sinks.begin(), sinks.end());\n\n    logger->set_pattern(Vendor::BuildConfig::is_debug_build()\n                            ? \"%Y-%m-%d %H:%M:%S.%e [%^%l%$] [%g:%#] %v\"\n                            : \"%Y-%m-%d %H:%M:%S.%e [%^%l%$] [%s:%#] %v\");\n\n    auto resolved_level = Detail::default_level();\n    std::optional<std::string> level_warning;\n    if (configured_level.has_value() && !configured_level->empty()) {\n      auto parse_result = Detail::parse_level(configured_level.value());\n      if (parse_result) {\n        resolved_level = parse_result.value();\n      } else {\n        level_warning = parse_result.error() + \", fallback to default level\";\n      }\n    }\n\n    logger->set_level(resolved_level);\n    logger->flush_on(spdlog::level::trace);\n    spdlog::register_logger(logger);\n    spdlog::set_default_logger(logger);\n\n    if (level_warning.has_value()) {\n      logger->warn(\"{}\", level_warning.value());\n    }\n\n    return {};\n  } catch (const spdlog::spdlog_ex& ex) {\n    return std::unexpected(std::string(\"Log initialization failed: \") + ex.what());\n  }\n}\n\nauto shutdown() -> void {\n  auto logger = spdlog::default_logger();\n  if (logger) {\n    logger->flush();\n  }\n  spdlog::shutdown();\n}\n\nauto flush() -> void {\n  auto logger = spdlog::default_logger();\n  if (logger) {\n    logger->flush();\n  }\n}\n\nauto set_level(std::string_view level) -> std::expected<void, std::string> {\n  auto logger = spdlog::default_logger();\n  if (!logger) {\n    return std::unexpected(\"Logger is not initialized\");\n  }\n\n  auto parse_result = Detail::parse_level(level);\n  if (!parse_result) {\n    return std::unexpected(parse_result.error());\n  }\n\n  logger->set_level(parse_result.value());\n  logger->flush();\n  return {};\n}\n\n}  // namespace Utils::Logging\n"
  },
  {
    "path": "src/utils/logger/logger.ixx",
    "content": "module;\r\n\r\nexport module Utils.Logger;\r\n\r\nimport std;\r\nimport <spdlog/spdlog.h>;\r\n\r\nnamespace Utils::Logging {\r\n\r\n// 日志管理函数\r\nexport auto initialize(const std::optional<std::string>& configured_level = std::nullopt)\r\n    -> std::expected<void, std::string>;\r\nexport auto shutdown() -> void;\r\nexport auto flush() -> void;\r\nexport auto set_level(std::string_view level) -> std::expected<void, std::string>;\r\n\r\n}  // namespace Utils::Logging\r\n\r\n// Logger类 - 使用构造函数捕获source_location\r\nexport class Logger {\r\n public:\r\n  Logger(std::source_location loc = std::source_location::current());\r\n\r\n  // 格式化日志函数\r\n  template <typename... Args>\r\n  auto trace(spdlog::format_string_t<Args...> fmt, Args&&... args) const -> void {\r\n    spdlog::default_logger()->log(\r\n        spdlog::source_loc{loc_.file_name(), static_cast<int>(loc_.line()), loc_.function_name()},\r\n        spdlog::level::trace, fmt, std::forward<Args>(args)...);\r\n  }\r\n\r\n  template <typename... Args>\r\n  auto debug(spdlog::format_string_t<Args...> fmt, Args&&... args) const -> void {\r\n    spdlog::default_logger()->log(\r\n        spdlog::source_loc{loc_.file_name(), static_cast<int>(loc_.line()), loc_.function_name()},\r\n        spdlog::level::debug, fmt, std::forward<Args>(args)...);\r\n  }\r\n\r\n  template <typename... Args>\r\n  auto info(spdlog::format_string_t<Args...> fmt, Args&&... args) const -> void {\r\n    spdlog::default_logger()->log(\r\n        spdlog::source_loc{loc_.file_name(), static_cast<int>(loc_.line()), loc_.function_name()},\r\n        spdlog::level::info, fmt, std::forward<Args>(args)...);\r\n  }\r\n\r\n  template <typename... Args>\r\n  auto warn(spdlog::format_string_t<Args...> fmt, Args&&... args) const -> void {\r\n    spdlog::default_logger()->log(\r\n        spdlog::source_loc{loc_.file_name(), static_cast<int>(loc_.line()), loc_.function_name()},\r\n        spdlog::level::warn, fmt, std::forward<Args>(args)...);\r\n  }\r\n\r\n  template <typename... Args>\r\n  auto error(spdlog::format_string_t<Args...> fmt, Args&&... args) const -> void {\r\n    spdlog::default_logger()->log(\r\n        spdlog::source_loc{loc_.file_name(), static_cast<int>(loc_.line()), loc_.function_name()},\r\n        spdlog::level::err, fmt, std::forward<Args>(args)...);\r\n  }\r\n\r\n  template <typename... Args>\r\n  auto critical(spdlog::format_string_t<Args...> fmt, Args&&... args) const -> void {\r\n    spdlog::default_logger()->log(\r\n        spdlog::source_loc{loc_.file_name(), static_cast<int>(loc_.line()), loc_.function_name()},\r\n        spdlog::level::critical, fmt, std::forward<Args>(args)...);\r\n  }\r\n\r\n  // 简单字符串日志函数\r\n  auto trace(std::string_view msg) const -> void;\r\n  auto debug(std::string_view msg) const -> void;\r\n  auto info(std::string_view msg) const -> void;\r\n  auto warn(std::string_view msg) const -> void;\r\n  auto error(std::string_view msg) const -> void;\r\n  auto critical(std::string_view msg) const -> void;\r\n\r\n private:\r\n  std::source_location loc_;\r\n};\r\n"
  },
  {
    "path": "src/utils/lru_cache.ixx",
    "content": "module;\n\nexport module Utils.LRUCache;\n\nimport std;\n\nexport namespace Utils::LRUCache {\n\n// 缓存节点\ntemplate <typename Key, typename Value>\nstruct CacheNode {\n  Key key;\n  Value value;\n};\n\n// LRU 缓存状态\ntemplate <typename Key, typename Value>\nstruct LRUCacheState {\n  size_t capacity;\n  std::unordered_map<Key, typename std::list<CacheNode<Key, Value>>::iterator> map;\n  std::list<CacheNode<Key, Value>> list;  // 头部=最新，尾部=最旧\n  std::shared_mutex mutex;                // 读写锁\n};\n\n// 创建缓存\ntemplate <typename Key, typename Value>\nauto create(size_t capacity) -> LRUCacheState<Key, Value> {\n  return LRUCacheState<Key, Value>{.capacity = capacity, .map = {}, .list = {}};\n}\n\n// 查询缓存\ntemplate <typename Key, typename Value>\nauto get(LRUCacheState<Key, Value>& cache, const Key& key) -> std::optional<Value> {\n  std::unique_lock lock(cache.mutex);\n\n  auto it = cache.map.find(key);\n  if (it == cache.map.end()) {\n    return std::nullopt;\n  }\n\n  auto node_it = it->second;\n  auto value = node_it->value;  // 拷贝值\n\n  // 移动到链表头部（LRU更新）\n  cache.list.splice(cache.list.begin(), cache.list, node_it);\n\n  return value;\n}\n\n// 插入缓存（写操作）\ntemplate <typename Key, typename Value>\nauto put(LRUCacheState<Key, Value>& cache, const Key& key, Value value) -> void {\n  std::unique_lock lock(cache.mutex);\n\n  // 如果已存在，更新\n  auto it = cache.map.find(key);\n  if (it != cache.map.end()) {\n    auto node_it = it->second;\n    node_it->value = std::move(value);\n    cache.list.splice(cache.list.begin(), cache.list, node_it);\n    return;\n  }\n\n  // 检查容量\n  if (cache.list.size() >= cache.capacity) {\n    // 淘汰最旧的\n    auto& oldest = cache.list.back();\n    cache.map.erase(oldest.key);\n    cache.list.pop_back();\n  }\n\n  // 插入新节点\n  cache.list.push_front(CacheNode<Key, Value>{.key = key, .value = std::move(value)});\n\n  cache.map[key] = cache.list.begin();\n}\n\n// 批量预热（高效插入多个）\ntemplate <typename Key, typename Value>\nauto warm_up(LRUCacheState<Key, Value>& cache, const std::vector<std::pair<Key, Value>>& items)\n    -> void {\n  std::unique_lock lock(cache.mutex);\n\n  for (const auto& [key, value] : items) {\n    // 如果已存在，跳过\n    if (cache.map.contains(key)) {\n      continue;\n    }\n\n    // 检查容量\n    if (cache.list.size() >= cache.capacity) {\n      auto& oldest = cache.list.back();\n      cache.map.erase(oldest.key);\n      cache.list.pop_back();\n    }\n\n    cache.list.push_front(CacheNode<Key, Value>{.key = key, .value = value});\n\n    cache.map[key] = cache.list.begin();\n  }\n}\n\n// 清空缓存\ntemplate <typename Key, typename Value>\nauto clear(LRUCacheState<Key, Value>& cache) -> void {\n  std::unique_lock lock(cache.mutex);\n  cache.map.clear();\n  cache.list.clear();\n}\n\n// 获取统计信息\ntemplate <typename Key, typename Value>\nauto get_stats(const LRUCacheState<Key, Value>& cache)\n    -> std::tuple<size_t, size_t> {  // (current_size, capacity)\n  std::shared_lock lock(cache.mutex);\n  return {cache.list.size(), cache.capacity};\n}\n\n}  // namespace Utils::LRUCache\n"
  },
  {
    "path": "src/utils/media/audio_capture.cpp",
    "content": "module;\n\n#include <audioclient.h>\n#include <audioclientactivationparams.h>\n#include <mmdeviceapi.h>\n#include <mmreg.h>\n#include <wil/result.h>\n\nmodule Utils.Media.AudioCapture;\n\nimport std;\nimport Utils.Logger;\nimport <wil/com.h>;\nimport <windows.h>;\nimport <wrl/implements.h>;\n\nnamespace {\n\n// Process Loopback 激活回调类\nclass ProcessLoopbackActivator\n    : public Microsoft::WRL::RuntimeClass<\n          Microsoft::WRL::RuntimeClassFlags<Microsoft::WRL::ClassicCom>, Microsoft::WRL::FtmBase,\n          IActivateAudioInterfaceCompletionHandler> {\n private:\n  wil::com_ptr<IAudioClient> m_audio_client;\n  HRESULT m_activation_result = E_PENDING;\n  wil::unique_event_nothrow m_completion_event;\n\n public:\n  ProcessLoopbackActivator() { m_completion_event.create(); }\n\n  STDMETHOD(ActivateCompleted)(IActivateAudioInterfaceAsyncOperation* operation) {\n    wil::com_ptr<IUnknown> audio_interface;\n    HRESULT hr = operation->GetActivateResult(&m_activation_result, &audio_interface);\n\n    if (SUCCEEDED(hr) && SUCCEEDED(m_activation_result)) {\n      audio_interface.query_to(&m_audio_client);\n    }\n\n    m_completion_event.SetEvent();\n    return S_OK;\n  }\n\n  auto wait_and_get_client() -> std::expected<wil::com_ptr<IAudioClient>, std::string> {\n    m_completion_event.wait();\n\n    if (FAILED(m_activation_result)) {\n      return std::unexpected(std::format(\"Audio activation failed: {:08X}\",\n                                         static_cast<uint32_t>(m_activation_result)));\n    }\n\n    if (!m_audio_client) {\n      return std::unexpected(\"Audio client is null after activation\");\n    }\n\n    return m_audio_client;\n  }\n};\n\n// Process Loopback 初始化\nauto initialize_process_loopback(Utils::Media::AudioCapture::AudioCaptureContext& ctx,\n                                 std::uint32_t process_id) -> std::expected<void, std::string> {\n  HRESULT hr;\n\n  ctx.audio_event = CreateEventW(nullptr, FALSE, FALSE, nullptr);\n  if (!ctx.audio_event) {\n    return std::unexpected(\"Failed to create audio event for process loopback\");\n  }\n\n  AUDIOCLIENT_ACTIVATION_PARAMS activation_params = {};\n  activation_params.ActivationType = AUDIOCLIENT_ACTIVATION_TYPE_PROCESS_LOOPBACK;\n  activation_params.ProcessLoopbackParams.TargetProcessId = process_id;\n  activation_params.ProcessLoopbackParams.ProcessLoopbackMode =\n      PROCESS_LOOPBACK_MODE_INCLUDE_TARGET_PROCESS_TREE;\n\n  PROPVARIANT activate_params = {};\n  activate_params.vt = VT_BLOB;\n  activate_params.blob.cbSize = sizeof(activation_params);\n  activate_params.blob.pBlobData = reinterpret_cast<BYTE*>(&activation_params);\n\n  auto activator = Microsoft::WRL::Make<ProcessLoopbackActivator>();\n  if (!activator) {\n    return std::unexpected(\"Failed to create activation callback handler\");\n  }\n\n  wil::com_ptr<IActivateAudioInterfaceAsyncOperation> async_op;\n  hr = ActivateAudioInterfaceAsync(VIRTUAL_AUDIO_DEVICE_PROCESS_LOOPBACK, __uuidof(IAudioClient),\n                                   &activate_params, activator.Get(), &async_op);\n\n  if (FAILED(hr)) {\n    return std::unexpected(\n        std::format(\"Failed to activate audio interface async: {:08X}\", static_cast<uint32_t>(hr)));\n  }\n\n  auto client_result = activator->wait_and_get_client();\n  if (!client_result) {\n    return std::unexpected(client_result.error());\n  }\n  ctx.audio_client = *client_result;\n\n  // 硬编码格式 (GetMixFormat 在此模式不可用)\n  ctx.wave_format = reinterpret_cast<WAVEFORMATEX*>(CoTaskMemAlloc(sizeof(WAVEFORMATEX)));\n  if (!ctx.wave_format) {\n    return std::unexpected(\"Failed to allocate memory for wave format\");\n  }\n\n  ctx.wave_format->wFormatTag = WAVE_FORMAT_PCM;\n  ctx.wave_format->nChannels = 2;\n  ctx.wave_format->nSamplesPerSec = 48000;\n  ctx.wave_format->wBitsPerSample = 16;\n  ctx.wave_format->nBlockAlign = ctx.wave_format->nChannels * ctx.wave_format->wBitsPerSample / 8;\n  ctx.wave_format->nAvgBytesPerSec = ctx.wave_format->nSamplesPerSec * ctx.wave_format->nBlockAlign;\n  ctx.wave_format->cbSize = 0;\n\n  Logger().info(\"Process Loopback audio format: {} Hz, {} channels, {} bits\",\n                ctx.wave_format->nSamplesPerSec, ctx.wave_format->nChannels,\n                ctx.wave_format->wBitsPerSample);\n\n  REFERENCE_TIME buffer_duration = 1'000'000;  // 100ms\n  hr = ctx.audio_client->Initialize(AUDCLNT_SHAREMODE_SHARED,\n                                    AUDCLNT_STREAMFLAGS_LOOPBACK |\n                                        AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM |\n                                        AUDCLNT_STREAMFLAGS_EVENTCALLBACK,\n                                    buffer_duration, 0, ctx.wave_format, nullptr);\n  if (FAILED(hr)) {\n    return std::unexpected(\n        std::format(\"Failed to initialize audio client: {:08X}\", static_cast<uint32_t>(hr)));\n  }\n\n  hr = ctx.audio_client->SetEventHandle(ctx.audio_event);\n  if (FAILED(hr)) {\n    return std::unexpected(\n        std::format(\"Failed to set audio event handle: {:08X}\", static_cast<uint32_t>(hr)));\n  }\n\n  hr = ctx.audio_client->GetService(__uuidof(IAudioCaptureClient),\n                                    reinterpret_cast<void**>(ctx.capture_client.put()));\n  if (FAILED(hr)) {\n    return std::unexpected(\n        std::format(\"Failed to get capture client: {:08X}\", static_cast<uint32_t>(hr)));\n  }\n\n  hr = ctx.audio_client->GetBufferSize(&ctx.buffer_frame_count);\n  if (FAILED(hr)) {\n    return std::unexpected(\n        std::format(\"Failed to get buffer size: {:08X}\", static_cast<uint32_t>(hr)));\n  }\n\n  Logger().info(\"Process Loopback audio capture initialized: buffer size = {} frames\",\n                ctx.buffer_frame_count);\n  return {};\n}\n\n// System Loopback 初始化\nauto initialize_system_loopback(Utils::Media::AudioCapture::AudioCaptureContext& ctx)\n    -> std::expected<void, std::string> {\n  HRESULT hr;\n\n  ctx.audio_event = CreateEventW(nullptr, FALSE, FALSE, nullptr);\n  if (!ctx.audio_event) {\n    return std::unexpected(\"Failed to create audio event for system loopback\");\n  }\n\n  wil::com_ptr<IMMDeviceEnumerator> enumerator;\n  hr = CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL,\n                        IID_PPV_ARGS(enumerator.put()));\n  if (FAILED(hr)) {\n    return std::unexpected(\n        std::format(\"Failed to create device enumerator: {:08X}\", static_cast<uint32_t>(hr)));\n  }\n\n  hr = enumerator->GetDefaultAudioEndpoint(eRender, eConsole, ctx.device.put());\n  if (FAILED(hr)) {\n    return std::unexpected(\n        std::format(\"Failed to get default audio endpoint: {:08X}\", static_cast<uint32_t>(hr)));\n  }\n\n  hr = ctx.device->Activate(__uuidof(IAudioClient), CLSCTX_ALL, nullptr,\n                            reinterpret_cast<void**>(ctx.audio_client.put()));\n  if (FAILED(hr)) {\n    return std::unexpected(\n        std::format(\"Failed to activate audio client: {:08X}\", static_cast<uint32_t>(hr)));\n  }\n\n  WAVEFORMATEX* device_format = nullptr;\n  hr = ctx.audio_client->GetMixFormat(&device_format);\n  if (FAILED(hr)) {\n    return std::unexpected(\n        std::format(\"Failed to get mix format: {:08X}\", static_cast<uint32_t>(hr)));\n  }\n\n  Logger().info(\"Device audio format: {} Hz, {} channels, {} bits, format tag: {}\",\n                device_format->nSamplesPerSec, device_format->nChannels,\n                device_format->wBitsPerSample, device_format->wFormatTag);\n\n  // 创建 16-bit PCM 格式（用于 AAC 编码器兼容性）\n  WAVEFORMATEX pcm_format = {};\n  pcm_format.wFormatTag = WAVE_FORMAT_PCM;\n  pcm_format.nChannels = device_format->nChannels;\n  pcm_format.nSamplesPerSec = device_format->nSamplesPerSec;\n  pcm_format.wBitsPerSample = 16;\n  pcm_format.nBlockAlign = pcm_format.nChannels * pcm_format.wBitsPerSample / 8;\n  pcm_format.nAvgBytesPerSec = pcm_format.nSamplesPerSec * pcm_format.nBlockAlign;\n  pcm_format.cbSize = 0;\n\n  WAVEFORMATEX* closest_match = nullptr;\n  hr = ctx.audio_client->IsFormatSupported(AUDCLNT_SHAREMODE_SHARED, &pcm_format, &closest_match);\n\n  WAVEFORMATEX* format_to_use = nullptr;\n  if (hr == S_OK) {\n    Logger().info(\"Device supports 16-bit PCM format directly\");\n    ctx.wave_format = reinterpret_cast<WAVEFORMATEX*>(CoTaskMemAlloc(sizeof(WAVEFORMATEX)));\n    if (!ctx.wave_format) {\n      CoTaskMemFree(device_format);\n      if (closest_match) CoTaskMemFree(closest_match);\n      return std::unexpected(\"Failed to allocate memory for wave format\");\n    }\n    std::memcpy(ctx.wave_format, &pcm_format, sizeof(WAVEFORMATEX));\n    format_to_use = ctx.wave_format;\n  } else if (hr == S_FALSE && closest_match) {\n    Logger().info(\"Using closest match format: {} Hz, {} channels, {} bits\",\n                  closest_match->nSamplesPerSec, closest_match->nChannels,\n                  closest_match->wBitsPerSample);\n    ctx.wave_format = closest_match;\n    format_to_use = closest_match;\n    closest_match = nullptr;\n  } else {\n    Logger().warn(\"Device does not support 16-bit PCM, using device format (may need conversion)\");\n    ctx.wave_format = device_format;\n    format_to_use = device_format;\n    device_format = nullptr;\n  }\n\n  if (device_format) CoTaskMemFree(device_format);\n  if (closest_match) CoTaskMemFree(closest_match);\n\n  Logger().info(\"Final audio format: {} Hz, {} channels, {} bits\", ctx.wave_format->nSamplesPerSec,\n                ctx.wave_format->nChannels, ctx.wave_format->wBitsPerSample);\n\n  REFERENCE_TIME buffer_duration = 1'000'000;  // 100ms\n  hr = ctx.audio_client->Initialize(\n      AUDCLNT_SHAREMODE_SHARED, AUDCLNT_STREAMFLAGS_LOOPBACK | AUDCLNT_STREAMFLAGS_EVENTCALLBACK,\n      buffer_duration, 0, format_to_use, nullptr);\n  if (FAILED(hr)) {\n    return std::unexpected(\n        std::format(\"Failed to initialize audio client: {:08X}\", static_cast<uint32_t>(hr)));\n  }\n\n  hr = ctx.audio_client->SetEventHandle(ctx.audio_event);\n  if (FAILED(hr)) {\n    return std::unexpected(\n        std::format(\"Failed to set audio event handle: {:08X}\", static_cast<uint32_t>(hr)));\n  }\n\n  hr = ctx.audio_client->GetService(__uuidof(IAudioCaptureClient),\n                                    reinterpret_cast<void**>(ctx.capture_client.put()));\n  if (FAILED(hr)) {\n    return std::unexpected(\n        std::format(\"Failed to get capture client: {:08X}\", static_cast<uint32_t>(hr)));\n  }\n\n  hr = ctx.audio_client->GetBufferSize(&ctx.buffer_frame_count);\n  if (FAILED(hr)) {\n    return std::unexpected(\n        std::format(\"Failed to get buffer size: {:08X}\", static_cast<uint32_t>(hr)));\n  }\n\n  Logger().info(\"System Loopback audio capture initialized: buffer size = {} frames\",\n                ctx.buffer_frame_count);\n  return {};\n}\n\n// 通用音频捕获循环\nauto audio_capture_loop(Utils::Media::AudioCapture::AudioCaptureContext& ctx,\n                        std::function<std::int64_t()> get_elapsed_100ns,\n                        std::function<bool()> is_active,\n                        Utils::Media::AudioCapture::AudioPacketCallback on_packet) -> void {\n  HRESULT hr = ctx.audio_client->Start();\n  if (FAILED(hr)) {\n    Logger().error(\"Failed to start audio client: {:08X}\", static_cast<uint32_t>(hr));\n    return;\n  }\n\n  Logger().info(\"Audio capture thread started\");\n\n  while (!ctx.should_stop.load()) {\n    if (ctx.audio_event) {\n      WaitForSingleObject(ctx.audio_event, 100);\n    } else {\n      Sleep(10);\n    }\n\n    UINT32 packet_length = 0;\n    hr = ctx.capture_client->GetNextPacketSize(&packet_length);\n    if (FAILED(hr)) {\n      Logger().error(\"GetNextPacketSize failed: {:08X}\", static_cast<uint32_t>(hr));\n      break;\n    }\n\n    while (packet_length > 0) {\n      BYTE* data = nullptr;\n      UINT32 frames_available = 0;\n      DWORD flags = 0;\n      UINT64 device_position = 0;\n      UINT64 qpc_position = 0;\n\n      hr = ctx.capture_client->GetBuffer(&data, &frames_available, &flags, &device_position,\n                                         &qpc_position);\n\n      if (FAILED(hr)) {\n        Logger().error(\"GetBuffer failed: {:08X}\", static_cast<uint32_t>(hr));\n        break;\n      }\n\n      if (!(flags & AUDCLNT_BUFFERFLAGS_SILENT) && frames_available > 0) {\n        if (is_active()) {\n          auto timestamp_100ns = get_elapsed_100ns();\n          UINT32 bytes_per_frame = ctx.wave_format->nBlockAlign;\n          on_packet(data, frames_available, bytes_per_frame, timestamp_100ns);\n        }\n      }\n\n      hr = ctx.capture_client->ReleaseBuffer(frames_available);\n      if (FAILED(hr)) {\n        Logger().error(\"ReleaseBuffer failed: {:08X}\", static_cast<uint32_t>(hr));\n        break;\n      }\n\n      hr = ctx.capture_client->GetNextPacketSize(&packet_length);\n      if (FAILED(hr)) {\n        break;\n      }\n    }\n  }\n\n  ctx.audio_client->Stop();\n  Logger().info(\"Audio capture thread stopped\");\n}\n\n}  // namespace\n\nnamespace Utils::Media::AudioCapture {\n\nauto is_process_loopback_supported() -> bool {\n  OSVERSIONINFOEXW osvi = {sizeof(osvi)};\n  osvi.dwMajorVersion = 10;\n  osvi.dwMinorVersion = 0;\n  osvi.dwBuildNumber = 19041;  // Windows 10 2004\n\n  DWORDLONG mask = 0;\n  VER_SET_CONDITION(mask, VER_MAJORVERSION, VER_GREATER_EQUAL);\n  VER_SET_CONDITION(mask, VER_MINORVERSION, VER_GREATER_EQUAL);\n  VER_SET_CONDITION(mask, VER_BUILDNUMBER, VER_GREATER_EQUAL);\n\n  return VerifyVersionInfoW(&osvi, VER_MAJORVERSION | VER_MINORVERSION | VER_BUILDNUMBER, mask);\n}\n\nauto initialize(AudioCaptureContext& ctx, AudioSource source, std::uint32_t process_id)\n    -> std::expected<void, std::string> {\n  if (source == AudioSource::None) {\n    Logger().info(\"Audio capture disabled by configuration\");\n    return {};\n  }\n\n  if (source == AudioSource::GameOnly) {\n    if (!is_process_loopback_supported()) {\n      Logger().warn(\n          \"Process Loopback API not supported (requires Windows 10 2004+), falling back to \"\n          \"System Loopback\");\n      source = AudioSource::System;\n    } else {\n      Logger().info(\"Using Process Loopback mode (Game audio only)\");\n      return initialize_process_loopback(ctx, process_id);\n    }\n  }\n\n  Logger().info(\"Using System Loopback mode (All system audio)\");\n  return initialize_system_loopback(ctx);\n}\n\nauto start_capture_thread(AudioCaptureContext& ctx, std::function<std::int64_t()> get_elapsed_100ns,\n                          std::function<bool()> is_active, AudioPacketCallback on_packet) -> void {\n  ctx.should_stop = false;\n  ctx.capture_thread = std::jthread([&ctx, get_elapsed_100ns = std::move(get_elapsed_100ns),\n                                     is_active = std::move(is_active),\n                                     on_packet = std::move(on_packet)](std::stop_token) {\n    audio_capture_loop(ctx, get_elapsed_100ns, is_active, on_packet);\n  });\n}\n\nauto stop(AudioCaptureContext& ctx) -> void {\n  ctx.should_stop = true;\n  if (ctx.capture_thread.joinable()) {\n    ctx.capture_thread.join();\n  }\n}\n\nauto cleanup(AudioCaptureContext& ctx) -> void {\n  stop(ctx);\n\n  ctx.capture_client = nullptr;\n  ctx.audio_client = nullptr;\n  ctx.device = nullptr;\n\n  if (ctx.wave_format) {\n    CoTaskMemFree(ctx.wave_format);\n    ctx.wave_format = nullptr;\n  }\n\n  if (ctx.audio_event) {\n    CloseHandle(ctx.audio_event);\n    ctx.audio_event = nullptr;\n  }\n\n  ctx.buffer_frame_count = 0;\n}\n\n}  // namespace Utils::Media::AudioCapture\n"
  },
  {
    "path": "src/utils/media/audio_capture.ixx",
    "content": "module;\n\nexport module Utils.Media.AudioCapture;\n\nimport std;\nimport <audioclient.h>;\nimport <mmdeviceapi.h>;\nimport <wil/com.h>;\nimport <windows.h>;\n\nnamespace Utils::Media::AudioCapture {\n\n// 音频源类型\nexport enum class AudioSource {\n  None,     // 不录制音频\n  System,   // 系统全部音频（传统 Loopback）\n  GameOnly  // 仅游戏音频（Process Loopback，需 Windows 10 2004+）\n};\n\n// 从字符串转换为 AudioSource\nexport constexpr AudioSource audio_source_from_string(std::string_view str) {\n  if (str == \"none\") return AudioSource::None;\n  if (str == \"game_only\") return AudioSource::GameOnly;\n  return AudioSource::System;  // 默认\n}\n\n// 音频捕获上下文\nexport struct AudioCaptureContext {\n  wil::com_ptr<IMMDevice> device;                    // 音频设备\n  wil::com_ptr<IAudioClient> audio_client;           // 音频客户端\n  wil::com_ptr<IAudioCaptureClient> capture_client;  // 捕获客户端\n\n  WAVEFORMATEX* wave_format = nullptr;  // 音频格式\n  UINT32 buffer_frame_count = 0;        // 缓冲区帧数\n\n  HANDLE audio_event = nullptr;           // WASAPI 缓冲就绪事件\n  std::jthread capture_thread;            // 捕获线程\n  std::atomic<bool> should_stop = false;  // 停止信号\n};\n\n// 音频数据包回调: (audio_data, num_frames, bytes_per_frame, timestamp_100ns)\nexport using AudioPacketCallback = std::function<void(const BYTE*, UINT32, UINT32, std::int64_t)>;\n\n// 是否支持 Process Loopback API（Windows 10 2004+）\nexport auto is_process_loopback_supported() -> bool;\n\n// 初始化音频捕获（根据音频源类型选择不同的初始化方式）\nexport auto initialize(AudioCaptureContext& ctx, AudioSource source, std::uint32_t process_id)\n    -> std::expected<void, std::string>;\n\n// 启动音频捕获线程（回调式）\n// get_elapsed_100ns: 获取当前经过时间（100ns 单位）\n// is_active: 判断调用方是否仍处于活跃状态\n// on_packet: 音频数据包回调\nexport auto start_capture_thread(AudioCaptureContext& ctx,\n                                 std::function<std::int64_t()> get_elapsed_100ns,\n                                 std::function<bool()> is_active, AudioPacketCallback on_packet)\n    -> void;\n\n// 停止音频捕获\nexport auto stop(AudioCaptureContext& ctx) -> void;\n\n// 清理音频资源\nexport auto cleanup(AudioCaptureContext& ctx) -> void;\n\n}  // namespace Utils::Media::AudioCapture\n"
  },
  {
    "path": "src/utils/media/encoder.cpp",
    "content": "module;\n\n#include <mfidl.h>\n#include <mfreadwrite.h>\n\nmodule Utils.Media.Encoder;\n\nimport std;\nimport Utils.Logger;\nimport <d3d11.h>;\nimport <mfapi.h>;\nimport <mferror.h>;\nimport <wil/com.h>;\nimport <windows.h>;\nimport <codecapi.h>;\nimport <strmif.h>;\n\nnamespace Utils::Media::Encoder {\n\n// 辅助函数：创建输出媒体类型\nauto create_output_media_type(uint32_t width, uint32_t height, uint32_t fps, uint32_t bitrate,\n                              uint32_t keyframe_interval, Types::VideoCodec codec)\n    -> wil::com_ptr<IMFMediaType> {\n  wil::com_ptr<IMFMediaType> media_type;\n  if (FAILED(MFCreateMediaType(media_type.put()))) return nullptr;\n  if (FAILED(media_type->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Video))) return nullptr;\n\n  // 根据 codec 选择编码格式\n  GUID subtype = (codec == Types::VideoCodec::H265) ? MFVideoFormat_HEVC : MFVideoFormat_H264;\n  if (FAILED(media_type->SetGUID(MF_MT_SUBTYPE, subtype))) return nullptr;\n  if (FAILED(media_type->SetUINT32(MF_MT_AVG_BITRATE, bitrate))) return nullptr;\n  if (FAILED(media_type->SetUINT32(MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive)))\n    return nullptr;\n  if (FAILED(MFSetAttributeSize(media_type.get(), MF_MT_FRAME_SIZE, width, height))) return nullptr;\n  if (FAILED(MFSetAttributeRatio(media_type.get(), MF_MT_FRAME_RATE, fps, 1))) return nullptr;\n  if (FAILED(MFSetAttributeRatio(media_type.get(), MF_MT_PIXEL_ASPECT_RATIO, 1, 1))) return nullptr;\n\n  // 颜色元数据 (BT.709 标准，与 NVIDIA APP / OBS 一致)\n  media_type->SetUINT32(MF_MT_VIDEO_PRIMARIES, MFVideoPrimaries_BT709);\n  media_type->SetUINT32(MF_MT_TRANSFER_FUNCTION, MFVideoTransFunc_709);\n  media_type->SetUINT32(MF_MT_YUV_MATRIX, MFVideoTransferMatrix_BT709);\n  media_type->SetUINT32(MF_MT_VIDEO_NOMINAL_RANGE, MFNominalRange_16_235);  // TV / Limited Range\n\n  // 编码 Profile (H.264 High / H.265 Main，与 NVIDIA APP / OBS 一致)\n  if (codec == Types::VideoCodec::H265) {\n    media_type->SetUINT32(MF_MT_MPEG2_PROFILE, eAVEncH265VProfile_Main_420_8);\n  } else {\n    media_type->SetUINT32(MF_MT_MPEG2_PROFILE, eAVEncH264VProfile_High);\n  }\n\n  // 关键帧间隔\n  media_type->SetUINT32(MF_MT_MAX_KEYFRAME_SPACING, fps * keyframe_interval);\n\n  return media_type;\n}\n\n// 辅助函数：创建输入媒体类型\nauto create_input_media_type(uint32_t width, uint32_t height, uint32_t fps, bool set_stride)\n    -> wil::com_ptr<IMFMediaType> {\n  wil::com_ptr<IMFMediaType> media_type;\n  if (FAILED(MFCreateMediaType(media_type.put()))) return nullptr;\n  if (FAILED(media_type->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Video))) return nullptr;\n  if (FAILED(media_type->SetGUID(MF_MT_SUBTYPE, MFVideoFormat_ARGB32))) return nullptr;\n  if (FAILED(media_type->SetUINT32(MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive)))\n    return nullptr;\n  if (FAILED(MFSetAttributeSize(media_type.get(), MF_MT_FRAME_SIZE, width, height))) return nullptr;\n\n  if (set_stride) {\n    const INT32 stride = static_cast<INT32>(width * 4);\n    if (FAILED(media_type->SetUINT32(MF_MT_DEFAULT_STRIDE, static_cast<UINT32>(stride))))\n      return nullptr;\n  }\n\n  if (FAILED(MFSetAttributeRatio(media_type.get(), MF_MT_FRAME_RATE, fps, 1))) return nullptr;\n  if (FAILED(MFSetAttributeRatio(media_type.get(), MF_MT_PIXEL_ASPECT_RATIO, 1, 1))) return nullptr;\n\n  // 输入颜色信息：屏幕捕获是 Full Range RGB，告知编码器以正确执行 RGB→YUV 转换\n  media_type->SetUINT32(MF_MT_VIDEO_PRIMARIES, MFVideoPrimaries_BT709);\n  media_type->SetUINT32(MF_MT_TRANSFER_FUNCTION, MFVideoTransFunc_709);\n  media_type->SetUINT32(MF_MT_VIDEO_NOMINAL_RANGE, MFNominalRange_0_255);  // Full Range (屏幕捕获)\n\n  return media_type;\n}\n\n// 辅助函数：添加音频流\nauto add_audio_stream(State::EncoderContext& encoder, WAVEFORMATEX* wave_format,\n                      uint32_t audio_bitrate) -> std::expected<void, std::string> {\n  if (!encoder.sink_writer) {\n    return std::unexpected(\"Sink writer not initialized\");\n  }\n\n  if (!wave_format) {\n    return std::unexpected(\"Invalid wave format\");\n  }\n\n  // 1. 创建 AAC 输出媒体类型\n  wil::com_ptr<IMFMediaType> audio_out;\n  if (FAILED(MFCreateMediaType(audio_out.put()))) {\n    return std::unexpected(\"Failed to create audio output media type\");\n  }\n\n  if (FAILED(audio_out->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Audio))) {\n    return std::unexpected(\"Failed to set audio major type\");\n  }\n\n  if (FAILED(audio_out->SetGUID(MF_MT_SUBTYPE, MFAudioFormat_AAC))) {\n    return std::unexpected(\"Failed to set AAC subtype\");\n  }\n\n  if (FAILED(audio_out->SetUINT32(MF_MT_AUDIO_SAMPLES_PER_SECOND, wave_format->nSamplesPerSec))) {\n    return std::unexpected(\"Failed to set audio sample rate\");\n  }\n\n  if (FAILED(audio_out->SetUINT32(MF_MT_AUDIO_NUM_CHANNELS, wave_format->nChannels))) {\n    return std::unexpected(\"Failed to set audio channel count\");\n  }\n\n  if (FAILED(audio_out->SetUINT32(MF_MT_AUDIO_BITS_PER_SAMPLE, 16))) {\n    return std::unexpected(\"Failed to set audio bits per sample\");\n  }\n\n  // 设置 AAC 码率（使用配置的码率）\n  if (FAILED(audio_out->SetUINT32(MF_MT_AUDIO_AVG_BYTES_PER_SECOND, audio_bitrate / 8))) {\n    return std::unexpected(\"Failed to set audio bitrate\");\n  }\n\n  // 添加音频流\n  if (FAILED(encoder.sink_writer->AddStream(audio_out.get(), &encoder.audio_stream_index))) {\n    return std::unexpected(\"Failed to add audio stream\");\n  }\n\n  // 2. 创建 PCM 输入媒体类型（直接使用 WASAPI 返回的 16-bit PCM 格式）\n  wil::com_ptr<IMFMediaType> audio_in;\n  if (FAILED(MFCreateMediaType(audio_in.put()))) {\n    return std::unexpected(\"Failed to create audio input media type\");\n  }\n\n  UINT32 wave_format_size = sizeof(WAVEFORMATEX) + wave_format->cbSize;\n  if (FAILED(MFInitMediaTypeFromWaveFormatEx(audio_in.get(), wave_format, wave_format_size))) {\n    return std::unexpected(\"Failed to initialize audio input from wave format\");\n  }\n\n  // 设置输入媒体类型\n  if (FAILED(encoder.sink_writer->SetInputMediaType(encoder.audio_stream_index, audio_in.get(),\n                                                    nullptr))) {\n    return std::unexpected(\"Failed to set audio input media type\");\n  }\n\n  encoder.has_audio = true;\n  Logger().info(\"Audio stream added: {} Hz, {} channels, {} kbps\", wave_format->nSamplesPerSec,\n                wave_format->nChannels, audio_bitrate / 1000);\n\n  return {};\n}\n\n// 尝试创建 GPU 编码器\nauto try_create_gpu_encoder(State::EncoderContext& ctx, const Types::EncoderConfig& config,\n                            ID3D11Device* device, WAVEFORMATEX* wave_format)\n    -> std::expected<void, std::string> {\n  // 1. 创建 DXGI Device Manager\n  if (FAILED(MFCreateDXGIDeviceManager(&ctx.reset_token, ctx.dxgi_manager.put()))) {\n    return std::unexpected(\"Failed to create DXGI Device Manager\");\n  }\n\n  // 2. 将 D3D11 设备关联到 DXGI Manager\n  if (FAILED(ctx.dxgi_manager->ResetDevice(device, ctx.reset_token))) {\n    return std::unexpected(\"Failed to reset DXGI device\");\n  }\n\n  // 3. 确保目录存在\n  std::filesystem::create_directories(config.output_path.parent_path());\n\n  // 4. 创建 Byte Stream\n  wil::com_ptr<IMFByteStream> byte_stream;\n  if (FAILED(MFCreateFile(MF_ACCESSMODE_WRITE, MF_OPENMODE_DELETE_IF_EXIST, MF_FILEFLAGS_NONE,\n                          config.output_path.c_str(), byte_stream.put()))) {\n    return std::unexpected(\"Failed to create byte stream\");\n  }\n\n  // 5. 创建 Media Sink\n  wil::com_ptr<IMFMediaSink> media_sink;\n  if (FAILED(MFCreateMPEG4MediaSink(byte_stream.get(), nullptr, nullptr, media_sink.put()))) {\n    return std::unexpected(\"Failed to create MPEG4 media sink\");\n  }\n\n  // 6. 创建 Sink Writer 属性并预设编码器参数\n  wil::com_ptr<IMFAttributes> attributes;\n  if (FAILED(MFCreateAttributes(attributes.put(), 8))) {\n    return std::unexpected(\"Failed to create MF attributes\");\n  }\n\n  // 启用硬件加速\n  if (FAILED(attributes->SetUINT32(MF_READWRITE_ENABLE_HARDWARE_TRANSFORMS, TRUE))) {\n    return std::unexpected(\"Failed to enable hardware transforms\");\n  }\n\n  // 设置 DXGI Manager\n  if (FAILED(attributes->SetUnknown(MF_SINK_WRITER_D3D_MANAGER, ctx.dxgi_manager.get()))) {\n    return std::unexpected(\"Failed to set D3D manager\");\n  }\n\n  // 在创建 Sink Writer 前预设 Rate Control Mode\n  UINT32 rate_control_mode = eAVEncCommonRateControlMode_CBR;  // 默认 CBR\n\n  if (config.rate_control == Types::RateControlMode::VBR) {\n    rate_control_mode = eAVEncCommonRateControlMode_Quality;\n  } else if (config.rate_control == Types::RateControlMode::ManualQP) {\n    rate_control_mode = eAVEncCommonRateControlMode_Quality;  // QP 模式也使用 Quality\n  }\n\n  if (FAILED(attributes->SetUINT32(CODECAPI_AVEncCommonRateControlMode, rate_control_mode))) {\n    Logger().warn(\"Failed to set rate control mode attribute\");\n  }\n\n  // 预设 Quality (VBR 模式) 或 QP (ManualQP 模式)\n  if (config.rate_control == Types::RateControlMode::VBR) {\n    if (FAILED(attributes->SetUINT32(CODECAPI_AVEncCommonQuality, config.quality))) {\n      Logger().warn(\"Failed to set quality attribute\");\n    }\n    Logger().info(\"GPU encoder configured for VBR mode with quality: {}\", config.quality);\n  } else if (config.rate_control == Types::RateControlMode::ManualQP) {\n    // 尝试设置 QP 参数（可能不被所有编码器支持）\n    if (FAILED(attributes->SetUINT32(CODECAPI_AVEncVideoEncodeQP, config.qp))) {\n      Logger().warn(\"Failed to set QP attribute - encoder may not support Manual QP mode\");\n    }\n    Logger().info(\"GPU encoder configured for Manual QP mode with QP: {}\", config.qp);\n  } else {\n    Logger().info(\"GPU encoder configured for CBR mode\");\n  }\n\n  // 7. 用预设属性创建 Sink Writer\n  if (FAILED(MFCreateSinkWriterFromMediaSink(media_sink.get(), attributes.get(),\n                                             ctx.sink_writer.put()))) {\n    return std::unexpected(\"Failed to create Sink Writer from media sink\");\n  }\n\n  // 8. 创建输出媒体类型\n  auto media_type_out =\n      create_output_media_type(config.width, config.height, config.fps, config.bitrate,\n                               config.keyframe_interval, config.codec);\n  if (!media_type_out) {\n    return std::unexpected(\"Failed to create output media type\");\n  }\n\n  if (FAILED(ctx.sink_writer->AddStream(media_type_out.get(), &ctx.video_stream_index))) {\n    return std::unexpected(\"Failed to add video stream\");\n  }\n\n  // 9. 创建 GPU 输入媒体类型\n  auto media_type_in = create_input_media_type(config.width, config.height, config.fps, false);\n  if (!media_type_in) {\n    return std::unexpected(\"Failed to create GPU input media type\");\n  }\n\n  if (FAILED(ctx.sink_writer->SetInputMediaType(ctx.video_stream_index, media_type_in.get(),\n                                                nullptr))) {\n    return std::unexpected(\"Failed to set GPU input media type\");\n  }\n\n  // 10. 创建共享纹理 (编码器专用)\n  D3D11_TEXTURE2D_DESC tex_desc = {};\n  tex_desc.Width = config.width;\n  tex_desc.Height = config.height;\n  tex_desc.MipLevels = 1;\n  tex_desc.ArraySize = 1;\n  tex_desc.Format = DXGI_FORMAT_B8G8R8A8_UNORM;\n  tex_desc.SampleDesc.Count = 1;\n  tex_desc.Usage = D3D11_USAGE_DEFAULT;\n  tex_desc.BindFlags = D3D11_BIND_RENDER_TARGET;  // 允许作为渲染目标\n  tex_desc.MiscFlags = 0;\n\n  if (FAILED(device->CreateTexture2D(&tex_desc, nullptr, ctx.shared_texture.put()))) {\n    return std::unexpected(\"Failed to create shared texture for GPU encoding\");\n  }\n\n  // 11. 如果有音频格式，添加音频流（必须在 BeginWriting 之前）\n  if (wave_format) {\n    auto audio_result = add_audio_stream(ctx, wave_format, config.audio_bitrate);\n    if (!audio_result) {\n      Logger().warn(\"Failed to add audio stream to GPU encoder: {}\", audio_result.error());\n    }\n  }\n\n  // 12. 开始写入\n  if (FAILED(ctx.sink_writer->BeginWriting())) {\n    return std::unexpected(\"Failed to begin writing with GPU encoder\");\n  }\n\n  // 缓存尺寸信息\n  ctx.frame_width = config.width;\n  ctx.frame_height = config.height;\n  ctx.buffer_size = config.width * config.height * 4;\n  ctx.gpu_encoding = true;\n  return {};\n}\n\n// 创建 CPU 编码器 (fallback)\nauto create_cpu_encoder(State::EncoderContext& ctx, const Types::EncoderConfig& config,\n                        WAVEFORMATEX* wave_format) -> std::expected<void, std::string> {\n  // 确保目录存在\n  std::filesystem::create_directories(config.output_path.parent_path());\n\n  // 1. 创建 Byte Stream\n  wil::com_ptr<IMFByteStream> byte_stream;\n  if (FAILED(MFCreateFile(MF_ACCESSMODE_WRITE, MF_OPENMODE_DELETE_IF_EXIST, MF_FILEFLAGS_NONE,\n                          config.output_path.c_str(), byte_stream.put()))) {\n    return std::unexpected(\"Failed to create byte stream\");\n  }\n\n  // 2. 创建 Media Sink\n  wil::com_ptr<IMFMediaSink> media_sink;\n  if (FAILED(MFCreateMPEG4MediaSink(byte_stream.get(), nullptr, nullptr, media_sink.put()))) {\n    return std::unexpected(\"Failed to create MPEG4 media sink\");\n  }\n\n  // 3. 创建 Sink Writer 属性并预设编码器参数\n  wil::com_ptr<IMFAttributes> attributes;\n  if (FAILED(MFCreateAttributes(attributes.put(), 8))) {\n    return std::unexpected(\"Failed to create MF attributes\");\n  }\n\n  // 启用硬件加速 (仅对编码,输入仍然是 CPU 内存)\n  if (FAILED(attributes->SetUINT32(MF_READWRITE_ENABLE_HARDWARE_TRANSFORMS, TRUE))) {\n    Logger().warn(\"Failed to enable hardware transforms for encoder\");\n  }\n\n  // 在创建 Sink Writer 前预设 Rate Control Mode\n  UINT32 rate_control_mode = eAVEncCommonRateControlMode_CBR;  // 默认 CBR\n\n  if (config.rate_control == Types::RateControlMode::VBR) {\n    rate_control_mode = eAVEncCommonRateControlMode_Quality;\n  } else if (config.rate_control == Types::RateControlMode::ManualQP) {\n    rate_control_mode = eAVEncCommonRateControlMode_Quality;  // QP 模式也使用 Quality\n  }\n\n  if (FAILED(attributes->SetUINT32(CODECAPI_AVEncCommonRateControlMode, rate_control_mode))) {\n    Logger().warn(\"Failed to set rate control mode attribute\");\n  }\n\n  // 预设 Quality (VBR 模式) 或 QP (ManualQP 模式)\n  if (config.rate_control == Types::RateControlMode::VBR) {\n    if (FAILED(attributes->SetUINT32(CODECAPI_AVEncCommonQuality, config.quality))) {\n      Logger().warn(\"Failed to set quality attribute\");\n    }\n    Logger().info(\"CPU encoder configured for VBR mode with quality: {}\", config.quality);\n  } else if (config.rate_control == Types::RateControlMode::ManualQP) {\n    // 尝试设置 QP 参数（可能不被所有编码器支持）\n    if (FAILED(attributes->SetUINT32(CODECAPI_AVEncVideoEncodeQP, config.qp))) {\n      Logger().warn(\"Failed to set QP attribute - encoder may not support Manual QP mode\");\n    }\n    Logger().info(\"CPU encoder configured for Manual QP mode with QP: {}\", config.qp);\n  } else {\n    Logger().info(\"CPU encoder configured for CBR mode\");\n  }\n\n  // 4. 用预设属性创建 Sink Writer\n  if (FAILED(MFCreateSinkWriterFromMediaSink(media_sink.get(), attributes.get(),\n                                             ctx.sink_writer.put()))) {\n    return std::unexpected(\"Failed to create Sink Writer from media sink\");\n  }\n\n  // 5. 创建输出媒体类型\n  auto media_type_out =\n      create_output_media_type(config.width, config.height, config.fps, config.bitrate,\n                               config.keyframe_interval, config.codec);\n  if (!media_type_out) {\n    return std::unexpected(\"Failed to create output media type\");\n  }\n\n  if (FAILED(ctx.sink_writer->AddStream(media_type_out.get(), &ctx.video_stream_index))) {\n    return std::unexpected(\"Failed to add video stream\");\n  }\n\n  // 6. 创建输入媒体类型\n  auto media_type_in = create_input_media_type(config.width, config.height, config.fps, true);\n  if (!media_type_in) {\n    return std::unexpected(\"Failed to create input media type\");\n  }\n\n  if (FAILED(ctx.sink_writer->SetInputMediaType(ctx.video_stream_index, media_type_in.get(),\n                                                nullptr))) {\n    return std::unexpected(\"Failed to set input media type\");\n  }\n\n  // 7. 如果有音频格式，添加音频流（必须在 BeginWriting 之前）\n  if (wave_format) {\n    auto audio_result = add_audio_stream(ctx, wave_format, config.audio_bitrate);\n    if (!audio_result) {\n      Logger().warn(\"Failed to add audio stream to CPU encoder: {}\", audio_result.error());\n    }\n  }\n\n  // 8. 开始写入\n  if (FAILED(ctx.sink_writer->BeginWriting())) {\n    return std::unexpected(\"Failed to begin writing\");\n  }\n\n  // 缓存尺寸信息\n  ctx.frame_width = config.width;\n  ctx.frame_height = config.height;\n  ctx.buffer_size = config.width * config.height * 4;\n  ctx.gpu_encoding = false;\n  return {};\n}\n\nauto create_encoder(const Types::EncoderConfig& config, ID3D11Device* device,\n                    WAVEFORMATEX* wave_format)\n    -> std::expected<State::EncoderContext, std::string> {\n  auto ctx = std::make_unique<State::EncoderContext>();\n\n  bool try_gpu = (config.encoder_mode == Types::EncoderMode::Auto ||\n                  config.encoder_mode == Types::EncoderMode::GPU) &&\n                 device != nullptr;\n\n  const char* codec_name = (config.codec == Types::VideoCodec::H265) ? \"H.265\" : \"H.264\";\n\n  if (try_gpu) {\n    auto gpu_result = try_create_gpu_encoder(*ctx, config, device, wave_format);\n    if (gpu_result) {\n      Logger().info(\"GPU encoder created: {}x{} @ {}fps, {} bps, codec: {}\", config.width,\n                    config.height, config.fps, config.bitrate, codec_name);\n      return std::move(*ctx);\n    }\n\n    // GPU 失败\n    Logger().warn(\"Failed to create GPU encoder: {}\", gpu_result.error());\n\n    if (config.encoder_mode == Types::EncoderMode::GPU) {\n      // 强制 GPU 模式，不降级\n      return std::unexpected(gpu_result.error());\n    }\n\n    // Auto 模式，降级到 CPU\n    Logger().info(\"Falling back to CPU encoder\");\n    ctx = std::make_unique<State::EncoderContext>();  // 重置 context\n  }\n\n  // CPU 编码\n  auto cpu_result = create_cpu_encoder(*ctx, config, wave_format);\n  if (!cpu_result) {\n    return std::unexpected(cpu_result.error());\n  }\n\n  Logger().info(\"CPU encoder created: {}x{} @ {}fps, {} bps, codec: {}\", config.width,\n                config.height, config.fps, config.bitrate, codec_name);\n  return std::move(*ctx);\n}\n\n// GPU 编码帧（内部函数）\nauto encode_frame_gpu(State::EncoderContext& encoder, ID3D11DeviceContext* context,\n                      ID3D11Texture2D* frame_texture, int64_t timestamp_100ns, uint32_t fps)\n    -> std::expected<void, std::string> {\n  // 1. 复制到共享纹理\n  context->CopyResource(encoder.shared_texture.get(), frame_texture);\n\n  // 2. 从 DXGI Surface 创建 MF Buffer\n  wil::com_ptr<IMFMediaBuffer> buffer;\n  wil::com_ptr<IDXGISurface> surface;\n\n  if (FAILED(encoder.shared_texture->QueryInterface(IID_PPV_ARGS(surface.put())))) {\n    return std::unexpected(\"Failed to query DXGI surface\");\n  }\n\n  if (FAILED(MFCreateDXGISurfaceBuffer(__uuidof(ID3D11Texture2D), surface.get(), 0, FALSE,\n                                       buffer.put()))) {\n    return std::unexpected(\"Failed to create DXGI surface buffer\");\n  }\n\n  // 设置 buffer 长度（使用缓存的尺寸）\n  buffer->SetCurrentLength(encoder.buffer_size);\n\n  // 3. 创建 Sample\n  wil::com_ptr<IMFSample> sample;\n  if (FAILED(MFCreateSample(sample.put()))) {\n    return std::unexpected(\"Failed to create MF sample\");\n  }\n\n  sample->AddBuffer(buffer.get());\n  sample->SetSampleTime(timestamp_100ns);\n  sample->SetSampleDuration(10'000'000 / fps);\n\n  // 4. 写入\n  if (FAILED(encoder.sink_writer->WriteSample(encoder.video_stream_index, sample.get()))) {\n    return std::unexpected(\"Failed to write GPU sample\");\n  }\n\n  return {};\n}\n\n// CPU 编码帧（内部函数）\nauto encode_frame_cpu(State::EncoderContext& encoder, ID3D11DeviceContext* context,\n                      ID3D11Texture2D* frame_texture, int64_t timestamp_100ns, uint32_t fps)\n    -> std::expected<void, std::string> {\n  // 1. 获取纹理描述\n  D3D11_TEXTURE2D_DESC desc;\n  frame_texture->GetDesc(&desc);\n\n  // 2. 确保 staging texture 存在且尺寸匹配\n  if (!encoder.staging_texture) {\n    D3D11_TEXTURE2D_DESC staging_desc = desc;\n    staging_desc.BindFlags = 0;\n    staging_desc.CPUAccessFlags = D3D11_CPU_ACCESS_READ;\n    staging_desc.Usage = D3D11_USAGE_STAGING;\n    staging_desc.MiscFlags = 0;\n\n    wil::com_ptr<ID3D11Device> device;\n    frame_texture->GetDevice(device.put());\n    if (FAILED(device->CreateTexture2D(&staging_desc, nullptr, encoder.staging_texture.put()))) {\n      return std::unexpected(\"Failed to create staging texture\");\n    }\n  }\n\n  // 3. 首次使用时创建可复用的 Sample 和 Buffer\n  if (!encoder.reusable_sample) {\n    if (FAILED(MFCreateSample(encoder.reusable_sample.put()))) {\n      return std::unexpected(\"Failed to create reusable sample\");\n    }\n    if (FAILED(MFCreateMemoryBuffer(encoder.buffer_size, encoder.reusable_buffer.put()))) {\n      return std::unexpected(\"Failed to create reusable buffer\");\n    }\n    encoder.reusable_sample->AddBuffer(encoder.reusable_buffer.get());\n  }\n\n  // 4. 复制纹理数据\n  context->CopyResource(encoder.staging_texture.get(), frame_texture);\n\n  // 5. 映射 staging texture 读取数据\n  D3D11_MAPPED_SUBRESOURCE mapped;\n  if (FAILED(context->Map(encoder.staging_texture.get(), 0, D3D11_MAP_READ, 0, &mapped))) {\n    return std::unexpected(\"Failed to map staging texture\");\n  }\n\n  // 6. 将数据写入复用的 buffer\n  BYTE* dest = nullptr;\n  HRESULT hr = encoder.reusable_buffer->Lock(&dest, nullptr, nullptr);\n  if (SUCCEEDED(hr)) {\n    const BYTE* src = static_cast<const BYTE*>(mapped.pData);\n    const UINT row_pitch = encoder.frame_width * 4;\n\n    if (row_pitch == mapped.RowPitch) {\n      // 直接复制\n      std::memcpy(dest, src, encoder.buffer_size);\n    } else {\n      // 逐行复制\n      for (UINT y = 0; y < encoder.frame_height; ++y) {\n        std::memcpy(dest + y * row_pitch, src + y * mapped.RowPitch, row_pitch);\n      }\n    }\n\n    encoder.reusable_buffer->Unlock();\n    encoder.reusable_buffer->SetCurrentLength(encoder.buffer_size);\n\n    // 7. 设置时间戳并写入\n    encoder.reusable_sample->SetSampleTime(timestamp_100ns);\n    encoder.reusable_sample->SetSampleDuration(10'000'000 / fps);\n    hr =\n        encoder.sink_writer->WriteSample(encoder.video_stream_index, encoder.reusable_sample.get());\n  }\n\n  context->Unmap(encoder.staging_texture.get(), 0);\n\n  if (FAILED(hr)) {\n    return std::unexpected(\"Failed to write CPU sample\");\n  }\n  return {};\n}\n\nauto encode_frame(State::EncoderContext& encoder, ID3D11DeviceContext* context,\n                  ID3D11Texture2D* frame_texture, int64_t timestamp_100ns, uint32_t fps)\n    -> std::expected<void, std::string> {\n  if (!encoder.sink_writer || !context || !frame_texture) {\n    return std::unexpected(\"Invalid encoder state\");\n  }\n\n  // 注：线程同步由调用方管理\n  if (encoder.gpu_encoding) {\n    return encode_frame_gpu(encoder, context, frame_texture, timestamp_100ns, fps);\n  } else {\n    return encode_frame_cpu(encoder, context, frame_texture, timestamp_100ns, fps);\n  }\n}\n\nauto encode_audio(State::EncoderContext& encoder, const BYTE* audio_data, UINT32 num_frames,\n                  UINT32 bytes_per_frame, int64_t timestamp_100ns)\n    -> std::expected<void, std::string> {\n  if (!encoder.sink_writer || !encoder.has_audio) {\n    return std::unexpected(\"Audio stream not available\");\n  }\n\n  if (!audio_data || num_frames == 0) {\n    return {};  // 空数据，跳过\n  }\n\n  UINT32 data_size = num_frames * bytes_per_frame;\n\n  // 创建音频 buffer\n  wil::com_ptr<IMFMediaBuffer> buffer;\n  if (FAILED(MFCreateMemoryBuffer(data_size, buffer.put()))) {\n    return std::unexpected(\"Failed to create audio buffer\");\n  }\n\n  // 复制音频数据\n  BYTE* buffer_data = nullptr;\n  if (FAILED(buffer->Lock(&buffer_data, nullptr, nullptr))) {\n    return std::unexpected(\"Failed to lock audio buffer\");\n  }\n\n  std::memcpy(buffer_data, audio_data, data_size);\n  buffer->Unlock();\n  buffer->SetCurrentLength(data_size);\n\n  // 创建音频 sample\n  wil::com_ptr<IMFSample> sample;\n  if (FAILED(MFCreateSample(sample.put()))) {\n    return std::unexpected(\"Failed to create audio sample\");\n  }\n\n  sample->AddBuffer(buffer.get());\n  sample->SetSampleTime(timestamp_100ns);\n\n  // 注：线程同步由调用方管理\n  if (FAILED(encoder.sink_writer->WriteSample(encoder.audio_stream_index, sample.get()))) {\n    return std::unexpected(\"Failed to write audio sample\");\n  }\n\n  return {};\n}\n\nauto finalize_encoder(State::EncoderContext& encoder) -> std::expected<void, std::string> {\n  if (encoder.sink_writer) {\n    if (FAILED(encoder.sink_writer->Finalize())) {\n      return std::unexpected(\"Failed to finalize sink writer\");\n    }\n    encoder.sink_writer = nullptr;\n  }\n\n  // 清理所有资源\n  encoder.staging_texture = nullptr;\n  encoder.reusable_sample = nullptr;\n  encoder.reusable_buffer = nullptr;\n  encoder.shared_texture = nullptr;\n  encoder.dxgi_manager = nullptr;\n\n  return {};\n}\n\n}  // namespace Utils::Media::Encoder\n"
  },
  {
    "path": "src/utils/media/encoder.ixx",
    "content": "module;\n\nexport module Utils.Media.Encoder;\n\nimport std;\nimport Utils.Media.Encoder.Types;\nimport Utils.Media.Encoder.State;\nimport <audioclient.h>;\nimport <d3d11.h>;\n\nexport namespace Utils::Media::Encoder {\n\n// 创建编码器\nauto create_encoder(const Utils::Media::Encoder::Types::EncoderConfig& config, ID3D11Device* device,\n                    WAVEFORMATEX* wave_format = nullptr)\n    -> std::expected<Utils::Media::Encoder::State::EncoderContext, std::string>;\n\n// 编码视频帧\nauto encode_frame(Utils::Media::Encoder::State::EncoderContext& encoder,\n                  ID3D11DeviceContext* context, ID3D11Texture2D* frame_texture,\n                  std::int64_t timestamp_100ns, std::uint32_t fps)\n    -> std::expected<void, std::string>;\n\n// 编码音频样本\nauto encode_audio(Utils::Media::Encoder::State::EncoderContext& encoder, const BYTE* audio_data,\n                  UINT32 num_frames, UINT32 bytes_per_frame, std::int64_t timestamp_100ns)\n    -> std::expected<void, std::string>;\n\n// 完成编码\nauto finalize_encoder(Utils::Media::Encoder::State::EncoderContext& encoder)\n    -> std::expected<void, std::string>;\n\n}  // namespace Utils::Media::Encoder\n"
  },
  {
    "path": "src/utils/media/raw_encoder.cpp",
    "content": "module;\n\n#include <codecapi.h>\n#include <mfidl.h>\n#include <mfobjects.h>\n\nmodule Utils.Media.RawEncoder;\n\nimport std;\nimport Utils.Logger;\nimport <audioclient.h>;\nimport <d3d11.h>;\nimport <mfapi.h>;\nimport <mferror.h>;\nimport <wil/com.h>;\n\nnamespace Utils::Media::RawEncoder {\n\n// 辅助函数：从 IMFSample 提取压缩数据\nauto extract_sample_data(IMFSample* sample, EncodedFrame& frame)\n    -> std::expected<void, std::string> {\n  if (!sample) {\n    return std::unexpected(\"Null sample\");\n  }\n\n  // 获取时间戳\n  LONGLONG sample_time = 0;\n  sample->GetSampleTime(&sample_time);\n  frame.timestamp_100ns = sample_time;\n\n  // 获取时长\n  LONGLONG sample_duration = 0;\n  sample->GetSampleDuration(&sample_duration);\n  frame.duration_100ns = sample_duration;\n\n  // 获取 buffer\n  wil::com_ptr<IMFMediaBuffer> buffer;\n  HRESULT hr = sample->ConvertToContiguousBuffer(buffer.put());\n  if (FAILED(hr)) {\n    return std::unexpected(\"Failed to get contiguous buffer\");\n  }\n\n  // 锁定并复制数据\n  BYTE* data = nullptr;\n  DWORD data_length = 0;\n  hr = buffer->Lock(&data, nullptr, &data_length);\n  if (FAILED(hr)) {\n    return std::unexpected(\"Failed to lock buffer\");\n  }\n\n  frame.data.resize(data_length);\n  std::memcpy(frame.data.data(), data, data_length);\n  buffer->Unlock();\n\n  return {};\n}\n\n// 辅助函数：检查 sample 是否为关键帧\nauto is_keyframe_sample(IMFSample* sample) -> bool {\n  if (!sample) return false;\n\n  UINT32 clean_point = 0;\n  if (SUCCEEDED(sample->GetUINT32(MFSampleExtension_CleanPoint, &clean_point))) {\n    return clean_point != 0;\n  }\n  return false;\n}\n\n// 辅助函数：等待异步 MFT 事件\nauto wait_events(RawEncoderContext& ctx) -> std::expected<void, std::string> {\n  if (!ctx.async_events) return {};\n\n  while (!(ctx.async_need_input || ctx.async_have_output || ctx.draining_done)) {\n    wil::com_ptr<IMFMediaEvent> event;\n    HRESULT hr = ctx.async_events->GetEvent(0, event.put());\n    if (FAILED(hr)) {\n      return std::unexpected(\"GetEvent failed: \" + std::to_string(hr));\n    }\n\n    MediaEventType event_type;\n    event->GetType(&event_type);\n\n    switch (event_type) {\n      case METransformNeedInput:\n        if (!ctx.draining) ctx.async_need_input = true;\n        break;\n      case METransformHaveOutput:\n        ctx.async_have_output = true;\n        break;\n      case METransformDrainComplete:\n        ctx.draining_done = true;\n        break;\n      default:\n        break;\n    }\n  }\n  return {};\n}\n\n// 辅助函数：从 Transform 获取输出（视频用 RawEncoderContext 版本）\nauto get_transform_output(RawEncoderContext& ctx, bool is_audio)\n    -> std::expected<std::optional<EncodedFrame>, std::string> {\n  IMFTransform* transform = is_audio ? ctx.audio_encoder.get() : ctx.video_encoder.get();\n  if (!transform) {\n    return std::unexpected(\"Transform not initialized\");\n  }\n\n  MFT_OUTPUT_STREAM_INFO stream_info = {};\n  HRESULT hr = transform->GetOutputStreamInfo(0, &stream_info);\n  if (FAILED(hr)) {\n    return std::unexpected(\"Failed to get output stream info\");\n  }\n\n  // 准备输出 buffer\n  MFT_OUTPUT_DATA_BUFFER output_buffer = {};\n  output_buffer.dwStreamID = 0;\n\n  // 检查 MFT 是否自己分配 sample\n  bool mft_provides_samples = (stream_info.dwFlags & MFT_OUTPUT_STREAM_PROVIDES_SAMPLES) != 0;\n\n  wil::com_ptr<IMFSample> output_sample;\n  if (!mft_provides_samples) {\n    // 我们需要提供 sample\n    hr = MFCreateSample(output_sample.put());\n    if (FAILED(hr)) {\n      return std::unexpected(\"Failed to create output sample\");\n    }\n\n    wil::com_ptr<IMFMediaBuffer> output_media_buffer;\n    DWORD buffer_size = stream_info.cbSize > 0 ? stream_info.cbSize : (1024 * 1024);  // 默认 1MB\n    hr = MFCreateMemoryBuffer(buffer_size, output_media_buffer.put());\n    if (FAILED(hr)) {\n      return std::unexpected(\"Failed to create output buffer\");\n    }\n\n    output_sample->AddBuffer(output_media_buffer.get());\n    output_buffer.pSample = output_sample.get();\n  }\n\n  DWORD status = 0;\n  hr = transform->ProcessOutput(0, 1, &output_buffer, &status);\n\n  if (hr == MF_E_TRANSFORM_NEED_MORE_INPUT) {\n    // 没有输出可用，这是正常的（同步模式）\n    return std::nullopt;\n  }\n\n  if (FAILED(hr)) {\n    return std::unexpected(\"ProcessOutput failed: \" + std::to_string(hr));\n  }\n\n  // 异步模式：已消费一个输出事件\n  if (ctx.is_async && !is_audio) {\n    ctx.async_have_output = false;\n  }\n\n  // 如果 MFT 提供了 sample\n  IMFSample* result_sample = mft_provides_samples ? output_buffer.pSample : output_sample.get();\n  if (!result_sample) {\n    return std::nullopt;\n  }\n\n  EncodedFrame frame;\n  frame.is_audio = is_audio;\n  frame.is_keyframe = !is_audio && is_keyframe_sample(result_sample);\n\n  auto extract_result = extract_sample_data(result_sample, frame);\n  if (!extract_result) {\n    // 释放 MFT 分配的 sample\n    if (mft_provides_samples && output_buffer.pSample) {\n      output_buffer.pSample->Release();\n    }\n    return std::unexpected(extract_result.error());\n  }\n\n  // 释放 MFT 分配的 sample\n  if (mft_provides_samples && output_buffer.pSample) {\n    output_buffer.pSample->Release();\n  }\n\n  return frame;\n}\n\n// 辅助函数：创建 D3D11 Video Processor 用于 BGRA→NV12 转换\nauto create_video_processor(RawEncoderContext& ctx, ID3D11Device* device, std::uint32_t width,\n                            std::uint32_t height) -> std::expected<void, std::string> {\n  // 获取 Video Device 接口\n  HRESULT hr = device->QueryInterface(IID_PPV_ARGS(ctx.video_device.put()));\n  if (FAILED(hr)) {\n    return std::unexpected(\"Failed to get ID3D11VideoDevice\");\n  }\n\n  // 获取 Video Context 接口\n  wil::com_ptr<ID3D11DeviceContext> d3d_context;\n  device->GetImmediateContext(d3d_context.put());\n  hr = d3d_context->QueryInterface(IID_PPV_ARGS(ctx.video_context.put()));\n  if (FAILED(hr)) {\n    return std::unexpected(\"Failed to get ID3D11VideoContext\");\n  }\n\n  // 创建 Video Processor Enumerator\n  D3D11_VIDEO_PROCESSOR_CONTENT_DESC content_desc = {};\n  content_desc.InputFrameFormat = D3D11_VIDEO_FRAME_FORMAT_PROGRESSIVE;\n  content_desc.InputWidth = width;\n  content_desc.InputHeight = height;\n  content_desc.OutputWidth = width;\n  content_desc.OutputHeight = height;\n  content_desc.Usage = D3D11_VIDEO_USAGE_PLAYBACK_NORMAL;\n\n  hr = ctx.video_device->CreateVideoProcessorEnumerator(&content_desc, ctx.vp_enum.put());\n  if (FAILED(hr)) {\n    return std::unexpected(\"Failed to create video processor enumerator\");\n  }\n\n  // 创建 Video Processor\n  hr = ctx.video_device->CreateVideoProcessor(ctx.vp_enum.get(), 0, ctx.video_processor.put());\n  if (FAILED(hr)) {\n    return std::unexpected(\"Failed to create video processor\");\n  }\n\n  // 创建 NV12 中间纹理（编码器从这里读取）\n  D3D11_TEXTURE2D_DESC nv12_desc = {};\n  nv12_desc.Width = width;\n  nv12_desc.Height = height;\n  nv12_desc.MipLevels = 1;\n  nv12_desc.ArraySize = 1;\n  nv12_desc.Format = DXGI_FORMAT_NV12;\n  nv12_desc.SampleDesc.Count = 1;\n  nv12_desc.Usage = D3D11_USAGE_DEFAULT;\n  nv12_desc.BindFlags = D3D11_BIND_RENDER_TARGET;\n\n  hr = device->CreateTexture2D(&nv12_desc, nullptr, ctx.nv12_texture.put());\n  if (FAILED(hr)) {\n    return std::unexpected(\"Failed to create NV12 texture\");\n  }\n\n  ctx.needs_nv12_conversion = true;\n  Logger().info(\"D3D11 Video Processor created for BGRA->NV12 conversion\");\n  return {};\n}\n\n// 辅助函数：执行 BGRA→NV12 转换\nauto convert_bgra_to_nv12(RawEncoderContext& ctx, ID3D11Texture2D* bgra_texture) -> HRESULT {\n  // 创建输入视图 (BGRA)\n  D3D11_VIDEO_PROCESSOR_INPUT_VIEW_DESC input_view_desc = {};\n  input_view_desc.FourCC = 0;\n  input_view_desc.ViewDimension = D3D11_VPIV_DIMENSION_TEXTURE2D;\n  input_view_desc.Texture2D.MipSlice = 0;\n\n  wil::com_ptr<ID3D11VideoProcessorInputView> input_view;\n  HRESULT hr = ctx.video_device->CreateVideoProcessorInputView(bgra_texture, ctx.vp_enum.get(),\n                                                               &input_view_desc, input_view.put());\n  if (FAILED(hr)) return hr;\n\n  // 创建输出视图 (NV12)\n  D3D11_VIDEO_PROCESSOR_OUTPUT_VIEW_DESC output_view_desc = {};\n  output_view_desc.ViewDimension = D3D11_VPOV_DIMENSION_TEXTURE2D;\n  output_view_desc.Texture2D.MipSlice = 0;\n\n  wil::com_ptr<ID3D11VideoProcessorOutputView> output_view;\n  hr = ctx.video_device->CreateVideoProcessorOutputView(ctx.nv12_texture.get(), ctx.vp_enum.get(),\n                                                        &output_view_desc, output_view.put());\n  if (FAILED(hr)) return hr;\n\n  // 执行转换\n  D3D11_VIDEO_PROCESSOR_STREAM stream = {};\n  stream.Enable = TRUE;\n  stream.OutputIndex = 0;\n  stream.InputFrameOrField = 0;\n  stream.pInputSurface = input_view.get();\n\n  return ctx.video_context->VideoProcessorBlt(ctx.video_processor.get(), output_view.get(), 0, 1,\n                                              &stream);\n}\n\n// 创建视频编码器 Transform\nauto create_video_encoder(RawEncoderContext& ctx, const RawEncoderConfig& config,\n                          ID3D11Device* device) -> std::expected<void, std::string> {\n  // 1. 枚举 H.264 编码器（包含异步 MFT，硬件编码器通常是异步的）\n  MFT_REGISTER_TYPE_INFO output_type_info = {};\n  output_type_info.guidMajorType = MFMediaType_Video;\n  output_type_info.guidSubtype = MFVideoFormat_H264;\n\n  UINT32 flags = MFT_ENUM_FLAG_SORTANDFILTER;\n  if (config.use_hardware && device) {\n    flags |= MFT_ENUM_FLAG_HARDWARE | MFT_ENUM_FLAG_ASYNCMFT;\n  } else {\n    flags |= MFT_ENUM_FLAG_SYNCMFT;\n  }\n\n  IMFActivate** activates = nullptr;\n  UINT32 count = 0;\n  HRESULT hr =\n      MFTEnumEx(MFT_CATEGORY_VIDEO_ENCODER, flags, nullptr, &output_type_info, &activates, &count);\n\n  // 如果硬件编码器找不到，回退到软件\n  if ((FAILED(hr) || count == 0) && config.use_hardware) {\n    Logger().warn(\"No hardware H.264 encoder found, falling back to software\");\n    flags = MFT_ENUM_FLAG_SYNCMFT | MFT_ENUM_FLAG_SORTANDFILTER;\n    hr = MFTEnumEx(MFT_CATEGORY_VIDEO_ENCODER, flags, nullptr, &output_type_info, &activates,\n                   &count);\n  }\n\n  if (FAILED(hr) || count == 0) {\n    return std::unexpected(\"No H.264 encoder found\");\n  }\n\n  // 激活第一个编码器\n  hr = activates[0]->ActivateObject(IID_PPV_ARGS(ctx.video_encoder.put()));\n\n  // 释放 activates\n  for (UINT32 i = 0; i < count; i++) {\n    activates[i]->Release();\n  }\n  CoTaskMemFree(activates);\n\n  if (FAILED(hr)) {\n    return std::unexpected(\"Failed to activate encoder\");\n  }\n\n  // 2. 解锁异步 MFT 并获取事件生成器\n  wil::com_ptr<IMFAttributes> encoder_attrs;\n  hr = ctx.video_encoder->GetAttributes(encoder_attrs.put());\n  if (SUCCEEDED(hr)) {\n    UINT32 is_async = 0;\n    hr = encoder_attrs->GetUINT32(MF_TRANSFORM_ASYNC, &is_async);\n    if (SUCCEEDED(hr) && is_async) {\n      encoder_attrs->SetUINT32(MF_TRANSFORM_ASYNC_UNLOCK, TRUE);\n      Logger().info(\"Async MFT detected and unlocked\");\n\n      // 获取事件生成器（异步 MFT 必须通过事件驱动 ProcessInput/ProcessOutput）\n      hr = ctx.video_encoder->QueryInterface(IID_PPV_ARGS(ctx.async_events.put()));\n      if (SUCCEEDED(hr)) {\n        ctx.is_async = true;\n        Logger().info(\"Async MFT event generator acquired\");\n      } else {\n        Logger().warn(\"Failed to get IMFMediaEventGenerator, falling back to sync mode\");\n      }\n    }\n  }\n\n  // 3. 如果是硬件编码，设置 D3D Manager\n  if (config.use_hardware && device) {\n    hr = MFCreateDXGIDeviceManager(&ctx.dxgi_reset_token, ctx.dxgi_manager.put());\n    if (FAILED(hr)) {\n      return std::unexpected(\"Failed to create DXGI manager\");\n    }\n\n    hr = ctx.dxgi_manager->ResetDevice(device, ctx.dxgi_reset_token);\n    if (FAILED(hr)) {\n      return std::unexpected(\"Failed to reset DXGI device\");\n    }\n\n    hr = ctx.video_encoder->ProcessMessage(MFT_MESSAGE_SET_D3D_MANAGER,\n                                           reinterpret_cast<ULONG_PTR>(ctx.dxgi_manager.get()));\n    if (FAILED(hr)) {\n      Logger().warn(\"Failed to set D3D manager on encoder, falling back to CPU\");\n      ctx.gpu_encoding = false;\n    } else {\n      ctx.gpu_encoding = true;\n    }\n  }\n\n  // 4. 设置输出媒体类型（H.264）\n  wil::com_ptr<IMFMediaType> output_type;\n  hr = MFCreateMediaType(output_type.put());\n  if (FAILED(hr)) {\n    return std::unexpected(\"Failed to create output media type\");\n  }\n\n  output_type->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Video);\n  output_type->SetGUID(MF_MT_SUBTYPE, MFVideoFormat_H264);\n  output_type->SetUINT32(MF_MT_AVG_BITRATE, config.bitrate);\n  output_type->SetUINT32(MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive);\n  MFSetAttributeSize(output_type.get(), MF_MT_FRAME_SIZE, config.width, config.height);\n  MFSetAttributeRatio(output_type.get(), MF_MT_FRAME_RATE, config.fps, 1);\n  MFSetAttributeRatio(output_type.get(), MF_MT_PIXEL_ASPECT_RATIO, 1, 1);\n  output_type->SetUINT32(MF_MT_MPEG2_PROFILE, eAVEncH264VProfile_Main);\n  output_type->SetUINT32(MF_MT_MAX_KEYFRAME_SPACING, config.fps * config.keyframe_interval);\n\n  hr = ctx.video_encoder->SetOutputType(0, output_type.get(), 0);\n  if (FAILED(hr)) {\n    return std::unexpected(\"Failed to set encoder output type: \" + std::to_string(hr));\n  }\n\n  // 保存输出类型（包含 SPS/PPS）\n  hr = ctx.video_encoder->GetOutputCurrentType(0, ctx.video_output_type.put());\n  if (FAILED(hr)) {\n    ctx.video_output_type = output_type;\n  }\n\n  // 5. 枚举编码器支持的输入类型并选择最佳格式\n  bool input_type_set = false;\n  GUID chosen_subtype = {};\n\n  // 优先尝试 ARGB32（无需颜色转换）\n  {\n    wil::com_ptr<IMFMediaType> argb_type;\n    MFCreateMediaType(argb_type.put());\n    argb_type->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Video);\n    argb_type->SetGUID(MF_MT_SUBTYPE, MFVideoFormat_ARGB32);\n    argb_type->SetUINT32(MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive);\n    MFSetAttributeSize(argb_type.get(), MF_MT_FRAME_SIZE, config.width, config.height);\n    MFSetAttributeRatio(argb_type.get(), MF_MT_FRAME_RATE, config.fps, 1);\n    MFSetAttributeRatio(argb_type.get(), MF_MT_PIXEL_ASPECT_RATIO, 1, 1);\n\n    if (SUCCEEDED(ctx.video_encoder->SetInputType(0, argb_type.get(), 0))) {\n      input_type_set = true;\n      chosen_subtype = MFVideoFormat_ARGB32;\n      Logger().info(\"RawEncoder using ARGB32 input (no conversion needed)\");\n    }\n  }\n\n  // 如果 ARGB32 不行，枚举编码器实际支持的类型\n  if (!input_type_set) {\n    for (DWORD i = 0;; i++) {\n      wil::com_ptr<IMFMediaType> available_type;\n      hr = ctx.video_encoder->GetInputAvailableType(0, i, available_type.put());\n      if (FAILED(hr)) break;\n\n      // 在枚举到的类型上设置我们的参数\n      MFSetAttributeSize(available_type.get(), MF_MT_FRAME_SIZE, config.width, config.height);\n      MFSetAttributeRatio(available_type.get(), MF_MT_FRAME_RATE, config.fps, 1);\n      MFSetAttributeRatio(available_type.get(), MF_MT_PIXEL_ASPECT_RATIO, 1, 1);\n      available_type->SetUINT32(MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive);\n\n      hr = ctx.video_encoder->SetInputType(0, available_type.get(), 0);\n      if (SUCCEEDED(hr)) {\n        available_type->GetGUID(MF_MT_SUBTYPE, &chosen_subtype);\n        input_type_set = true;\n\n        // 记录选中的格式\n        if (chosen_subtype == MFVideoFormat_NV12) {\n          Logger().info(\"RawEncoder using NV12 input (BGRA->NV12 conversion needed)\");\n        } else {\n          Logger().info(\"RawEncoder using enumerated input type index {}\", i);\n        }\n        break;\n      }\n    }\n  }\n\n  if (!input_type_set) {\n    return std::unexpected(\"Failed to set any encoder input type\");\n  }\n\n  // 6. 通知开始流\n  hr = ctx.video_encoder->ProcessMessage(MFT_MESSAGE_NOTIFY_BEGIN_STREAMING, 0);\n  if (FAILED(hr)) {\n    Logger().warn(\"Failed to notify begin streaming\");\n  }\n\n  hr = ctx.video_encoder->ProcessMessage(MFT_MESSAGE_NOTIFY_START_OF_STREAM, 0);\n  if (FAILED(hr)) {\n    Logger().warn(\"Failed to notify start of stream\");\n  }\n\n  // 7. 缓存信息\n  ctx.frame_width = config.width;\n  ctx.frame_height = config.height;\n  ctx.fps = config.fps;\n\n  // 8. 创建输入纹理和颜色转换器\n  if (ctx.gpu_encoding && device) {\n    bool need_nv12 = (chosen_subtype == MFVideoFormat_NV12);\n\n    if (need_nv12) {\n      // 需要 BGRA→NV12 转换：创建 BGRA 输入纹理 + Video Processor\n      D3D11_TEXTURE2D_DESC bgra_desc = {};\n      bgra_desc.Width = config.width;\n      bgra_desc.Height = config.height;\n      bgra_desc.MipLevels = 1;\n      bgra_desc.ArraySize = 1;\n      bgra_desc.Format = DXGI_FORMAT_B8G8R8A8_UNORM;\n      bgra_desc.SampleDesc.Count = 1;\n      bgra_desc.Usage = D3D11_USAGE_DEFAULT;\n      bgra_desc.BindFlags = D3D11_BIND_RENDER_TARGET;\n\n      hr = device->CreateTexture2D(&bgra_desc, nullptr, ctx.input_texture.put());\n      if (FAILED(hr)) {\n        return std::unexpected(\"Failed to create BGRA input texture\");\n      }\n\n      auto vp_result = create_video_processor(ctx, device, config.width, config.height);\n      if (!vp_result) {\n        return std::unexpected(\"Failed to create video processor: \" + vp_result.error());\n      }\n    } else {\n      // 不需要转换：直接创建 BGRA 纹理给编码器\n      D3D11_TEXTURE2D_DESC desc = {};\n      desc.Width = config.width;\n      desc.Height = config.height;\n      desc.MipLevels = 1;\n      desc.ArraySize = 1;\n      desc.Format = DXGI_FORMAT_B8G8R8A8_UNORM;\n      desc.SampleDesc.Count = 1;\n      desc.Usage = D3D11_USAGE_DEFAULT;\n      desc.BindFlags = D3D11_BIND_RENDER_TARGET;\n\n      hr = device->CreateTexture2D(&desc, nullptr, ctx.input_texture.put());\n      if (FAILED(hr)) {\n        return std::unexpected(\"Failed to create input texture\");\n      }\n    }\n  }\n\n  Logger().info(\"RawEncoder video encoder created: {}x{} @ {}fps, {} bps, GPU={}, NV12={}\",\n                config.width, config.height, config.fps, config.bitrate, ctx.gpu_encoding,\n                ctx.needs_nv12_conversion);\n\n  return {};\n}\n\n// 创建音频编码器 Transform\nauto create_audio_encoder(RawEncoderContext& ctx, WAVEFORMATEX* wave_format)\n    -> std::expected<void, std::string> {\n  if (!wave_format) {\n    return {};\n  }\n\n  // 枚举 AAC 编码器\n  MFT_REGISTER_TYPE_INFO output_type_info = {};\n  output_type_info.guidMajorType = MFMediaType_Audio;\n  output_type_info.guidSubtype = MFAudioFormat_AAC;\n\n  IMFActivate** activates = nullptr;\n  UINT32 count = 0;\n  HRESULT hr = MFTEnumEx(MFT_CATEGORY_AUDIO_ENCODER, MFT_ENUM_FLAG_SYNCMFT, nullptr,\n                         &output_type_info, &activates, &count);\n\n  if (FAILED(hr) || count == 0) {\n    Logger().warn(\"No AAC encoder found, audio will be disabled\");\n    return {};\n  }\n\n  hr = activates[0]->ActivateObject(IID_PPV_ARGS(ctx.audio_encoder.put()));\n\n  for (UINT32 i = 0; i < count; i++) {\n    activates[i]->Release();\n  }\n  CoTaskMemFree(activates);\n\n  if (FAILED(hr)) {\n    Logger().warn(\"Failed to activate AAC encoder\");\n    return {};\n  }\n\n  // 设置输出类型（AAC）\n  wil::com_ptr<IMFMediaType> output_type;\n  MFCreateMediaType(output_type.put());\n  output_type->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Audio);\n  output_type->SetGUID(MF_MT_SUBTYPE, MFAudioFormat_AAC);\n  output_type->SetUINT32(MF_MT_AUDIO_SAMPLES_PER_SECOND, wave_format->nSamplesPerSec);\n  output_type->SetUINT32(MF_MT_AUDIO_NUM_CHANNELS, wave_format->nChannels);\n  output_type->SetUINT32(MF_MT_AUDIO_BITS_PER_SAMPLE, 16);\n  output_type->SetUINT32(MF_MT_AUDIO_AVG_BYTES_PER_SECOND, 16000);  // ~128kbps\n\n  hr = ctx.audio_encoder->SetOutputType(0, output_type.get(), 0);\n  if (FAILED(hr)) {\n    Logger().warn(\"Failed to set audio output type\");\n    ctx.audio_encoder = nullptr;\n    return {};\n  }\n\n  // 获取实际的输出类型\n  ctx.audio_encoder->GetOutputCurrentType(0, ctx.audio_output_type.put());\n  if (!ctx.audio_output_type) {\n    ctx.audio_output_type = output_type;\n  }\n\n  // 设置输入类型（PCM）\n  wil::com_ptr<IMFMediaType> input_type;\n  MFCreateMediaType(input_type.put());\n  MFInitMediaTypeFromWaveFormatEx(input_type.get(), wave_format,\n                                  sizeof(WAVEFORMATEX) + wave_format->cbSize);\n\n  hr = ctx.audio_encoder->SetInputType(0, input_type.get(), 0);\n  if (FAILED(hr)) {\n    Logger().warn(\"Failed to set audio input type\");\n    ctx.audio_encoder = nullptr;\n    return {};\n  }\n\n  // 通知开始流\n  ctx.audio_encoder->ProcessMessage(MFT_MESSAGE_NOTIFY_BEGIN_STREAMING, 0);\n  ctx.audio_encoder->ProcessMessage(MFT_MESSAGE_NOTIFY_START_OF_STREAM, 0);\n\n  ctx.has_audio = true;\n  Logger().info(\"RawEncoder audio encoder created\");\n\n  return {};\n}\n\nauto create_encoder(const RawEncoderConfig& config, ID3D11Device* device, WAVEFORMATEX* wave_format)\n    -> std::expected<RawEncoderContext, std::string> {\n  RawEncoderContext ctx;\n\n  // 创建视频编码器\n  auto video_result = create_video_encoder(ctx, config, device);\n  if (!video_result) {\n    return std::unexpected(video_result.error());\n  }\n\n  // 创建音频编码器\n  auto audio_result = create_audio_encoder(ctx, wave_format);\n  if (!audio_result) {\n    Logger().warn(\"Audio encoder creation failed: {}\", audio_result.error());\n  }\n\n  return ctx;\n}\n\nauto encode_video_frame(RawEncoderContext& ctx, ID3D11DeviceContext* context,\n                        ID3D11Texture2D* texture, std::int64_t timestamp_100ns)\n    -> std::expected<std::vector<EncodedFrame>, std::string> {\n  if (!ctx.video_encoder) {\n    return std::unexpected(\"Video encoder not initialized\");\n  }\n\n  std::vector<EncodedFrame> outputs;\n\n  // 1. 创建输入 sample\n  wil::com_ptr<IMFSample> input_sample;\n  HRESULT hr = MFCreateSample(input_sample.put());\n  if (FAILED(hr)) {\n    return std::unexpected(\"Failed to create input sample\");\n  }\n\n  wil::com_ptr<IMFMediaBuffer> input_buffer;\n\n  if (ctx.gpu_encoding && ctx.input_texture && context) {\n    // GPU 路径：复制 BGRA 纹理到输入纹理\n    context->CopyResource(ctx.input_texture.get(), texture);\n\n    // 确定编码器实际接收的纹理（可能需要 NV12 转换）\n    ID3D11Texture2D* encoder_texture = ctx.input_texture.get();\n    DWORD buffer_length = ctx.frame_width * ctx.frame_height * 4;  // BGRA\n\n    if (ctx.needs_nv12_conversion && ctx.nv12_texture) {\n      // BGRA→NV12 转换（硬件加速）\n      hr = convert_bgra_to_nv12(ctx, ctx.input_texture.get());\n      if (FAILED(hr)) {\n        return std::unexpected(\"BGRA->NV12 conversion failed: \" + std::to_string(hr));\n      }\n      encoder_texture = ctx.nv12_texture.get();\n      buffer_length = ctx.frame_width * ctx.frame_height * 3 / 2;  // NV12\n    }\n\n    wil::com_ptr<IDXGISurface> surface;\n    hr = encoder_texture->QueryInterface(IID_PPV_ARGS(surface.put()));\n    if (FAILED(hr)) {\n      return std::unexpected(\"Failed to get DXGI surface\");\n    }\n\n    hr = MFCreateDXGISurfaceBuffer(__uuidof(ID3D11Texture2D), surface.get(), 0, FALSE,\n                                   input_buffer.put());\n    if (FAILED(hr)) {\n      return std::unexpected(\"Failed to create DXGI buffer\");\n    }\n\n    input_buffer->SetCurrentLength(buffer_length);\n  } else {\n    // CPU 路径：从纹理复制到内存\n    if (!ctx.staging_texture) {\n      D3D11_TEXTURE2D_DESC desc;\n      texture->GetDesc(&desc);\n      desc.Usage = D3D11_USAGE_STAGING;\n      desc.BindFlags = 0;\n      desc.CPUAccessFlags = D3D11_CPU_ACCESS_READ;\n      desc.MiscFlags = 0;\n\n      wil::com_ptr<ID3D11Device> device;\n      texture->GetDevice(device.put());\n      hr = device->CreateTexture2D(&desc, nullptr, ctx.staging_texture.put());\n      if (FAILED(hr)) {\n        return std::unexpected(\"Failed to create staging texture\");\n      }\n    }\n\n    context->CopyResource(ctx.staging_texture.get(), texture);\n\n    D3D11_MAPPED_SUBRESOURCE mapped;\n    hr = context->Map(ctx.staging_texture.get(), 0, D3D11_MAP_READ, 0, &mapped);\n    if (FAILED(hr)) {\n      return std::unexpected(\"Failed to map staging texture\");\n    }\n\n    DWORD buffer_size = ctx.frame_width * ctx.frame_height * 4;\n    hr = MFCreateMemoryBuffer(buffer_size, input_buffer.put());\n    if (FAILED(hr)) {\n      context->Unmap(ctx.staging_texture.get(), 0);\n      return std::unexpected(\"Failed to create memory buffer\");\n    }\n\n    BYTE* dest = nullptr;\n    hr = input_buffer->Lock(&dest, nullptr, nullptr);\n    if (SUCCEEDED(hr)) {\n      const BYTE* src = static_cast<const BYTE*>(mapped.pData);\n      UINT row_pitch = ctx.frame_width * 4;\n\n      if (row_pitch == mapped.RowPitch) {\n        std::memcpy(dest, src, buffer_size);\n      } else {\n        for (UINT y = 0; y < ctx.frame_height; y++) {\n          std::memcpy(dest + y * row_pitch, src + y * mapped.RowPitch, row_pitch);\n        }\n      }\n      input_buffer->Unlock();\n      input_buffer->SetCurrentLength(buffer_size);\n    }\n\n    context->Unmap(ctx.staging_texture.get(), 0);\n  }\n\n  input_sample->AddBuffer(input_buffer.get());\n  input_sample->SetSampleTime(timestamp_100ns);\n  input_sample->SetSampleDuration(10'000'000 / ctx.fps);\n\n  // 2. 异步模式：循环等 need_input，途中消费 have_output\n  if (ctx.is_async) {\n    while (true) {\n      auto wait_result = wait_events(ctx);\n      if (!wait_result) return std::unexpected(wait_result.error());\n\n      if (ctx.async_have_output) {\n        auto output = get_transform_output(ctx, false);\n        if (!output) return std::unexpected(output.error());\n        if (output->has_value()) {\n          outputs.push_back(std::move(output->value()));\n        }\n        // 继续等 need_input\n        continue;\n      }\n\n      if (ctx.async_need_input) {\n        break;\n      }\n    }\n  }\n\n  // 3. 送入编码器\n  hr = ctx.video_encoder->ProcessInput(0, input_sample.get(), 0);\n  if (FAILED(hr)) {\n    return std::unexpected(\"ProcessInput failed: \" + std::to_string(hr));\n  }\n  ctx.async_need_input = false;\n\n  // 4. 同步模式：尝试获取输出\n  if (!ctx.is_async) {\n    auto output = get_transform_output(ctx, false);\n    if (!output) return std::unexpected(output.error());\n    if (output->has_value()) {\n      outputs.push_back(std::move(output->value()));\n    }\n  }\n\n  return outputs;\n}\n\nauto encode_audio_frame(RawEncoderContext& ctx, const BYTE* pcm_data, std::uint32_t pcm_size,\n                        std::int64_t timestamp_100ns)\n    -> std::expected<std::optional<EncodedFrame>, std::string> {\n  if (!ctx.audio_encoder || !ctx.has_audio) {\n    return std::nullopt;\n  }\n\n  // 创建输入 sample\n  wil::com_ptr<IMFSample> input_sample;\n  MFCreateSample(input_sample.put());\n\n  wil::com_ptr<IMFMediaBuffer> input_buffer;\n  MFCreateMemoryBuffer(pcm_size, input_buffer.put());\n\n  BYTE* dest = nullptr;\n  input_buffer->Lock(&dest, nullptr, nullptr);\n  std::memcpy(dest, pcm_data, pcm_size);\n  input_buffer->Unlock();\n  input_buffer->SetCurrentLength(pcm_size);\n\n  input_sample->AddBuffer(input_buffer.get());\n  input_sample->SetSampleTime(timestamp_100ns);\n\n  // 送入编码器\n  HRESULT hr = ctx.audio_encoder->ProcessInput(0, input_sample.get(), 0);\n  if (FAILED(hr) && hr != MF_E_NOTACCEPTING) {\n    return std::unexpected(\"Audio ProcessInput failed\");\n  }\n\n  // 尝试获取输出\n  return get_transform_output(ctx, true);\n}\n\nauto flush_encoder(RawEncoderContext& ctx)\n    -> std::expected<std::vector<EncodedFrame>, std::string> {\n  std::vector<EncodedFrame> frames;\n\n  // Drain 视频编码器\n  if (ctx.video_encoder) {\n    ctx.video_encoder->ProcessMessage(MFT_MESSAGE_COMMAND_DRAIN, 0);\n\n    if (ctx.is_async) {\n      // 异步模式：通过事件等待 drain 完成\n      ctx.draining = true;\n\n      while (!ctx.draining_done) {\n        auto wait_result = wait_events(ctx);\n        if (!wait_result) break;\n\n        if (ctx.async_have_output) {\n          auto output = get_transform_output(ctx, false);\n          if (output && output->has_value()) {\n            frames.push_back(std::move(output->value()));\n          }\n        }\n      }\n    } else {\n      // 同步模式：循环取输出直到没有更多\n      while (true) {\n        auto output = get_transform_output(ctx, false);\n        if (!output) break;\n        if (output->has_value()) {\n          frames.push_back(std::move(output->value()));\n        } else {\n          break;\n        }\n      }\n    }\n  }\n\n  // Drain 音频编码器（始终是同步 MFT）\n  if (ctx.audio_encoder && ctx.has_audio) {\n    ctx.audio_encoder->ProcessMessage(MFT_MESSAGE_COMMAND_DRAIN, 0);\n\n    while (true) {\n      auto output = get_transform_output(ctx, true);\n      if (!output) break;\n      if (output->has_value()) {\n        frames.push_back(std::move(output->value()));\n      } else {\n        break;\n      }\n    }\n  }\n\n  return frames;\n}\n\nauto get_video_codec_private_data(const RawEncoderContext& ctx) -> std::vector<std::uint8_t> {\n  std::vector<std::uint8_t> data;\n\n  if (!ctx.video_output_type) {\n    return data;\n  }\n\n  UINT32 blob_size = 0;\n  HRESULT hr = ctx.video_output_type->GetBlobSize(MF_MT_MPEG_SEQUENCE_HEADER, &blob_size);\n  if (FAILED(hr) || blob_size == 0) {\n    return data;\n  }\n\n  data.resize(blob_size);\n  hr = ctx.video_output_type->GetBlob(MF_MT_MPEG_SEQUENCE_HEADER, data.data(), blob_size, nullptr);\n  if (FAILED(hr)) {\n    data.clear();\n  }\n\n  return data;\n}\n\nauto get_video_output_type(const RawEncoderContext& ctx) -> IMFMediaType* {\n  return ctx.video_output_type.get();\n}\n\nauto get_audio_output_type(const RawEncoderContext& ctx) -> IMFMediaType* {\n  return ctx.audio_output_type.get();\n}\n\nauto finalize(RawEncoderContext& ctx) -> void {\n  if (ctx.video_encoder) {\n    ctx.video_encoder->ProcessMessage(MFT_MESSAGE_NOTIFY_END_OF_STREAM, 0);\n    ctx.video_encoder->ProcessMessage(MFT_MESSAGE_NOTIFY_END_STREAMING, 0);\n  }\n\n  if (ctx.audio_encoder) {\n    ctx.audio_encoder->ProcessMessage(MFT_MESSAGE_NOTIFY_END_OF_STREAM, 0);\n    ctx.audio_encoder->ProcessMessage(MFT_MESSAGE_NOTIFY_END_STREAMING, 0);\n  }\n\n  ctx.async_events = nullptr;\n  ctx.is_async = false;\n  ctx.async_need_input = false;\n  ctx.async_have_output = false;\n  ctx.draining = false;\n  ctx.draining_done = false;\n  ctx.video_encoder = nullptr;\n  ctx.audio_encoder = nullptr;\n  ctx.dxgi_manager = nullptr;\n  ctx.video_output_type = nullptr;\n  ctx.audio_output_type = nullptr;\n  ctx.input_texture = nullptr;\n  ctx.staging_texture = nullptr;\n  ctx.nv12_texture = nullptr;\n  ctx.video_processor = nullptr;\n  ctx.vp_enum = nullptr;\n  ctx.video_context = nullptr;\n  ctx.video_device = nullptr;\n  ctx.needs_nv12_conversion = false;\n}\n\n}  // namespace Utils::Media::RawEncoder\n"
  },
  {
    "path": "src/utils/media/raw_encoder.ixx",
    "content": "module;\n\n#include <mfidl.h>\n#include <mfobjects.h>\n\nexport module Utils.Media.RawEncoder;\n\nimport std;\nimport <audioclient.h>;\nimport <d3d11.h>;\nimport <mfapi.h>;\nimport <wil/com.h>;\n\nexport namespace Utils::Media::RawEncoder {\n\n// 编码后的压缩帧\nstruct EncodedFrame {\n  std::vector<std::uint8_t> data;  // 压缩数据\n  std::int64_t timestamp_100ns;    // 时间戳（100ns 单位）\n  std::int64_t duration_100ns;     // 持续时长（100ns 单位）\n  bool is_keyframe;                // 是否为关键帧（仅视频）\n  bool is_audio;                   // 是否为音频帧\n};\n\n// 编码器配置\nstruct RawEncoderConfig {\n  std::uint32_t width = 0;\n  std::uint32_t height = 0;\n  std::uint32_t fps = 30;\n  std::uint32_t bitrate = 20'000'000;\n  std::uint32_t keyframe_interval = 1;  // 关键帧间隔（秒）\n  bool use_hardware = true;             // 是否使用硬件编码\n};\n\n// 编码器上下文\nstruct RawEncoderContext {\n  // 视频编码器 Transform\n  wil::com_ptr<IMFTransform> video_encoder;\n  wil::com_ptr<IMFDXGIDeviceManager> dxgi_manager;\n  UINT dxgi_reset_token = 0;\n\n  // 输出媒体类型（包含 SPS/PPS）\n  wil::com_ptr<IMFMediaType> video_output_type;\n\n  // 音频编码器 Transform\n  wil::com_ptr<IMFTransform> audio_encoder;\n  wil::com_ptr<IMFMediaType> audio_output_type;\n  bool has_audio = false;\n\n  // 缓存信息\n  std::uint32_t frame_width = 0;\n  std::uint32_t frame_height = 0;\n  std::uint32_t fps = 30;\n  bool gpu_encoding = false;\n\n  // GPU 编码用的输入纹理（BGRA 或 NV12 取决于编码器支持）\n  wil::com_ptr<ID3D11Texture2D> input_texture;\n\n  // CPU 编码用的 staging 纹理\n  wil::com_ptr<ID3D11Texture2D> staging_texture;\n\n  // 异步 MFT 支持\n  wil::com_ptr<IMFMediaEventGenerator> async_events;  // 事件生成器\n  bool is_async = false;                              // 是否为异步 MFT\n  bool async_need_input = false;                      // 可以接受输入\n  bool async_have_output = false;                     // 有输出可用\n  bool draining = false;                              // 正在排空\n  bool draining_done = false;                         // 排空完成\n\n  // BGRA→NV12 颜色空间转换（硬件编码器通常只接受 NV12）\n  bool needs_nv12_conversion = false;\n  wil::com_ptr<ID3D11Texture2D> nv12_texture;\n  wil::com_ptr<ID3D11VideoDevice> video_device;\n  wil::com_ptr<ID3D11VideoContext> video_context;\n  wil::com_ptr<ID3D11VideoProcessorEnumerator> vp_enum;\n  wil::com_ptr<ID3D11VideoProcessor> video_processor;\n};\n\n// 创建编码器（不创建文件，只编码）\nauto create_encoder(const RawEncoderConfig& config, ID3D11Device* device,\n                    WAVEFORMATEX* wave_format = nullptr)\n    -> std::expected<RawEncoderContext, std::string>;\n\n// 编码视频帧，返回压缩数据（异步模式下可能返回 0 或多帧）\nauto encode_video_frame(RawEncoderContext& ctx, ID3D11DeviceContext* context,\n                        ID3D11Texture2D* texture, std::int64_t timestamp_100ns)\n    -> std::expected<std::vector<EncodedFrame>, std::string>;\n\n// 编码音频，返回压缩数据\nauto encode_audio_frame(RawEncoderContext& ctx, const BYTE* pcm_data, std::uint32_t pcm_size,\n                        std::int64_t timestamp_100ns)\n    -> std::expected<std::optional<EncodedFrame>, std::string>;\n\n// 刷新编码器（获取所有剩余输出）\nauto flush_encoder(RawEncoderContext& ctx) -> std::expected<std::vector<EncodedFrame>, std::string>;\n\n// 获取视频 codec private data（SPS/PPS，用于 mux）\nauto get_video_codec_private_data(const RawEncoderContext& ctx) -> std::vector<std::uint8_t>;\n\n// 获取视频输出媒体类型（用于创建 SinkWriter）\nauto get_video_output_type(const RawEncoderContext& ctx) -> IMFMediaType*;\n\n// 获取音频输出媒体类型（用于创建 SinkWriter）\nauto get_audio_output_type(const RawEncoderContext& ctx) -> IMFMediaType*;\n\n// 清理编码器\nauto finalize(RawEncoderContext& ctx) -> void;\n\n}  // namespace Utils::Media::RawEncoder\n"
  },
  {
    "path": "src/utils/media/state.ixx",
    "content": "module;\n\n#include <mfidl.h>\n#include <mfreadwrite.h>\n\nexport module Utils.Media.Encoder.State;\n\nimport std;\nimport <d3d11.h>;\nimport <wil/com.h>;\n\nexport namespace Utils::Media::Encoder::State {\n\n// 编码器上下文\nstruct EncoderContext {\n  wil::com_ptr<IMFSinkWriter> sink_writer;\n  DWORD video_stream_index = 0;\n\n  // 缓存的尺寸信息\n  uint32_t frame_width = 0;\n  uint32_t frame_height = 0;\n  DWORD buffer_size = 0;  // width * height * 4\n\n  // CPU 编码模式\n  wil::com_ptr<ID3D11Texture2D> staging_texture;  // CPU 可读的暂存纹理\n  wil::com_ptr<IMFSample> reusable_sample;        // 复用的 Sample\n  wil::com_ptr<IMFMediaBuffer> reusable_buffer;   // 复用的 Buffer\n\n  // GPU 编码模式\n  wil::com_ptr<IMFDXGIDeviceManager> dxgi_manager;\n  UINT reset_token = 0;\n  wil::com_ptr<ID3D11Texture2D> shared_texture;  // 编码器专用纹理\n  bool gpu_encoding = false;\n\n  // 音频流\n  DWORD audio_stream_index = 0;  // 音频流索引\n  bool has_audio = false;        // 是否有音频流\n\n  // 注：线程同步由调用方管理，因为 std::mutex 不可移动，无法放在 std::expected 返回值中\n};\n\n}  // namespace Utils::Media::Encoder::State\n"
  },
  {
    "path": "src/utils/media/types.ixx",
    "content": "module;\n\nexport module Utils.Media.Encoder.Types;\n\nimport std;\n\nnamespace Utils::Media::Encoder::Types {\n\n// 码率控制模式\nexport enum class RateControlMode {\n  CBR,      // 固定码率 - 使用 bitrate\n  VBR,      // 质量优先 VBR - 使用 quality (0-100)\n  ManualQP  // 手动 QP 模式 - 使用 qp (0-51)\n};\n\n// 从字符串转换为 RateControlMode\nexport constexpr RateControlMode rate_control_mode_from_string(std::string_view str) {\n  if (str == \"vbr\") return RateControlMode::VBR;\n  if (str == \"manual_qp\") return RateControlMode::ManualQP;\n  return RateControlMode::CBR;  // 默认\n}\n\n// 编码器模式\nexport enum class EncoderMode {\n  Auto,  // 自动检测，优先 GPU\n  GPU,   // 强制 GPU（不可用则失败）\n  CPU    // 强制 CPU\n};\n\n// 从字符串转换为 EncoderMode\nexport constexpr EncoderMode encoder_mode_from_string(std::string_view str) {\n  if (str == \"gpu\") return EncoderMode::GPU;\n  if (str == \"cpu\") return EncoderMode::CPU;\n  return EncoderMode::Auto;  // 默认或 \"auto\"\n}\n\n// 视频编码格式\nexport enum class VideoCodec {\n  H264,  // H.264/AVC\n  H265   // H.265/HEVC\n};\n\n// 从字符串转换为 VideoCodec\nexport constexpr VideoCodec video_codec_from_string(std::string_view str) {\n  if (str == \"h265\" || str == \"hevc\") return VideoCodec::H265;\n  return VideoCodec::H264;  // 默认\n}\n\n// 编码器配置\nexport struct EncoderConfig {\n  std::filesystem::path output_path;                    // 输出文件路径\n  std::uint32_t width = 0;                              // 视频宽度\n  std::uint32_t height = 0;                             // 视频高度\n  std::uint32_t fps = 30;                               // 帧率\n  std::uint32_t bitrate = 80'000'000;                   // 视频比特率 (默认 80Mbps, CBR 模式使用)\n  std::uint32_t quality = 70;                           // 质量值 (0-100, VBR 模式使用)\n  std::uint32_t qp = 23;                                // 量化参数 (0-51, ManualQP 模式使用)\n  std::uint32_t keyframe_interval = 2;                  // 关键帧间隔（秒），默认 2s\n  RateControlMode rate_control = RateControlMode::CBR;  // 码率控制模式\n  EncoderMode encoder_mode = EncoderMode::Auto;         // 编码器模式\n  VideoCodec codec = VideoCodec::H264;                  // 视频编码格式 (默认 H.264)\n\n  // 音频配置\n  std::uint32_t audio_bitrate = 256'000;  // 音频码率 (默认 256kbps)\n};\n\n}  // namespace Utils::Media::Encoder::Types\n"
  },
  {
    "path": "src/utils/media/video_asset.cpp",
    "content": "module;\n\n#include <mfapi.h>\n#include <mferror.h>\n#include <mfidl.h>\n#include <mfobjects.h>\n#include <mfreadwrite.h>\n#include <propvarutil.h>\n#include <wil/com.h>\n\nmodule Utils.Media.VideoAsset;\n\nimport std;\nimport Utils.File.Mime;\nimport Utils.Image;\nimport Utils.Logger;\n\nnamespace Utils::Media::VideoAsset {\n\n// MF 时长为 100ns 单位；封面时间点取「约 10%\n// 时长」并夹在下面两常量之间，减少片头黑场又避免拖到过久才解码。\nconstexpr std::int64_t kHundredNanosecondsPerMillisecond = 10'000;\nconstexpr std::int64_t kMinThumbnailTimestampHns = 2'000'000;\nconstexpr std::int64_t kMaxThumbnailTimestampHns = 30'000'000;\nconstexpr std::uint32_t kBgraBytesPerPixel = 4;\n\n// Media Foundation 返回的视频帧并不总是“画面宽高 = 内存宽高”。\n// 这里把“解码 surface 的布局”和“真正可见的画面区域”拆开保存，后续就只按这个结构取像素。\nstruct VideoFrameLayout {\n  std::uint32_t surface_width = 0;\n  std::uint32_t surface_height = 0;\n  std::int32_t stride = 0;\n  std::uint32_t visible_x = 0;\n  std::uint32_t visible_y = 0;\n  std::uint32_t visible_width = 0;\n  std::uint32_t visible_height = 0;\n};\n\nauto format_hresult(HRESULT hr, const std::string& context) -> std::string {\n  char* message_buffer = nullptr;\n  auto size = FormatMessageA(\n      FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS,\n      nullptr, static_cast<DWORD>(hr), MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT),\n      reinterpret_cast<LPSTR>(&message_buffer), 0, nullptr);\n\n  std::string message;\n  if (size > 0 && message_buffer) {\n    message = std::format(\"{} (HRESULT: 0x{:08X}): {}\", context, static_cast<unsigned int>(hr),\n                          message_buffer);\n    LocalFree(message_buffer);\n  } else {\n    message = std::format(\"{} (HRESULT: 0x{:08X})\", context, static_cast<unsigned int>(hr));\n  }\n\n  return message;\n}\n\nauto get_propvariant_int64(const PROPVARIANT& value) -> std::optional<std::int64_t> {\n  if (value.vt == VT_I8) {\n    return value.hVal.QuadPart;\n  }\n\n  if (value.vt == VT_UI8) {\n    return static_cast<std::int64_t>(value.uhVal.QuadPart);\n  }\n\n  return std::nullopt;\n}\n\nauto get_video_frame_size(IMFSourceReader* reader)\n    -> std::expected<std::pair<std::uint32_t, std::uint32_t>, std::string> {\n  wil::com_ptr<IMFMediaType> media_type;\n  HRESULT hr = reader->GetCurrentMediaType(MF_SOURCE_READER_FIRST_VIDEO_STREAM, media_type.put());\n  // 尚未 SetCurrentMediaType 时 Current 可能失败，回退到 native 类型取 MF_MT_FRAME_SIZE。\n  if (FAILED(hr) || !media_type) {\n    hr = reader->GetNativeMediaType(MF_SOURCE_READER_FIRST_VIDEO_STREAM, 0, media_type.put());\n  }\n\n  if (FAILED(hr) || !media_type) {\n    return std::unexpected(format_hresult(hr, \"Failed to get video media type\"));\n  }\n\n  UINT32 width = 0;\n  UINT32 height = 0;\n  hr = MFGetAttributeSize(media_type.get(), MF_MT_FRAME_SIZE, &width, &height);\n  if (FAILED(hr) || width == 0 || height == 0) {\n    return std::unexpected(format_hresult(hr, \"Failed to get video frame size\"));\n  }\n\n  return std::make_pair(static_cast<std::uint32_t>(width), static_cast<std::uint32_t>(height));\n}\n\nauto try_get_media_type_frame_size(IMFMediaType* media_type)\n    -> std::optional<std::pair<std::uint32_t, std::uint32_t>> {\n  if (!media_type) {\n    return std::nullopt;\n  }\n\n  UINT32 width = 0;\n  UINT32 height = 0;\n  auto hr = MFGetAttributeSize(media_type, MF_MT_FRAME_SIZE, &width, &height);\n  if (FAILED(hr) || width == 0 || height == 0) {\n    return std::nullopt;\n  }\n\n  return std::make_pair(static_cast<std::uint32_t>(width), static_cast<std::uint32_t>(height));\n}\n\nauto try_get_media_type_default_stride(IMFMediaType* media_type) -> std::optional<std::int32_t> {\n  if (!media_type) {\n    return std::nullopt;\n  }\n\n  UINT32 stride = 0;\n  auto hr = media_type->GetUINT32(MF_MT_DEFAULT_STRIDE, &stride);\n  if (FAILED(hr)) {\n    return std::nullopt;\n  }\n\n  return static_cast<std::int32_t>(stride);\n}\n\nauto try_get_media_type_video_area(IMFMediaType* media_type, REFGUID key)\n    -> std::optional<MFVideoArea> {\n  if (!media_type) {\n    return std::nullopt;\n  }\n\n  UINT32 blob_size = 0;\n  auto hr = media_type->GetBlobSize(key, &blob_size);\n  if (FAILED(hr) || blob_size != sizeof(MFVideoArea)) {\n    return std::nullopt;\n  }\n\n  MFVideoArea area{};\n  hr = media_type->GetBlob(key, reinterpret_cast<UINT8*>(&area), sizeof(area), nullptr);\n  if (FAILED(hr)) {\n    return std::nullopt;\n  }\n\n  return area;\n}\n\nauto resolve_video_area_offset(const MFOffset& offset, const char* axis_name)\n    -> std::expected<std::uint32_t, std::string> {\n  if (offset.fract != 0 || offset.value < 0) {\n    return std::unexpected(std::format(\"Unsupported non-integer {} aperture offset\", axis_name));\n  }\n\n  return static_cast<std::uint32_t>(offset.value);\n}\n\nauto resolve_video_frame_layout(IMFMediaType* media_type)\n    -> std::expected<VideoFrameLayout, std::string> {\n  if (!media_type) {\n    return std::unexpected(\"Video media type is null\");\n  }\n\n  auto frame_size = try_get_media_type_frame_size(media_type);\n  if (!frame_size) {\n    return std::unexpected(\"Video media type does not expose MF_MT_FRAME_SIZE\");\n  }\n\n  auto default_stride = try_get_media_type_default_stride(media_type);\n  if (!default_stride.has_value()) {\n    return std::unexpected(\"Video media type does not expose MF_MT_DEFAULT_STRIDE\");\n  }\n\n  if (default_stride.value() <= 0) {\n    return std::unexpected(\"Video media type exposes a non-positive default stride\");\n  }\n\n  // 这里要区分两套尺寸：\n  // 1. surface_*：解码器实际输出的内存表面大小，往往会按块对齐。\n  // 2. visible_*：真正应该拿来做缩略图的可见区域。\n  // 某些视频会出现「surface 比画面大一点」的情况；如果直接按 visible 宽高线性读整块\n  // buffer，就会把每行末尾的填充像素误当成下一行开头，从而出现撕裂。\n  VideoFrameLayout layout{\n      .surface_width = frame_size->first,\n      .surface_height = frame_size->second,\n      .stride = default_stride.value(),\n      .visible_x = 0,\n      .visible_y = 0,\n      .visible_width = frame_size->first,\n      .visible_height = frame_size->second,\n  };\n\n  auto minimum_display_aperture =\n      try_get_media_type_video_area(media_type, MF_MT_MINIMUM_DISPLAY_APERTURE);\n  if (!minimum_display_aperture.has_value()) {\n    return layout;\n  }\n\n  auto visible_x_result =\n      resolve_video_area_offset(minimum_display_aperture->OffsetX, \"horizontal\");\n  if (!visible_x_result) {\n    return std::unexpected(visible_x_result.error());\n  }\n\n  auto visible_y_result = resolve_video_area_offset(minimum_display_aperture->OffsetY, \"vertical\");\n  if (!visible_y_result) {\n    return std::unexpected(visible_y_result.error());\n  }\n\n  layout.visible_x = visible_x_result.value();\n  layout.visible_y = visible_y_result.value();\n  layout.visible_width = minimum_display_aperture->Area.cx;\n  layout.visible_height = minimum_display_aperture->Area.cy;\n\n  if (layout.visible_width == 0 || layout.visible_height == 0) {\n    return std::unexpected(\"Video media type exposes an empty minimum display aperture\");\n  }\n\n  if (layout.visible_x + layout.visible_width > layout.surface_width ||\n      layout.visible_y + layout.visible_height > layout.surface_height) {\n    return std::unexpected(\"Video minimum display aperture exceeds decoded surface bounds\");\n  }\n\n  return layout;\n}\n\nauto copy_bitmap_data_from_linear_buffer(const BYTE* buffer_start, std::uint32_t buffer_length,\n                                         const VideoFrameLayout& layout)\n    -> std::expected<Utils::Image::BGRABitmapData, std::string> {\n  if (!buffer_start || buffer_length == 0) {\n    return std::unexpected(\"Video buffer is empty\");\n  }\n\n  if (layout.stride <= 0) {\n    return std::unexpected(\"Video buffer stride must be positive\");\n  }\n\n  auto stride = static_cast<std::uint64_t>(layout.stride);\n  auto visible_row_bytes = static_cast<std::uint64_t>(layout.visible_width) * kBgraBytesPerPixel;\n  auto row_offset = static_cast<std::uint64_t>(layout.visible_x) * kBgraBytesPerPixel;\n  if (row_offset + visible_row_bytes > stride) {\n    return std::unexpected(\"Video aperture row exceeds decoded surface stride\");\n  }\n\n  auto last_row_offset =\n      static_cast<std::uint64_t>(layout.visible_y + layout.visible_height - 1) * stride;\n  auto required_bytes = last_row_offset + row_offset + visible_row_bytes;\n  if (required_bytes > buffer_length) {\n    return std::unexpected(\"Video buffer is smaller than the decoded surface layout requires\");\n  }\n\n  auto pixel_bytes = visible_row_bytes * layout.visible_height;\n  Utils::Image::BGRABitmapData result;\n  result.width = layout.visible_width;\n  result.height = layout.visible_height;\n  result.stride = static_cast<std::uint32_t>(visible_row_bytes);\n  result.pixels.resize(static_cast<std::size_t>(pixel_bytes));\n\n  // 这里只拷贝可见区域；surface 右侧/底部的对齐填充会被自然跳过。\n  for (std::uint32_t y = 0; y < layout.visible_height; ++y) {\n    auto source_offset = (static_cast<std::uint64_t>(layout.visible_y + y) * stride) + row_offset;\n    std::memcpy(result.pixels.data() + static_cast<std::size_t>(y) * result.stride,\n                buffer_start + source_offset, result.stride);\n  }\n\n  for (std::size_t i = 3; i < result.pixels.size(); i += kBgraBytesPerPixel) {\n    result.pixels[i] = 255;\n  }\n\n  return result;\n}\n\nauto create_source_reader(const std::filesystem::path& path)\n    -> std::expected<wil::com_ptr<IMFSourceReader>, std::string> {\n  wil::com_ptr<IMFAttributes> attributes;\n  HRESULT hr = MFCreateAttributes(attributes.put(), 2);\n  if (FAILED(hr)) {\n    return std::unexpected(format_hresult(hr, \"Failed to create source reader attributes\"));\n  }\n\n  // 允许解码器做色彩空间/尺寸处理，便于统一输出 RGB32 供 WIC 走缩略图管线。\n  attributes->SetUINT32(MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING, TRUE);\n  attributes->SetUINT32(MF_READWRITE_DISABLE_CONVERTERS, FALSE);\n\n  wil::com_ptr<IMFSourceReader> reader;\n  hr = MFCreateSourceReaderFromURL(path.c_str(), attributes.get(), reader.put());\n  if (FAILED(hr) || !reader) {\n    return std::unexpected(format_hresult(hr, \"Failed to create source reader\"));\n  }\n\n  return reader;\n}\n\nauto configure_rgb32_output(IMFSourceReader* reader) -> std::expected<void, std::string> {\n  wil::com_ptr<IMFMediaType> output_type;\n  HRESULT hr = MFCreateMediaType(output_type.put());\n  if (FAILED(hr)) {\n    return std::unexpected(format_hresult(hr, \"Failed to create RGB32 media type\"));\n  }\n\n  output_type->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Video);\n  output_type->SetGUID(MF_MT_SUBTYPE, MFVideoFormat_RGB32);\n\n  hr = reader->SetCurrentMediaType(MF_SOURCE_READER_FIRST_VIDEO_STREAM, nullptr, output_type.get());\n  if (FAILED(hr)) {\n    return std::unexpected(format_hresult(hr, \"Failed to configure RGB32 video output\"));\n  }\n\n  hr = reader->SetStreamSelection(MF_SOURCE_READER_FIRST_VIDEO_STREAM, TRUE);\n  if (FAILED(hr)) {\n    return std::unexpected(format_hresult(hr, \"Failed to select video stream\"));\n  }\n\n  return {};\n}\n\nauto calculate_thumbnail_timestamp_hns(std::optional<std::int64_t> duration_millis)\n    -> std::int64_t {\n  if (!duration_millis.has_value() || duration_millis.value() <= 0) {\n    return 0;\n  }\n\n  auto duration_hns = duration_millis.value() * kHundredNanosecondsPerMillisecond;\n  auto target = duration_hns / 10;\n  // 上限不超过「最后一帧之前」，避免 seek 到 EOF 导致读不到样本。\n  return std::clamp(\n      target, kMinThumbnailTimestampHns,\n      std::max(duration_hns - kHundredNanosecondsPerMillisecond, kMinThumbnailTimestampHns));\n}\n\nauto seek_source_reader(IMFSourceReader* reader, std::int64_t position_hns)\n    -> std::expected<void, std::string> {\n  PROPVARIANT position;\n  PropVariantInit(&position);\n\n  auto init_hr = InitPropVariantFromInt64(position_hns, &position);\n  if (FAILED(init_hr)) {\n    PropVariantClear(&position);\n    return std::unexpected(format_hresult(init_hr, \"Failed to build seek position\"));\n  }\n\n  HRESULT hr = reader->SetCurrentPosition(GUID_NULL, position);\n  PropVariantClear(&position);\n  if (FAILED(hr)) {\n    return std::unexpected(format_hresult(hr, \"Failed to seek source reader\"));\n  }\n\n  return {};\n}\n\nauto is_transitional_thumbnail_sample(DWORD stream_flags) -> bool {\n  return (stream_flags & MF_SOURCE_READERF_CURRENTMEDIATYPECHANGED) != 0;\n}\n\nauto is_stable_thumbnail_timestamp(LONGLONG timestamp_hns, std::int64_t minimum_timestamp_hns)\n    -> bool {\n  if (minimum_timestamp_hns <= 0) {\n    return timestamp_hns > 0;\n  }\n\n  return timestamp_hns > 0 && timestamp_hns >= minimum_timestamp_hns;\n}\n\nauto read_current_video_frame_layout(IMFSourceReader* reader)\n    -> std::expected<VideoFrameLayout, std::string> {\n  wil::com_ptr<IMFMediaType> media_type;\n  auto hr = reader->GetCurrentMediaType(MF_SOURCE_READER_FIRST_VIDEO_STREAM, media_type.put());\n  if (FAILED(hr) || !media_type) {\n    return std::unexpected(format_hresult(hr, \"Failed to get current video media type\"));\n  }\n\n  return resolve_video_frame_layout(media_type.get());\n}\n\nauto read_thumbnail_bitmap_data(IMFSourceReader* reader, std::int64_t minimum_timestamp_hns)\n    -> std::expected<Utils::Image::BGRABitmapData, std::string> {\n  constexpr DWORD kReadFlags = 0;\n  // Seek 后 reader 可能先吐出媒体类型切换帧、空样本或目标时间点之前的旧帧；\n  // 这里持续读到一张稳定帧再做缩略图。\n  constexpr int kMaxReadAttempts = 120;\n\n  for (int attempt = 0; attempt < kMaxReadAttempts; ++attempt) {\n    DWORD actual_stream_index = 0;\n    DWORD stream_flags = 0;\n    LONGLONG timestamp = 0;\n    wil::com_ptr<IMFSample> sample;\n\n    HRESULT hr = reader->ReadSample(MF_SOURCE_READER_FIRST_VIDEO_STREAM, kReadFlags,\n                                    &actual_stream_index, &stream_flags, &timestamp, sample.put());\n    if (FAILED(hr)) {\n      return std::unexpected(format_hresult(hr, \"Failed to read video sample\"));\n    }\n\n    if ((stream_flags & MF_SOURCE_READERF_ENDOFSTREAM) != 0) {\n      break;\n    }\n\n    if (!sample) {\n      continue;\n    }\n\n    // Step 1: 跳过管线刚切换时的过渡帧。\n    if (is_transitional_thumbnail_sample(stream_flags)) {\n      continue;\n    }\n\n    // Step 2: 只接受时间戳已经稳定、且不早于目标时间点的帧。\n    if (!is_stable_thumbnail_timestamp(timestamp, minimum_timestamp_hns)) {\n      continue;\n    }\n\n    // Step 3: 读取当前帧对应的 surface 布局与可见区域。\n    auto layout_result = read_current_video_frame_layout(reader);\n    if (!layout_result) {\n      Logger().warn(\"Video thumbnail decode failed. attempt={}, error={}\", attempt + 1,\n                    layout_result.error());\n      return std::unexpected(layout_result.error());\n    }\n    const auto& layout = layout_result.value();\n\n    // Step 4: 锁定 sample 的线性内存。\n    wil::com_ptr<IMFMediaBuffer> media_buffer;\n    hr = sample->GetBufferByIndex(0, media_buffer.put());\n    if (FAILED(hr) || !media_buffer) {\n      auto error = format_hresult(hr, \"Failed to get video buffer\");\n      Logger().warn(\"Video thumbnail decode failed. attempt={}, error={}\", attempt + 1, error);\n      return std::unexpected(error);\n    }\n\n    DWORD current_length = 0;\n    auto current_length_hr = media_buffer->GetCurrentLength(&current_length);\n\n    BYTE* buffer_start = nullptr;\n    hr = media_buffer->Lock(&buffer_start, nullptr, &current_length);\n    if (FAILED(hr)) {\n      auto error = format_hresult(hr, \"Failed to lock video buffer\");\n      Logger().warn(\"Video thumbnail decode failed. attempt={}, error={}\", attempt + 1, error);\n      return std::unexpected(error);\n    }\n\n    auto unlock_guard = std::unique_ptr<void, std::function<void(void*)>>(\n        media_buffer.get(), [&media_buffer](void*) { media_buffer->Unlock(); });\n\n    if (!buffer_start || current_length == 0) {\n      continue;\n    }\n\n    // Step 5: 按 stride 逐行拷贝，并裁出真正可见的画面区域。\n    auto copy_result = copy_bitmap_data_from_linear_buffer(buffer_start, current_length, layout);\n    if (!copy_result) {\n      Logger().warn(\"Video thumbnail decode failed. attempt={}, error={}\", attempt + 1,\n                    copy_result.error());\n      return std::unexpected(copy_result.error());\n    }\n\n    return copy_result;\n  }\n\n  return std::unexpected(\"Failed to decode a video frame for thumbnail generation\");\n}\n\nauto analyze_video_file(const std::filesystem::path& path,\n                        std::optional<std::uint32_t> thumbnail_short_edge)\n    -> std::expected<VideoAnalysis, std::string> {\n  if (!std::filesystem::exists(path)) {\n    return std::unexpected(\"Video file does not exist: \" + path.string());\n  }\n\n  // 分析可能在 worker 线程调用；与已以别种模式初始化的 COM 线程共存时返回\n  // RPC_E_CHANGED_MODE，此时不得 CoUninitialize。\n  HRESULT coinit_hr = CoInitializeEx(nullptr, COINIT_MULTITHREADED);\n  bool need_uninitialize = SUCCEEDED(coinit_hr);\n  if (FAILED(coinit_hr) && coinit_hr != RPC_E_CHANGED_MODE) {\n    return std::unexpected(format_hresult(coinit_hr, \"Failed to initialize COM\"));\n  }\n\n  auto uninitialize_guard =\n      std::unique_ptr<void, std::function<void(void*)>>(nullptr, [need_uninitialize](void*) {\n        if (need_uninitialize) {\n          CoUninitialize();\n        }\n      });\n\n  auto reader_result = create_source_reader(path);\n  if (!reader_result) {\n    return std::unexpected(reader_result.error());\n  }\n  auto reader = std::move(reader_result.value());\n\n  auto frame_size_result = get_video_frame_size(reader.get());\n  if (!frame_size_result) {\n    return std::unexpected(frame_size_result.error());\n  }\n  auto [width, height] = frame_size_result.value();\n\n  std::optional<std::int64_t> duration_millis;\n  PROPVARIANT duration_value;\n  PropVariantInit(&duration_value);\n  if (SUCCEEDED(reader->GetPresentationAttribute(MF_SOURCE_READER_MEDIASOURCE, MF_PD_DURATION,\n                                                 &duration_value))) {\n    if (auto duration_hns = get_propvariant_int64(duration_value); duration_hns.has_value()) {\n      duration_millis = duration_hns.value() / kHundredNanosecondsPerMillisecond;\n    }\n  }\n  PropVariantClear(&duration_value);\n\n  VideoAnalysis result{\n      .width = width,\n      .height = height,\n      .mime_type = Utils::File::Mime::get_mime_type(path),\n      .duration_millis = duration_millis,\n      .thumbnail = std::nullopt,\n  };\n\n  // 不传 short_edge 时只做元数据，避免扫描「仅索引、不生成缩略图」场景下的解码开销。\n  if (!thumbnail_short_edge.has_value()) {\n    return result;\n  }\n\n  // Step 1: 要求 reader 输出 RGB32，后续就能直接交给 WIC 生成缩略图。\n  auto output_result = configure_rgb32_output(reader.get());\n  if (!output_result) {\n    return std::unexpected(output_result.error());\n  }\n\n  auto seek_hns = calculate_thumbnail_timestamp_hns(duration_millis);\n\n  // Step 2: 先 seek 到目标时间点附近，再往后读到一张稳定帧。\n  auto seek_result = seek_source_reader(reader.get(), seek_hns);\n  if (!seek_result) {\n    Logger().warn(\"Video analysis failed while seeking thumbnail frame. path='{}', error={}\",\n                  path.string(), seek_result.error());\n    return std::unexpected(seek_result.error());\n  }\n\n  auto bitmap_result = read_thumbnail_bitmap_data(reader.get(), seek_hns);\n  if (!bitmap_result) {\n    Logger().warn(\"Video analysis failed while decoding thumbnail bitmap. path='{}', error={}\",\n                  path.string(), bitmap_result.error());\n    return std::unexpected(bitmap_result.error());\n  }\n\n  auto wic_factory_result = Utils::Image::get_thread_wic_factory();\n  if (!wic_factory_result) {\n    auto error =\n        \"Failed to initialize WIC factory for video thumbnail: \" + wic_factory_result.error();\n    Logger().warn(\"Video analysis failed while creating WIC factory. path='{}', error={}\",\n                  path.string(), error);\n    return std::unexpected(error);\n  }\n\n  Utils::Image::WebPEncodeOptions webp_options;\n  webp_options.quality = 90.0f;\n\n  // Step 3: 把 BGRA 位图缩放并编码成 WebP，供图库缩略图直接使用。\n  auto thumbnail_result = Utils::Image::generate_webp_thumbnail_from_bgra(\n      wic_factory_result->get(), bitmap_result.value(), thumbnail_short_edge.value(), webp_options);\n  if (!thumbnail_result) {\n    auto error = \"Failed to encode video thumbnail: \" + thumbnail_result.error();\n    Logger().warn(\"Video analysis failed while encoding thumbnail. path='{}', error={}\",\n                  path.string(), error);\n    return std::unexpected(error);\n  }\n\n  result.thumbnail = std::move(thumbnail_result.value());\n  return result;\n}\n\n}  // namespace Utils::Media::VideoAsset\n"
  },
  {
    "path": "src/utils/media/video_asset.ixx",
    "content": "export module Utils.Media.VideoAsset;\n\nimport std;\nimport Utils.Image;\n\nexport namespace Utils::Media::VideoAsset {\n\n// 单次扫描/监听内对单个视频文件的解析结果；thumbnail 仅在传入 short_edge 时填充。\nstruct VideoAnalysis {\n  std::uint32_t width = 0;\n  std::uint32_t height = 0;\n  std::string mime_type;\n  std::optional<std::int64_t> duration_millis;\n  std::optional<Utils::Image::WebPEncodedResult> thumbnail;\n};\n\n// 依赖进程内已 MFStartup；thumbnail_short_edge 为 nullopt 时跳过解码，仅填元数据。\nexport auto analyze_video_file(const std::filesystem::path& path,\n                               std::optional<std::uint32_t> thumbnail_short_edge = std::nullopt)\n    -> std::expected<VideoAnalysis, std::string>;\n\n}  // namespace Utils::Media::VideoAsset\n"
  },
  {
    "path": "src/utils/media/video_scaler.cpp",
    "content": "module;\n\n#include <codecapi.h>\n#include <d3d11.h>\n#include <mferror.h>\n#include <mfidl.h>\n#include <propvarutil.h>\n\nmodule Utils.Media.VideoScaler;\n\nimport std;\nimport Utils.Logger;\nimport <mfapi.h>;\nimport <wil/com.h>;\n\nnamespace Utils::Media::VideoScaler {\n\n// 计算缩放后的分辨率\nauto calculate_scaled_dimensions(std::uint32_t src_width, std::uint32_t src_height,\n                                 const ScaleConfig& config)\n    -> std::pair<std::uint32_t, std::uint32_t> {\n  // 如果指定了短边目标\n  if (config.target_short_edge > 0) {\n    bool width_is_shorter = src_width <= src_height;\n    std::uint32_t short_edge = width_is_shorter ? src_width : src_height;\n    std::uint32_t long_edge = width_is_shorter ? src_height : src_width;\n\n    double scale = static_cast<double>(config.target_short_edge) / short_edge;\n    std::uint32_t new_short = config.target_short_edge;\n    std::uint32_t new_long = static_cast<std::uint32_t>(long_edge * scale);\n\n    // 确保偶数（视频编码要求）\n    new_short = (new_short / 2) * 2;\n    new_long = (new_long / 2) * 2;\n\n    if (width_is_shorter) {\n      return {new_short, new_long};\n    } else {\n      return {new_long, new_short};\n    }\n  }\n\n  // 如果指定了具体宽高\n  if (config.target_width > 0 && config.target_height > 0) {\n    return {(config.target_width / 2) * 2, (config.target_height / 2) * 2};\n  }\n\n  // 默认不缩放\n  return {src_width, src_height};\n}\n\n// 从 MediaSource 获取视频分辨率\nauto get_source_video_dimensions(IMFMediaSource* source)\n    -> std::expected<std::pair<std::uint32_t, std::uint32_t>, std::string> {\n  wil::com_ptr<IMFPresentationDescriptor> pd;\n  HRESULT hr = source->CreatePresentationDescriptor(pd.put());\n  if (FAILED(hr)) {\n    return std::unexpected(\"Failed to create presentation descriptor\");\n  }\n\n  DWORD stream_count = 0;\n  pd->GetStreamDescriptorCount(&stream_count);\n\n  for (DWORD i = 0; i < stream_count; ++i) {\n    BOOL selected = FALSE;\n    wil::com_ptr<IMFStreamDescriptor> sd;\n    hr = pd->GetStreamDescriptorByIndex(i, &selected, sd.put());\n    if (FAILED(hr)) continue;\n\n    wil::com_ptr<IMFMediaTypeHandler> handler;\n    hr = sd->GetMediaTypeHandler(handler.put());\n    if (FAILED(hr)) continue;\n\n    GUID major_type;\n    hr = handler->GetMajorType(&major_type);\n    if (FAILED(hr) || major_type != MFMediaType_Video) continue;\n\n    wil::com_ptr<IMFMediaType> media_type;\n    hr = handler->GetCurrentMediaType(media_type.put());\n    if (FAILED(hr)) continue;\n\n    UINT32 width = 0, height = 0;\n    hr = MFGetAttributeSize(media_type.get(), MF_MT_FRAME_SIZE, &width, &height);\n    if (SUCCEEDED(hr) && width > 0 && height > 0) {\n      return std::make_pair(width, height);\n    }\n  }\n\n  return std::unexpected(\"No video stream found in source\");\n}\n\nauto scale_video_file(const std::filesystem::path& input_path,\n                      const std::filesystem::path& output_path, const ScaleConfig& config)\n    -> std::expected<ScaleResult, std::string> {\n  ScaleResult result = {};\n\n  // 1. 创建 MediaSource\n  wil::com_ptr<IMFSourceResolver> resolver;\n  HRESULT hr = MFCreateSourceResolver(resolver.put());\n  if (FAILED(hr)) {\n    return std::unexpected(\"Failed to create source resolver\");\n  }\n\n  MF_OBJECT_TYPE object_type = MF_OBJECT_INVALID;\n  wil::com_ptr<IUnknown> source_unk;\n  hr = resolver->CreateObjectFromURL(input_path.c_str(), MF_RESOLUTION_MEDIASOURCE, nullptr,\n                                     &object_type, source_unk.put());\n  if (FAILED(hr)) {\n    return std::unexpected(\"Failed to create media source: \" +\n                           std::to_string(static_cast<std::uint32_t>(hr)));\n  }\n\n  wil::com_ptr<IMFMediaSource> media_source;\n  hr = source_unk->QueryInterface(IID_PPV_ARGS(media_source.put()));\n  if (FAILED(hr)) {\n    return std::unexpected(\"Failed to get IMFMediaSource interface\");\n  }\n\n  // 2. 获取源视频分辨率\n  auto dims_result = get_source_video_dimensions(media_source.get());\n  if (!dims_result) {\n    return std::unexpected(dims_result.error());\n  }\n  auto [src_width, src_height] = *dims_result;\n\n  result.src_width = src_width;\n  result.src_height = src_height;\n\n  // 3. 计算目标分辨率\n  auto [target_width, target_height] = calculate_scaled_dimensions(src_width, src_height, config);\n  result.target_width = target_width;\n  result.target_height = target_height;\n\n  // 检查是否需要缩放\n  if (target_width == src_width && target_height == src_height) {\n    Logger().info(\"Video already at target resolution {}x{}, skipping scale\", src_width,\n                  src_height);\n    result.scaled = false;\n    media_source->Shutdown();\n    return result;\n  }\n\n  Logger().info(\"Scaling video from {}x{} to {}x{} using Transcode API\", src_width, src_height,\n                target_width, target_height);\n\n  // 4. 创建 TranscodeProfile\n  wil::com_ptr<IMFTranscodeProfile> profile;\n  hr = MFCreateTranscodeProfile(profile.put());\n  if (FAILED(hr)) {\n    media_source->Shutdown();\n    return std::unexpected(\"Failed to create transcode profile\");\n  }\n\n  // 视频属性\n  wil::com_ptr<IMFAttributes> video_attrs;\n  MFCreateAttributes(video_attrs.put(), 10);\n\n  // 根据 codec 配置选择编码器\n  if (config.codec == VideoCodec::H265) {\n    video_attrs->SetGUID(MF_MT_SUBTYPE, MFVideoFormat_HEVC);\n    video_attrs->SetUINT32(MF_MT_VIDEO_PROFILE, eAVEncH265VProfile_Main_420_8);\n    Logger().debug(\"Using H.265/HEVC codec\");\n  } else {\n    video_attrs->SetGUID(MF_MT_SUBTYPE, MFVideoFormat_H264);\n    video_attrs->SetUINT32(MF_MT_MPEG2_PROFILE, eAVEncH264VProfile_Main);\n    Logger().debug(\"Using H.264/AVC codec\");\n  }\n\n  MFSetAttributeSize(video_attrs.get(), MF_MT_FRAME_SIZE, target_width, target_height);\n  MFSetAttributeRatio(video_attrs.get(), MF_MT_FRAME_RATE, config.fps, 1);\n  video_attrs->SetUINT32(MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive);\n\n  // 根据 rate_control 配置码率控制\n  if (config.rate_control == RateControl::CBR) {\n    // CBR: 使用指定码率\n    video_attrs->SetUINT32(MF_MT_AVG_BITRATE, config.bitrate);\n    Logger().debug(\"Using CBR with bitrate: {} bps\", config.bitrate);\n  } else {\n    // VBR: 使用质量参数，码率作为上限\n    video_attrs->SetUINT32(MF_MT_AVG_BITRATE, config.bitrate);\n    Logger().debug(\"Using VBR with quality: {}, max bitrate: {} bps\", config.quality,\n                   config.bitrate);\n  }\n\n  // 质量 vs 速度：0=最快，100=最高质量\n  video_attrs->SetUINT32(MF_TRANSCODE_QUALITYVSSPEED, config.quality);\n\n  hr = profile->SetVideoAttributes(video_attrs.get());\n  if (FAILED(hr)) {\n    media_source->Shutdown();\n    return std::unexpected(\"Failed to set video attributes\");\n  }\n\n  // 音频属性（MF_MT_AUDIO_AVG_BYTES_PER_SECOND 为字节/秒 = bitrate/8）\n  std::uint32_t audio_bytes_per_sec = config.audio_bitrate / 8;\n  wil::com_ptr<IMFAttributes> audio_attrs;\n  MFCreateAttributes(audio_attrs.put(), 6);\n  audio_attrs->SetGUID(MF_MT_SUBTYPE, MFAudioFormat_AAC);\n  audio_attrs->SetUINT32(MF_MT_AUDIO_BITS_PER_SAMPLE, 16);\n  audio_attrs->SetUINT32(MF_MT_AUDIO_SAMPLES_PER_SECOND, 48000);\n  audio_attrs->SetUINT32(MF_MT_AUDIO_NUM_CHANNELS, 2);\n  audio_attrs->SetUINT32(MF_MT_AUDIO_BLOCK_ALIGNMENT, 1);\n  audio_attrs->SetUINT32(MF_MT_AUDIO_AVG_BYTES_PER_SECOND, audio_bytes_per_sec);\n\n  hr = profile->SetAudioAttributes(audio_attrs.get());\n  if (FAILED(hr)) {\n    Logger().warn(\"Failed to set audio attributes, continuing without audio\");\n  }\n\n  // 容器属性\n  wil::com_ptr<IMFAttributes> container_attrs;\n  MFCreateAttributes(container_attrs.put(), 2);\n  container_attrs->SetGUID(MF_TRANSCODE_CONTAINERTYPE, MFTranscodeContainerType_MPEG4);\n  // 允许硬件加速\n  container_attrs->SetUINT32(MF_TRANSCODE_TOPOLOGYMODE, MF_TRANSCODE_TOPOLOGYMODE_HARDWARE_ALLOWED);\n\n  hr = profile->SetContainerAttributes(container_attrs.get());\n  if (FAILED(hr)) {\n    media_source->Shutdown();\n    return std::unexpected(\"Failed to set container attributes\");\n  }\n\n  // 5. 创建 Transcode Topology\n  wil::com_ptr<IMFTopology> topology;\n  hr = MFCreateTranscodeTopology(media_source.get(), output_path.c_str(), profile.get(),\n                                 topology.put());\n  if (FAILED(hr)) {\n    media_source->Shutdown();\n    return std::unexpected(\"Failed to create transcode topology: \" +\n                           std::to_string(static_cast<std::uint32_t>(hr)));\n  }\n\n  Logger().debug(\"Transcode topology created\");\n\n  // 6. 创建 MediaSession\n  wil::com_ptr<IMFMediaSession> session;\n  hr = MFCreateMediaSession(nullptr, session.put());\n  if (FAILED(hr)) {\n    media_source->Shutdown();\n    return std::unexpected(\"Failed to create media session\");\n  }\n\n  // 设置 topology\n  hr = session->SetTopology(0, topology.get());\n  if (FAILED(hr)) {\n    session->Shutdown();\n    media_source->Shutdown();\n    return std::unexpected(\"Failed to set topology: \" +\n                           std::to_string(static_cast<std::uint32_t>(hr)));\n  }\n\n  // 7. 启动 session\n  PROPVARIANT var_start;\n  PropVariantInit(&var_start);\n  hr = session->Start(&GUID_NULL, &var_start);\n  PropVariantClear(&var_start);\n  if (FAILED(hr)) {\n    session->Shutdown();\n    media_source->Shutdown();\n    return std::unexpected(\"Failed to start session: \" +\n                           std::to_string(static_cast<std::uint32_t>(hr)));\n  }\n\n  Logger().debug(\"Transcode session started\");\n\n  // 8. 等待完成（同步轮询）\n  bool transcoding = true;\n  while (transcoding) {\n    wil::com_ptr<IMFMediaEvent> event;\n    hr = session->GetEvent(0, event.put());  // 阻塞等待\n    if (FAILED(hr)) {\n      Logger().warn(\"GetEvent failed: {:08X}\", static_cast<std::uint32_t>(hr));\n      break;\n    }\n\n    MediaEventType event_type = MEUnknown;\n    event->GetType(&event_type);\n\n    HRESULT event_status = S_OK;\n    event->GetStatus(&event_status);\n\n    switch (event_type) {\n      case MESessionTopologySet:\n        Logger().debug(\"Topology set\");\n        break;\n\n      case MESessionStarted:\n        Logger().debug(\"Session started\");\n        break;\n\n      case MESessionEnded:\n        Logger().debug(\"Session ended, closing...\");\n        session->Close();\n        break;\n\n      case MESessionClosed:\n        Logger().debug(\"Session closed\");\n        transcoding = false;\n        break;\n\n      case MEError:\n        Logger().error(\"Session error: {:08X}\", static_cast<std::uint32_t>(event_status));\n        transcoding = false;\n        break;\n\n      default:\n        // 其他事件忽略\n        break;\n    }\n\n    if (FAILED(event_status)) {\n      Logger().error(\"Event status failed: {:08X}\", static_cast<std::uint32_t>(event_status));\n      session->Close();\n      // 继续循环等待 MESessionClosed\n    }\n  }\n\n  // 9. 清理\n  session->Shutdown();\n  media_source->Shutdown();\n\n  // 检查输出文件\n  std::error_code ec;\n  auto file_size = std::filesystem::file_size(output_path, ec);\n  if (ec || file_size == 0) {\n    return std::unexpected(\"Output file is empty or not created\");\n  }\n\n  result.scaled = true;\n  Logger().info(\"Video scaling completed using Transcode API, saved to {}\", output_path.string());\n  return result;\n}\n\n}  // namespace Utils::Media::VideoScaler\n"
  },
  {
    "path": "src/utils/media/video_scaler.ixx",
    "content": "module;\n\n#include <d3d11.h>\n#include <mfidl.h>\n#include <wil/com.h>\n\nexport module Utils.Media.VideoScaler;\n\nimport std;\n\nexport namespace Utils::Media::VideoScaler {\n\n// 码率控制模式\nenum class RateControl {\n  CBR,  // 固定码率\n  VBR,  // 可变码率（质量优先）\n};\n\n// 视频编码格式\nenum class VideoCodec {\n  H264,  // H.264/AVC\n  H265,  // H.265/HEVC\n};\n\n// 视频缩放配置\nstruct ScaleConfig {\n  std::uint32_t target_width = 0;               // 目标宽度（0 表示自动计算）\n  std::uint32_t target_height = 0;              // 目标高度（0 表示自动计算）\n  std::uint32_t target_short_edge = 0;          // 目标短边（0=不缩放，720/1080/1440/2160）\n  std::uint32_t bitrate = 8'000'000;            // 视频码率\n  std::uint32_t fps = 30;                       // 帧率\n  RateControl rate_control = RateControl::VBR;  // 码率控制模式，默认 VBR\n  std::uint32_t quality = 100;                  // VBR 质量（0-100），仅 VBR 模式有效\n  VideoCodec codec = VideoCodec::H264;          // 视频编码格式\n  std::uint32_t audio_bitrate = 192'000;        // 音频码率 (bps)，AAC 编码\n};\n\n// 缩放结果\nstruct ScaleResult {\n  bool scaled;                  // 是否实际进行了缩放（false 表示源分辨率已符合目标）\n  std::uint32_t src_width;      // 源宽度\n  std::uint32_t src_height;     // 源高度\n  std::uint32_t target_width;   // 目标宽度\n  std::uint32_t target_height;  // 目标高度\n};\n\n// 使用 Media Foundation Transcode API 进行硬件加速视频缩放\n// 完整流程：IMFMediaSource → MFCreateTranscodeTopology → IMFMediaSession\nauto scale_video_file(const std::filesystem::path& input_path,\n                      const std::filesystem::path& output_path, const ScaleConfig& config)\n    -> std::expected<ScaleResult, std::string>;\n\n}  // namespace Utils::Media::VideoScaler\n"
  },
  {
    "path": "src/utils/path/path.cpp",
    "content": "module;\r\n\r\nmodule Utils.Path;\r\n\r\nimport std;\r\nimport Vendor.ShellApi;\r\nimport <windows.h>;\r\n\r\nnamespace Utils::Path::Detail {\r\n\r\nconstexpr std::wstring_view kPortableMarker = L\"portable\";\r\nconstexpr std::wstring_view kAppName = L\"SpinningMomo\";\r\n\r\nauto ensure_path_exists(const std::filesystem::path& path)\r\n    -> std::expected<std::filesystem::path, std::string> {\r\n  auto ensure_result = Utils::Path::EnsureDirectoryExists(path);\r\n  if (!ensure_result) {\r\n    return std::unexpected(ensure_result.error());\r\n  }\r\n\r\n  return path;\r\n}\r\n\r\n}  // namespace Utils::Path::Detail\r\n\r\n// 获取当前程序的完整路径\r\nauto Utils::Path::GetExecutablePath() -> std::expected<std::filesystem::path, std::string> {\r\n  // 静态缓存，只在第一次调用时初始化\r\n  static std::optional<std::filesystem::path> cached_path;\r\n  static std::optional<std::string> cached_error;\r\n\r\n  if (!cached_path.has_value() && !cached_error.has_value()) {\r\n    try {\r\n      std::vector<wchar_t> buffer(MAX_PATH);\r\n\r\n      while (true) {\r\n        DWORD size = GetModuleFileNameW(NULL, buffer.data(), static_cast<DWORD>(buffer.size()));\r\n\r\n        if (size == 0) {\r\n          cached_error = \"Failed to get executable path, error: \" + std::to_string(GetLastError());\r\n          break;\r\n        }\r\n\r\n        if (size < buffer.size()) {\r\n          cached_path = std::filesystem::path(buffer.data(), buffer.data() + size);\r\n          break;\r\n        }\r\n\r\n        if (buffer.size() >= 32767) {\r\n          cached_error = \"Path too long for GetModuleFileNameW\";\r\n          break;\r\n        }\r\n\r\n        buffer.resize(buffer.size() * 2);\r\n      }\r\n    } catch (const std::exception& e) {\r\n      cached_error = \"Exception: \" + std::string(e.what());\r\n    }\r\n  }\r\n\r\n  if (cached_path.has_value()) {\r\n    return cached_path.value();\r\n  } else {\r\n    return std::unexpected(cached_error.value());\r\n  }\r\n}\r\n\r\n// 获取当前程序所在的目录路径\r\nauto Utils::Path::GetExecutableDirectory() -> std::expected<std::filesystem::path, std::string> {\r\n  auto pathResult = GetExecutablePath();\r\n  if (!pathResult) {\r\n    return std::unexpected(pathResult.error());\r\n  }\r\n\r\n  try {\r\n    return pathResult.value().parent_path();\r\n  } catch (const std::exception& e) {\r\n    return std::unexpected(\"Exception getting directory: \" + std::string(e.what()));\r\n  }\r\n}\r\n\r\nauto Utils::Path::GetAppMode() -> AppMode {\r\n  auto exe_dir_result = GetExecutableDirectory();\r\n  if (!exe_dir_result) {\r\n    return AppMode::Portable;\r\n  }\r\n\r\n  return std::filesystem::exists(exe_dir_result.value() / Detail::kPortableMarker)\r\n             ? AppMode::Portable\r\n             : AppMode::Installed;\r\n}\r\n\r\nauto Utils::Path::GetAppDataDirectory() -> std::expected<std::filesystem::path, std::string> {\r\n  if (GetAppMode() == AppMode::Portable) {\r\n    auto exe_dir_result = GetExecutableDirectory();\r\n    if (!exe_dir_result) {\r\n      return std::unexpected(\"Failed to get executable directory: \" + exe_dir_result.error());\r\n    }\r\n\r\n    return Detail::ensure_path_exists(exe_dir_result.value() / \"data\");\r\n  }\r\n\r\n  PWSTR local_app_data_raw = nullptr;\r\n  const auto hr = Vendor::ShellApi::SHGetKnownFolderPath(Vendor::ShellApi::kFOLDERID_LocalAppData,\r\n                                                         0, nullptr, &local_app_data_raw);\r\n  if (FAILED(hr) || !local_app_data_raw) {\r\n    if (local_app_data_raw) {\r\n      Vendor::ShellApi::CoTaskMemFree(local_app_data_raw);\r\n    }\r\n    return std::unexpected(\"Failed to get LocalAppData directory, HRESULT: \" + std::to_string(hr));\r\n  }\r\n\r\n  std::filesystem::path app_data_root =\r\n      std::filesystem::path(local_app_data_raw) / Detail::kAppName;\r\n  Vendor::ShellApi::CoTaskMemFree(local_app_data_raw);\r\n  return Detail::ensure_path_exists(app_data_root);\r\n}\r\n\r\nauto Utils::Path::GetAppDataSubdirectory(std::string_view name)\r\n    -> std::expected<std::filesystem::path, std::string> {\r\n  auto app_data_dir_result = GetAppDataDirectory();\r\n  if (!app_data_dir_result) {\r\n    return std::unexpected(app_data_dir_result.error());\r\n  }\r\n\r\n  return Detail::ensure_path_exists(app_data_dir_result.value() /\r\n                                    std::filesystem::path(std::string{name}));\r\n}\r\n\r\nauto Utils::Path::GetAppDataFilePath(std::string_view filename)\r\n    -> std::expected<std::filesystem::path, std::string> {\r\n  auto app_data_dir_result = GetAppDataDirectory();\r\n  if (!app_data_dir_result) {\r\n    return std::unexpected(app_data_dir_result.error());\r\n  }\r\n\r\n  return app_data_dir_result.value() / std::filesystem::path(std::string{filename});\r\n}\r\n\r\nauto Utils::Path::GetEmbeddedWebRootDirectory()\r\n    -> std::expected<std::filesystem::path, std::string> {\r\n  auto exe_dir_result = GetExecutableDirectory();\r\n  if (!exe_dir_result) {\r\n    return std::unexpected(\"Failed to get executable directory: \" + exe_dir_result.error());\r\n  }\r\n\r\n  return exe_dir_result.value() / \"resources\" / \"web\";\r\n}\r\n\r\n// 确保目录存在，如果不存在则创建\r\nauto Utils::Path::EnsureDirectoryExists(const std::filesystem::path& dir)\r\n    -> std::expected<void, std::string> {\r\n  try {\r\n    if (!std::filesystem::exists(dir)) {\r\n      std::filesystem::create_directories(dir);\r\n    }\r\n    return {};\r\n  } catch (const std::exception& e) {\r\n    return std::unexpected(\"Failed to create directory: \" + std::string(e.what()));\r\n  }\r\n}\r\n\r\n// 规范化路径为绝对路径，默认相对于程序目录\r\nauto Utils::Path::NormalizePath(const std::filesystem::path& path,\r\n                                std::optional<std::filesystem::path> base)\r\n    -> std::expected<std::filesystem::path, std::string> {\r\n  try {\r\n    std::filesystem::path base_path;\r\n\r\n    if (base.has_value()) {\r\n      base_path = base.value();\r\n    } else {\r\n      // 默认使用程序目录作为base\r\n      auto exe_dir_result = GetExecutableDirectory();\r\n      if (!exe_dir_result) {\r\n        return std::unexpected(\"Failed to get executable directory: \" + exe_dir_result.error());\r\n      }\r\n      base_path = exe_dir_result.value();\r\n    }\r\n\r\n    if (!base_path.is_absolute()) {\r\n      return std::unexpected(\"Base path must be an absolute path.\");\r\n    }\r\n\r\n    std::filesystem::path combined_path;\r\n    if (path.is_absolute()) {\r\n      combined_path = path;\r\n    } else {\r\n      combined_path = base_path / path;\r\n    }\r\n\r\n    std::filesystem::path normalized_path = std::filesystem::weakly_canonical(combined_path);\r\n\r\n    // 统一使用正斜杠格式，确保跨平台一致性\r\n    return std::filesystem::path(normalized_path.generic_string());\r\n\r\n  } catch (const std::filesystem::filesystem_error& e) {\r\n    return std::unexpected(std::string(e.what()));\r\n  }\r\n}\r\n\r\n// 把路径转成适合比较的统一形式：先 lexically_normal 消除冗余分隔符，\r\n// 再转小写、统一为正斜杠。用于 Windows 大小写不敏感的前缀匹配场景。\r\nauto Utils::Path::NormalizeForComparison(const std::filesystem::path& path) -> std::wstring {\r\n  auto value = path.lexically_normal().generic_wstring();\r\n  std::ranges::transform(value, value.begin(),\r\n                         [](wchar_t ch) { return static_cast<wchar_t>(std::towlower(ch)); });\r\n  return value;\r\n}\r\n\r\n// 判断 target 是否位于 base 目录内部（大小写不敏感，Windows 语义）。\r\n// 通过前缀匹配实现，匹配后确认紧随字符为 '/' 或路径刚好等长，\r\n// 防止 /foo/bar 被误判为 /foo/ba 的子目录。\r\nauto Utils::Path::IsPathWithinBase(const std::filesystem::path& target,\r\n                                   const std::filesystem::path& base) -> bool {\r\n  auto normalized_base = NormalizeForComparison(base);\r\n  auto normalized_target = NormalizeForComparison(target);\r\n\r\n  if (!normalized_target.starts_with(normalized_base)) {\r\n    return false;\r\n  }\r\n\r\n  if (normalized_target.size() == normalized_base.size()) {\r\n    return true;\r\n  }\r\n\r\n  return normalized_target[normalized_base.size()] == L'/';\r\n}\r\n\r\n// 获取用户视频文件夹路径 (FOLDERID_Videos)\r\nauto Utils::Path::GetUserVideosDirectory() -> std::expected<std::filesystem::path, std::string> {\r\n  PWSTR path = nullptr;\r\n  HRESULT hr =\r\n      Vendor::ShellApi::SHGetKnownFolderPath(Vendor::ShellApi::kFOLDERID_Videos, 0, nullptr, &path);\r\n  if (FAILED(hr) || !path) {\r\n    if (path) Vendor::ShellApi::CoTaskMemFree(path);\r\n    return std::unexpected(\"Failed to get user Videos directory, HRESULT: \" + std::to_string(hr));\r\n  }\r\n\r\n  std::filesystem::path result(path);\r\n  Vendor::ShellApi::CoTaskMemFree(path);\r\n  return result;\r\n}\r\n\r\nauto Utils::Path::GetOutputDirectory(const std::string& configured_output_dir_path)\r\n    -> std::expected<std::filesystem::path, std::string> {\r\n  if (!configured_output_dir_path.empty()) {\r\n    std::filesystem::path configured_path = configured_output_dir_path;\r\n    auto ensure_result = EnsureDirectoryExists(configured_path);\r\n    if (!ensure_result) {\r\n      return std::unexpected(\"Failed to create configured output directory: \" +\r\n                             ensure_result.error());\r\n    }\r\n    return configured_path;\r\n  }\r\n\r\n  auto videos_dir_result = GetUserVideosDirectory();\r\n  if (videos_dir_result) {\r\n    auto output_dir = *videos_dir_result / \"SpinningMomo\";\r\n    auto ensure_result = EnsureDirectoryExists(output_dir);\r\n    if (ensure_result) {\r\n      return output_dir;\r\n    }\r\n  }\r\n\r\n  auto exe_dir_result = GetExecutableDirectory();\r\n  if (!exe_dir_result) {\r\n    return std::unexpected(\"Failed to get executable directory: \" + exe_dir_result.error());\r\n  }\r\n\r\n  auto fallback_output_dir = *exe_dir_result / \"SpinningMomo\";\r\n  auto ensure_result = EnsureDirectoryExists(fallback_output_dir);\r\n  if (!ensure_result) {\r\n    return std::unexpected(\"Failed to create fallback output directory: \" + ensure_result.error());\r\n  }\r\n\r\n  return fallback_output_dir;\r\n}\r\n"
  },
  {
    "path": "src/utils/path/path.ixx",
    "content": "module;\n\nexport module Utils.Path;\n\nimport std;\n\n// 路径工具命名空间\nnamespace Utils::Path {\n\n// 应用运行模式\nexport enum class AppMode {\n  Portable,\n  Installed,\n};\n\n// 获取当前程序所在的目录路径\nexport auto GetExecutableDirectory() -> std::expected<std::filesystem::path, std::string>;\n\n// 获取当前程序的完整路径\nexport auto GetExecutablePath() -> std::expected<std::filesystem::path, std::string>;\n\n// 检测当前是否为便携版模式（exe 同目录存在 portable 标记文件）\nexport auto GetAppMode() -> AppMode;\n\n// 获取应用运行时数据根目录：\n// - 便携版：<exe>/data\n// - 安装版：%LOCALAPPDATA%/ChanIok/SpinningMomo\nexport auto GetAppDataDirectory() -> std::expected<std::filesystem::path, std::string>;\n\n// 获取应用运行时数据子目录，并确保目录存在\nexport auto GetAppDataSubdirectory(std::string_view name)\n    -> std::expected<std::filesystem::path, std::string>;\n\n// 获取应用运行时数据文件路径，并确保数据根目录存在\nexport auto GetAppDataFilePath(std::string_view filename)\n    -> std::expected<std::filesystem::path, std::string>;\n\n// 获取内置前端静态资源根目录：<exe>/resources/web\nexport auto GetEmbeddedWebRootDirectory() -> std::expected<std::filesystem::path, std::string>;\n\n// 确保目录存在，如果不存在则创建\nexport auto EnsureDirectoryExists(const std::filesystem::path& dir)\n    -> std::expected<void, std::string>;\n\n// 规范化路径为绝对路径，默认相对于程序目录\nexport auto NormalizePath(const std::filesystem::path& path,\n                          std::optional<std::filesystem::path> base = std::nullopt)\n    -> std::expected<std::filesystem::path, std::string>;\n\n// 获取用户视频文件夹路径 (FOLDERID_Videos)\nexport auto GetUserVideosDirectory() -> std::expected<std::filesystem::path, std::string>;\n\n// 获取应用输出目录：\n// 1. 使用配置目录（非空时）\n// 2. 回退到 Videos/SpinningMomo\n// 3. 最终回退到 exe 目录下的 SpinningMomo\nexport auto GetOutputDirectory(const std::string& configured_output_dir_path)\n    -> std::expected<std::filesystem::path, std::string>;\n\n// 把路径转成适合比较的统一形式（小写 + 正斜杠）。\n// 主要用于 Windows 大小写不敏感的前缀匹配场景。\nexport auto NormalizeForComparison(const std::filesystem::path& path) -> std::wstring;\n\n// 判断 target 是否位于 base 目录内部（大小写不敏感）。\nexport auto IsPathWithinBase(const std::filesystem::path& target, const std::filesystem::path& base)\n    -> bool;\n\n}  // namespace Utils::Path\n"
  },
  {
    "path": "src/utils/string/string.ixx",
    "content": "module;\r\n\r\nexport module Utils.String;\r\n\r\nimport std;\r\nimport <windows.h>;\r\n\r\n// 字符串工具命名空间\r\nnamespace Utils::String {\r\n\r\n// 将宽字符串转换为UTF-8编码字符串\r\nexport [[nodiscard]] auto ToUtf8(const std::wstring& wide_str) noexcept -> std::string {\r\n  if (wide_str.empty()) [[likely]]\r\n    return {};\r\n\r\n  const auto size_needed =\r\n      WideCharToMultiByte(CP_UTF8, 0, wide_str.c_str(), static_cast<int>(wide_str.size()), nullptr,\r\n                          0, nullptr, nullptr);\r\n\r\n  if (size_needed <= 0) [[unlikely]]\r\n    return {};\r\n\r\n  std::string result(size_needed, '\\0');\r\n  WideCharToMultiByte(CP_UTF8, 0, wide_str.c_str(), static_cast<int>(wide_str.size()),\r\n                      result.data(), size_needed, nullptr, nullptr);\r\n\r\n  return result;\r\n}\r\n\r\n// 将UTF-8编码字符串转换为宽字符串\r\nexport [[nodiscard]] auto FromUtf8(const std::string& utf8_str) noexcept -> std::wstring {\r\n  if (utf8_str.empty()) [[likely]]\r\n    return {};\r\n\r\n  const auto size_needed = MultiByteToWideChar(CP_UTF8, 0, utf8_str.c_str(),\r\n                                               static_cast<int>(utf8_str.size()), nullptr, 0);\r\n\r\n  if (size_needed <= 0) [[unlikely]]\r\n    return {};\r\n\r\n  std::wstring result(size_needed, L'\\0');\r\n  MultiByteToWideChar(CP_UTF8, 0, utf8_str.c_str(), static_cast<int>(utf8_str.size()),\r\n                      result.data(), size_needed);\r\n\r\n  return result;\r\n}\r\n\r\n// 将ASCII字符串转换为小写副本\r\nexport [[nodiscard]] auto ToLowerAscii(std::string value) -> std::string {\r\n  std::ranges::transform(value, value.begin(),\r\n                         [](unsigned char ch) { return static_cast<char>(std::tolower(ch)); });\r\n  return value;\r\n}\r\n\r\n// 去除ASCII字符串首尾空白字符\r\nexport [[nodiscard]] auto TrimAscii(std::string_view value) -> std::string {\r\n  auto is_space = [](char ch) { return std::isspace(static_cast<unsigned char>(ch)) != 0; };\r\n\r\n  auto begin = std::find_if_not(value.begin(), value.end(), is_space);\r\n  if (begin == value.end()) {\r\n    return {};\r\n  }\r\n\r\n  auto end = std::find_if_not(value.rbegin(), value.rend(), is_space).base();\r\n  return std::string(begin, end);\r\n}\r\n\r\n// 检查ASCII字符串是否只包含空白字符\r\nexport [[nodiscard]] auto IsBlankAscii(std::string_view value) -> bool {\r\n  return std::ranges::all_of(\r\n      value, [](char ch) { return std::isspace(static_cast<unsigned char>(ch)) != 0; });\r\n}\r\n\r\n// 格式化时间戳为文件名安全的字符串\r\nexport [[nodiscard]] auto FormatTimestamp(const std::chrono::system_clock::time_point& time_point)\r\n    -> std::string {\r\n  using namespace std::chrono;\r\n\r\n  auto now = time_point;\r\n  auto local_time = zoned_time{current_zone(), floor<seconds>(now)};\r\n  auto ms = duration_cast<milliseconds>(now.time_since_epoch()) % 1000;\r\n\r\n  return std::format(\"{:%Y%m%d_%H%M%S}_{:03d}.png\", local_time, ms.count());\r\n}\r\n\r\n// Base64编码表\r\nconstexpr char kBase64Chars[] = \"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/\";\r\n\r\n// 将二进制数据编码为Base64字符串\r\nexport [[nodiscard]] auto ToBase64(const std::vector<char>& binary_data) -> std::string {\r\n  std::string result;\r\n  const auto data_size = binary_data.size();\r\n  result.reserve((data_size + 2) / 3 * 4);\r\n\r\n  for (size_t i = 0; i < data_size; i += 3) {\r\n    const auto bytes_left = std::min<size_t>(3, data_size - i);\r\n\r\n    std::uint32_t chunk = 0;\r\n    for (size_t j = 0; j < bytes_left; ++j) {\r\n      chunk |= (static_cast<std::uint8_t>(binary_data[i + j]) << (8 * (2 - j)));\r\n    }\r\n\r\n    result.push_back(kBase64Chars[(chunk >> 18) & 0x3F]);\r\n    result.push_back(kBase64Chars[(chunk >> 12) & 0x3F]);\r\n    result.push_back(bytes_left > 1 ? kBase64Chars[(chunk >> 6) & 0x3F] : '=');\r\n    result.push_back(bytes_left > 2 ? kBase64Chars[chunk & 0x3F] : '=');\r\n  }\r\n\r\n  return result;\r\n}\r\n\r\n// 将Base64字符串解码为二进制数据\r\nexport [[nodiscard]] auto FromBase64(const std::string& base64_str) -> std::vector<char> {\r\n  std::vector<char> result;\r\n  if (base64_str.empty()) return result;\r\n\r\n  // 创建解码表\r\n  int decode_table[256];\r\n  std::fill(std::begin(decode_table), std::end(decode_table), -1);\r\n  for (int i = 0; i < 64; ++i) {\r\n    decode_table[static_cast<std::uint8_t>(kBase64Chars[i])] = i;\r\n  }\r\n\r\n  const auto input_len = base64_str.length();\r\n  result.reserve(input_len / 4 * 3);\r\n\r\n  for (size_t i = 0; i < input_len; i += 4) {\r\n    if (i + 3 >= input_len) break;\r\n\r\n    const auto a = decode_table[static_cast<std::uint8_t>(base64_str[i])];\r\n    const auto b = decode_table[static_cast<std::uint8_t>(base64_str[i + 1])];\r\n    const auto c =\r\n        base64_str[i + 2] == '=' ? -1 : decode_table[static_cast<std::uint8_t>(base64_str[i + 2])];\r\n    const auto d =\r\n        base64_str[i + 3] == '=' ? -1 : decode_table[static_cast<std::uint8_t>(base64_str[i + 3])];\r\n\r\n    if (a == -1 || b == -1) break;\r\n\r\n    result.push_back(static_cast<char>((a << 2) | (b >> 4)));\r\n\r\n    if (c != -1) {\r\n      result.push_back(static_cast<char>(((b & 0x0F) << 4) | (c >> 2)));\r\n\r\n      if (d != -1) {\r\n        result.push_back(static_cast<char>(((c & 0x03) << 6) | d));\r\n      }\r\n    }\r\n  }\r\n\r\n  return result;\r\n}\r\n\r\n// 检查字符串是否为有效的UTF-8\r\nexport [[nodiscard]] auto IsValidUtf8(const std::vector<char>& data) -> bool {\r\n  for (size_t i = 0; i < data.size(); ++i) {\r\n    const auto byte = static_cast<std::uint8_t>(data[i]);\r\n\r\n    if (byte < 0x80) {\r\n      // ASCII字符\r\n      continue;\r\n    } else if ((byte >> 5) == 0x06) {\r\n      // 2字节UTF-8\r\n      if (i + 1 >= data.size() || (static_cast<std::uint8_t>(data[i + 1]) >> 6) != 0x02) {\r\n        return false;\r\n      }\r\n      i += 1;\r\n    } else if ((byte >> 4) == 0x0E) {\r\n      // 3字节UTF-8\r\n      if (i + 2 >= data.size() || (static_cast<std::uint8_t>(data[i + 1]) >> 6) != 0x02 ||\r\n          (static_cast<std::uint8_t>(data[i + 2]) >> 6) != 0x02) {\r\n        return false;\r\n      }\r\n      i += 2;\r\n    } else if ((byte >> 3) == 0x1E) {\r\n      // 4字节UTF-8\r\n      if (i + 3 >= data.size() || (static_cast<std::uint8_t>(data[i + 1]) >> 6) != 0x02 ||\r\n          (static_cast<std::uint8_t>(data[i + 2]) >> 6) != 0x02 ||\r\n          (static_cast<std::uint8_t>(data[i + 3]) >> 6) != 0x02) {\r\n        return false;\r\n      }\r\n      i += 3;\r\n    } else {\r\n      return false;\r\n    }\r\n  }\r\n  return true;\r\n}\r\n\r\n}  // namespace Utils::String\r\n"
  },
  {
    "path": "src/utils/system/system.cpp",
    "content": "module;\n\nmodule Utils.System;\n\nimport std;\nimport <windows.h>;\nimport Vendor.ShellApi;\nimport Vendor.Windows;\nimport Utils.String;\n\nnamespace Utils::System {\n\n// Windows 约定：Preferred DropEffect = 1 表示“复制”，\n// 这样其他程序在粘贴这些文件时会按“复制文件”来理解，而不是“移动文件”。\nconstexpr DWORD kClipboardDropEffectCopy = 1;\n\n// 获取当前 Windows 系统版本信息\n[[nodiscard]] auto get_windows_version() noexcept\n    -> std::expected<WindowsVersionInfo, std::string> {\n  RTL_OSVERSIONINFOW osInfo = {sizeof(RTL_OSVERSIONINFOW)};\n  HMODULE hNtDll = GetModuleHandleW(L\"ntdll.dll\");\n\n  if (!hNtDll) [[unlikely]]\n    return std::unexpected(\"Failed to get module handle for ntdll.dll\");\n\n  typedef LONG(NTAPI * RtlGetVersionPtr)(PRTL_OSVERSIONINFOW);\n  RtlGetVersionPtr RtlGetVersion = (RtlGetVersionPtr)GetProcAddress(hNtDll, \"RtlGetVersion\");\n\n  if (!RtlGetVersion) [[unlikely]]\n    return std::unexpected(\"Failed to get RtlGetVersion function address\");\n\n  RtlGetVersion(&osInfo);\n  return WindowsVersionInfo{osInfo.dwMajorVersion, osInfo.dwMinorVersion, osInfo.dwBuildNumber,\n                            osInfo.dwPlatformId};\n}\n\n// 根据 WindowsVersionInfo 获取系统名称\n[[nodiscard]] auto get_windows_name(const WindowsVersionInfo& version) noexcept -> std::string {\n  if (version.major_version == 10) {\n    if (version.build_number >= 22000) {\n      return \"Windows 11\";\n    } else {\n      return \"Windows 10\";\n    }\n  } else if (version.major_version == 6 && version.minor_version == 1) {\n    return \"Windows 7\";\n  } else if (version.major_version == 6 && version.minor_version == 2) {\n    return \"Windows 8\";\n  } else if (version.major_version == 6 && version.minor_version == 3) {\n    return \"Windows 8.1\";\n  } else {\n    return \"Windows\";\n  }\n}\n\n// 检测当前进程是否以管理员权限运行\n[[nodiscard]] auto is_process_elevated() noexcept -> bool {\n  // 使用静态变量缓存结果，避免重复检测\n  static bool result = []() {\n    BYTE admin_sid[SECURITY_MAX_SID_SIZE]{};\n    DWORD sid_size = sizeof(admin_sid);\n\n    // 创建管理员组的 SID\n    if (!CreateWellKnownSid(WinBuiltinAdministratorsSid, nullptr, &admin_sid, &sid_size)) {\n      return false;\n    }\n\n    BOOL is_admin = FALSE;\n    // 检查当前进程令牌是否属于管理员组\n    if (!CheckTokenMembership(nullptr, admin_sid, &is_admin)) {\n      return false;\n    }\n\n    return is_admin != FALSE;\n  }();\n\n  return result;\n}\n\n// 以管理员权限重启当前应用程序\n[[nodiscard]] auto restart_as_elevated(const wchar_t* arguments) noexcept -> bool {\n  // 获取当前可执行文件路径\n  wchar_t exe_path[MAX_PATH]{};\n  if (GetModuleFileNameW(nullptr, exe_path, MAX_PATH) == 0) {\n    return false;\n  }\n\n  // 使用 ShellExecuteEx 请求提升权限\n  Vendor::ShellApi::SHELLEXECUTEINFOW exec_info{\n      .cbSize = sizeof(exec_info),\n      .fMask = Vendor::ShellApi::kSEE_MASK_NOASYNC,  // 同步执行\n      .lpVerb = L\"runas\",                            // 请求提升权限\n      .lpFile = exe_path,                            // 要执行的文件\n      .lpParameters = arguments,                     // 命令行参数\n      .nShow = Vendor::ShellApi::kSW_SHOWNORMAL      // 显示窗口\n  };\n\n  return Vendor::ShellApi::ShellExecuteExW(&exec_info) != FALSE;\n}\n\nauto open_directory(const std::filesystem::path& path) -> std::expected<void, std::string> {\n  if (path.empty()) {\n    return std::unexpected(\"Directory path is empty\");\n  }\n\n  try {\n    if (!std::filesystem::exists(path)) {\n      return std::unexpected(\"Directory does not exist: \" + path.string());\n    }\n\n    if (!std::filesystem::is_directory(path)) {\n      return std::unexpected(\"Path is not a directory: \" + path.string());\n    }\n  } catch (const std::exception& e) {\n    return std::unexpected(\"Failed to validate directory path: \" + std::string(e.what()));\n  }\n\n  std::wstring wpath = std::filesystem::path(path).make_preferred().wstring();\n  Vendor::ShellApi::SHELLEXECUTEINFOW exec_info{\n      .cbSize = sizeof(exec_info),\n      .fMask = Vendor::ShellApi::kSEE_MASK_NOASYNC,\n      .lpVerb = L\"open\",\n      .lpFile = wpath.c_str(),\n      .nShow = Vendor::ShellApi::kSW_SHOWNORMAL,\n  };\n\n  if (!Vendor::ShellApi::ShellExecuteExW(&exec_info)) {\n    return std::unexpected(\"Failed to open directory, Win32 error: \" +\n                           std::to_string(Vendor::Windows::GetLastError()));\n  }\n\n  return {};\n}\n\nauto open_file_with_default_app(const std::filesystem::path& path)\n    -> std::expected<void, std::string> {\n  if (path.empty()) {\n    return std::unexpected(\"File path is empty\");\n  }\n\n  try {\n    if (!std::filesystem::exists(path)) {\n      return std::unexpected(\"File does not exist: \" + path.string());\n    }\n\n    if (!std::filesystem::is_regular_file(path)) {\n      return std::unexpected(\"Path is not a file: \" + path.string());\n    }\n  } catch (const std::exception& e) {\n    return std::unexpected(\"Failed to validate file path: \" + std::string(e.what()));\n  }\n\n  std::wstring wpath = std::filesystem::path(path).make_preferred().wstring();\n  Vendor::ShellApi::SHELLEXECUTEINFOW exec_info{\n      .cbSize = sizeof(exec_info),\n      .fMask = Vendor::ShellApi::kSEE_MASK_NOASYNC,\n      .lpVerb = L\"open\",\n      .lpFile = wpath.c_str(),\n      .nShow = Vendor::ShellApi::kSW_SHOWNORMAL,\n  };\n\n  if (!Vendor::ShellApi::ShellExecuteExW(&exec_info)) {\n    return std::unexpected(\"Failed to open file with default app, Win32 error: \" +\n                           std::to_string(Vendor::Windows::GetLastError()));\n  }\n\n  return {};\n}\n\nauto reveal_file_in_explorer(const std::filesystem::path& path)\n    -> std::expected<void, std::string> {\n  if (path.empty()) {\n    return std::unexpected(\"File path is empty\");\n  }\n\n  try {\n    if (!std::filesystem::exists(path)) {\n      return std::unexpected(\"File does not exist: \" + path.string());\n    }\n  } catch (const std::exception& e) {\n    return std::unexpected(\"Failed to validate file path: \" + std::string(e.what()));\n  }\n\n  std::wstring wpath = std::filesystem::path(path).make_preferred().wstring();\n  std::wstring params = std::format(LR\"(/select,\"{}\")\", wpath);\n  Vendor::ShellApi::SHELLEXECUTEINFOW exec_info{\n      .cbSize = sizeof(exec_info),\n      .fMask = Vendor::ShellApi::kSEE_MASK_NOASYNC,\n      .lpVerb = L\"open\",\n      .lpFile = L\"explorer.exe\",\n      .lpParameters = params.c_str(),\n      .nShow = Vendor::ShellApi::kSW_SHOWNORMAL,\n  };\n\n  if (!Vendor::ShellApi::ShellExecuteExW(&exec_info)) {\n    return std::unexpected(\"Failed to reveal file in explorer, Win32 error: \" +\n                           std::to_string(Vendor::Windows::GetLastError()));\n  }\n\n  return {};\n}\n\nauto copy_files_to_clipboard(const std::vector<std::filesystem::path>& paths)\n    -> std::expected<void, std::string> {\n  // 这里复制的是“文件对象”到系统剪贴板，不是复制路径文本。\n  // 目标效果要和资源管理器里 Ctrl+C 文件尽量一致。\n  if (paths.empty()) {\n    return std::unexpected(\"No file paths to copy\");\n  }\n\n  std::vector<std::wstring> normalized_paths;\n  normalized_paths.reserve(paths.size());\n\n  size_t total_chars = 0;\n  for (const auto& path : paths) {\n    if (path.empty()) {\n      continue;\n    }\n\n    auto normalized_path = std::filesystem::path(path).make_preferred().wstring();\n    if (normalized_path.empty()) {\n      continue;\n    }\n\n    total_chars += normalized_path.size() + 1;\n    normalized_paths.push_back(std::move(normalized_path));\n  }\n\n  if (normalized_paths.empty()) {\n    return std::unexpected(\"No valid file paths to copy\");\n  }\n\n  auto free_global = [](HGLOBAL handle) {\n    if (handle != nullptr) {\n      GlobalFree(handle);\n    }\n  };\n\n  // CF_HDROP 需要一块连续内存：前面是 DROPFILES 头，后面是以 \\0 分隔、\\0\\0 结尾的宽字符串路径列表。\n  const auto dropfiles_size =\n      sizeof(Vendor::ShellApi::DROPFILES) + ((total_chars + 1) * sizeof(wchar_t));\n  HGLOBAL dropfiles_handle = GlobalAlloc(GMEM_MOVEABLE | GMEM_ZEROINIT, dropfiles_size);\n  if (dropfiles_handle == nullptr) {\n    return std::unexpected(\"Failed to allocate clipboard file list, Win32 error: \" +\n                           std::to_string(Vendor::Windows::GetLastError()));\n  }\n\n  auto* dropfiles = static_cast<Vendor::ShellApi::DROPFILES*>(GlobalLock(dropfiles_handle));\n  if (dropfiles == nullptr) {\n    free_global(dropfiles_handle);\n    return std::unexpected(\"Failed to lock clipboard file list, Win32 error: \" +\n                           std::to_string(Vendor::Windows::GetLastError()));\n  }\n\n  dropfiles->pFiles = sizeof(Vendor::ShellApi::DROPFILES);\n  dropfiles->fWide = TRUE;\n\n  // 把所有文件路径顺序写进 DROPFILES 后面的缓冲区。\n  auto* cursor = reinterpret_cast<wchar_t*>(reinterpret_cast<std::byte*>(dropfiles) +\n                                            sizeof(Vendor::ShellApi::DROPFILES));\n  for (const auto& normalized_path : normalized_paths) {\n    const auto byte_count = (normalized_path.size() + 1) * sizeof(wchar_t);\n    std::memcpy(cursor, normalized_path.c_str(), byte_count);\n    cursor += normalized_path.size() + 1;\n  }\n  *cursor = L'\\0';\n\n  // GlobalUnlock 返回 0 不一定代表失败。\n  // 所以先清空 last error，再根据是否仍然为 NO_ERROR 来判断。\n  SetLastError(NO_ERROR);\n  if (!GlobalUnlock(dropfiles_handle) && Vendor::Windows::GetLastError() != NO_ERROR) {\n    free_global(dropfiles_handle);\n    return std::unexpected(\"Failed to unlock clipboard file list, Win32 error: \" +\n                           std::to_string(Vendor::Windows::GetLastError()));\n  }\n\n  // Preferred DropEffect 是 Windows Shell 常用的附加格式，\n  // 用来告诉接收方这次剪贴板语义是“复制”还是“剪切/移动”。\n  const auto preferred_drop_effect_format = RegisterClipboardFormatW(L\"Preferred DropEffect\");\n  if (preferred_drop_effect_format == 0) {\n    free_global(dropfiles_handle);\n    return std::unexpected(\"Failed to register clipboard drop effect format, Win32 error: \" +\n                           std::to_string(Vendor::Windows::GetLastError()));\n  }\n\n  HGLOBAL drop_effect_handle = GlobalAlloc(GMEM_MOVEABLE | GMEM_ZEROINIT, sizeof(DWORD));\n  if (drop_effect_handle == nullptr) {\n    free_global(dropfiles_handle);\n    return std::unexpected(\"Failed to allocate clipboard drop effect, Win32 error: \" +\n                           std::to_string(Vendor::Windows::GetLastError()));\n  }\n\n  auto* drop_effect = static_cast<DWORD*>(GlobalLock(drop_effect_handle));\n  if (drop_effect == nullptr) {\n    free_global(dropfiles_handle);\n    free_global(drop_effect_handle);\n    return std::unexpected(\"Failed to lock clipboard drop effect, Win32 error: \" +\n                           std::to_string(Vendor::Windows::GetLastError()));\n  }\n\n  *drop_effect = kClipboardDropEffectCopy;\n\n  // 同上：GlobalUnlock 返回 0 时，需要结合 last error 判断是否真失败。\n  SetLastError(NO_ERROR);\n  if (!GlobalUnlock(drop_effect_handle) && Vendor::Windows::GetLastError() != NO_ERROR) {\n    free_global(dropfiles_handle);\n    free_global(drop_effect_handle);\n    return std::unexpected(\"Failed to unlock clipboard drop effect, Win32 error: \" +\n                           std::to_string(Vendor::Windows::GetLastError()));\n  }\n\n  if (!OpenClipboard(nullptr)) {\n    free_global(dropfiles_handle);\n    free_global(drop_effect_handle);\n    return std::unexpected(\"Failed to open clipboard, Win32 error: \" +\n                           std::to_string(Vendor::Windows::GetLastError()));\n  }\n\n  struct ClipboardCloser {\n    ~ClipboardCloser() { CloseClipboard(); }\n  } clipboard_closer;\n\n  // OpenClipboard 成功后，当前进程接管剪贴板；\n  // 先清空旧内容，再写入文件列表和“复制”语义。\n  if (!EmptyClipboard()) {\n    free_global(dropfiles_handle);\n    free_global(drop_effect_handle);\n    return std::unexpected(\"Failed to empty clipboard, Win32 error: \" +\n                           std::to_string(Vendor::Windows::GetLastError()));\n  }\n\n  if (SetClipboardData(Vendor::ShellApi::kCF_HDROP, dropfiles_handle) == nullptr) {\n    free_global(dropfiles_handle);\n    free_global(drop_effect_handle);\n    return std::unexpected(\"Failed to set clipboard file list, Win32 error: \" +\n                           std::to_string(Vendor::Windows::GetLastError()));\n  }\n  dropfiles_handle = nullptr;\n\n  // 再补一份 Preferred DropEffect，让粘贴方知道这是“复制文件”。\n  if (SetClipboardData(preferred_drop_effect_format, drop_effect_handle) == nullptr) {\n    free_global(drop_effect_handle);\n    return std::unexpected(\"Failed to set clipboard drop effect, Win32 error: \" +\n                           std::to_string(Vendor::Windows::GetLastError()));\n  }\n  drop_effect_handle = nullptr;\n\n  return {};\n}\n\nauto read_clipboard_text() -> std::expected<std::optional<std::string>, std::string> {\n  if (!OpenClipboard(nullptr)) {\n    return std::unexpected(\"Failed to open clipboard, Win32 error: \" +\n                           std::to_string(Vendor::Windows::GetLastError()));\n  }\n\n  struct ClipboardCloser {\n    ~ClipboardCloser() { CloseClipboard(); }\n  } clipboard_closer;\n\n  if (!IsClipboardFormatAvailable(CF_UNICODETEXT)) {\n    return std::optional<std::string>{std::nullopt};\n  }\n\n  auto handle = GetClipboardData(CF_UNICODETEXT);\n  if (!handle) {\n    return std::unexpected(\"Failed to get clipboard text handle, Win32 error: \" +\n                           std::to_string(Vendor::Windows::GetLastError()));\n  }\n\n  auto* text_ptr = static_cast<const wchar_t*>(GlobalLock(handle));\n  if (!text_ptr) {\n    return std::unexpected(\"Failed to lock clipboard text, Win32 error: \" +\n                           std::to_string(Vendor::Windows::GetLastError()));\n  }\n\n  struct GlobalUnlockGuard {\n    HGLOBAL handle;\n    ~GlobalUnlockGuard() {\n      if (handle) {\n        GlobalUnlock(handle);\n      }\n    }\n  } unlock_guard{handle};\n\n  std::wstring wide_text(text_ptr);\n  return std::optional<std::string>{Utils::String::ToUtf8(wide_text)};\n}\n\nauto move_files_to_recycle_bin(const std::vector<std::filesystem::path>& paths)\n    -> std::expected<void, std::string> {\n  if (paths.empty()) {\n    return {};\n  }\n\n  std::wstring from_buffer;\n  for (const auto& path : paths) {\n    if (path.empty()) {\n      continue;\n    }\n\n    from_buffer.append(path.wstring());\n    from_buffer.push_back(L'\\0');\n  }\n\n  if (from_buffer.empty()) {\n    return {};\n  }\n\n  // SHFileOperation 要求双 \\0 结尾的路径列表\n  from_buffer.push_back(L'\\0');\n\n  Vendor::ShellApi::SHFILEOPSTRUCTW file_op{};\n  file_op.wFunc = Vendor::ShellApi::kFO_DELETE;\n  file_op.pFrom = from_buffer.c_str();\n  file_op.fFlags = Vendor::ShellApi::kFOF_ALLOWUNDO | Vendor::ShellApi::kFOF_NOCONFIRMATION |\n                   Vendor::ShellApi::kFOF_NOERRORUI | Vendor::ShellApi::kFOF_SILENT;\n\n  auto result = Vendor::ShellApi::SHFileOperationW(&file_op);\n  if (result != 0) {\n    return std::unexpected(\"Failed to move files to recycle bin, shell error: \" +\n                           std::to_string(result));\n  }\n\n  if (file_op.fAnyOperationsAborted != FALSE) {\n    return std::unexpected(\"Move to recycle bin was aborted\");\n  }\n\n  return {};\n}\n\n// 单实例互斥锁名称\nconstexpr auto kMutexName = L\"Global\\\\SpinningMomo_SingleInstance_Mutex\";\n// 窗口类名\nconstexpr auto kWindowClassName = L\"SpinningMomoFloatingWindowClass\";\n\n// 全局互斥锁句柄\nstatic HANDLE g_instance_mutex = nullptr;\n\n// 单实例检测：尝试获取单实例锁\n[[nodiscard]] auto acquire_single_instance_lock() noexcept -> bool {\n  g_instance_mutex = CreateMutexW(nullptr, FALSE, kMutexName);\n\n  if (g_instance_mutex == nullptr) {\n    // 创建失败，假定已有实例\n    return false;\n  }\n\n  if (GetLastError() == ERROR_ALREADY_EXISTS) {\n    // 互斥锁已存在，说明已有实例在运行\n    CloseHandle(g_instance_mutex);\n    g_instance_mutex = nullptr;\n    return false;\n  }\n\n  // 成功获取锁，当前是第一个实例\n  return true;\n}\n\nauto release_single_instance_lock() noexcept -> void {\n  if (g_instance_mutex != nullptr) {\n    CloseHandle(g_instance_mutex);\n    g_instance_mutex = nullptr;\n  }\n}\n\n// 激活已运行的实例窗口\nauto activate_existing_instance() noexcept -> void {\n  // 查找已运行实例的窗口\n  HWND hwnd = FindWindowW(kWindowClassName, nullptr);\n  if (hwnd) {\n    // 发送自定义消息，让已有实例自己显示窗口\n    // 这样可以绕过 UIPI 限制（高权限窗口已允许接收此消息）\n    PostMessageW(hwnd, WM_SPINNINGMOMO_SHOW, 0, 0);\n  }\n}\n\n}  // namespace Utils::System\n"
  },
  {
    "path": "src/utils/system/system.ixx",
    "content": "module;\n\nexport module Utils.System;\n\nimport std;\n\nnamespace Utils::System {\n\n// Windows 系统版本信息结构体\nexport struct WindowsVersionInfo {\n  unsigned long major_version = 0;\n  unsigned long minor_version = 0;\n  unsigned long build_number = 0;\n  unsigned long platform_id = 0;\n};\n\n// 获取当前 Windows 系统版本信息\nexport [[nodiscard]] auto get_windows_version() noexcept\n    -> std::expected<WindowsVersionInfo, std::string>;\n\n// 根据 WindowsVersionInfo 获取系统名称\nexport [[nodiscard]] auto get_windows_name(const WindowsVersionInfo& version) noexcept\n    -> std::string;\n\n// 检测当前进程是否以管理员权限运行\nexport [[nodiscard]] auto is_process_elevated() noexcept -> bool;\n\n// 以管理员权限重启当前应用程序\n// 返回 true 表示成功启动新进程（当前进程应退出）\n// 返回 false 表示用户取消或启动失败\nexport [[nodiscard]] auto restart_as_elevated(const wchar_t* arguments = nullptr) noexcept -> bool;\n\n// 在资源管理器中打开目录\nexport auto open_directory(const std::filesystem::path& path) -> std::expected<void, std::string>;\n\n// 使用系统默认应用打开文件\nexport auto open_file_with_default_app(const std::filesystem::path& path)\n    -> std::expected<void, std::string>;\n\n// 在资源管理器中显示并选中文件\nexport auto reveal_file_in_explorer(const std::filesystem::path& path)\n    -> std::expected<void, std::string>;\n\nexport auto copy_files_to_clipboard(const std::vector<std::filesystem::path>& paths)\n    -> std::expected<void, std::string>;\n\n// 读取系统剪贴板中的纯文本内容（UTF-8）\nexport auto read_clipboard_text() -> std::expected<std::optional<std::string>, std::string>;\n\n// 将文件移动到系统回收站\nexport auto move_files_to_recycle_bin(const std::vector<std::filesystem::path>& paths)\n    -> std::expected<void, std::string>;\n\n// 单实例检测：尝试获取单实例锁\n// 返回 true 表示成功获取锁（当前是第一个实例）\n// 返回 false 表示已有实例在运行\nexport [[nodiscard]] auto acquire_single_instance_lock() noexcept -> bool;\n\n// 释放单实例锁（若当前进程持有）\nexport auto release_single_instance_lock() noexcept -> void;\n\n// 激活已运行的实例窗口\nexport auto activate_existing_instance() noexcept -> void;\n\n// 自定义消息：通知已运行实例显示窗口\nexport constexpr unsigned int WM_SPINNINGMOMO_SHOW = 0x8000 + 100;  // 跨进程消息范围\n\n}  // namespace Utils::System\n"
  },
  {
    "path": "src/utils/throttle/throttle.ixx",
    "content": "module;\n\nexport module Utils.Throttle;\n\nimport std;\n\nexport namespace Utils::Throttle {\n\n// 节流状态（带参数版本）\ntemplate <typename... Args>\nstruct ThrottleState {\n  std::chrono::milliseconds interval{16};                // 节流间隔，默认约60fps\n  std::chrono::steady_clock::time_point last_call_time;  // 上次执行时间\n  bool has_pending{false};                               // 是否有待处理的调用\n  std::tuple<Args...> pending_args;                      // 待处理的参数\n  std::mutex mutex;                                      // 线程安全\n};\n\n// 节流状态（无参数特化版本）\ntemplate <>\nstruct ThrottleState<void> {\n  std::chrono::milliseconds interval{16};\n  std::chrono::steady_clock::time_point last_call_time;\n  bool has_pending{false};\n  std::mutex mutex;\n};\n\n// 创建节流状态\ntemplate <typename... Args>\nauto create(std::chrono::milliseconds interval) -> std::unique_ptr<ThrottleState<Args...>> {\n  auto state = std::make_unique<ThrottleState<Args...>>();\n  state->interval = interval;\n  state->last_call_time = std::chrono::steady_clock::time_point{};  // epoch，允许立即首次调用\n  return state;\n}\n\n// 创建节流状态（无参数版本）\ntemplate <>\nauto create<void>(std::chrono::milliseconds interval) -> std::unique_ptr<ThrottleState<void>> {\n  auto state = std::make_unique<ThrottleState<void>>();\n  state->interval = interval;\n  state->last_call_time = std::chrono::steady_clock::time_point{};\n  return state;\n}\n\n// 检查是否可以立即执行（仅检查时间，不修改状态）\ntemplate <typename... Args>\nauto can_call(const ThrottleState<Args...>& state) -> bool {\n  // 注意：这里读取不是线程安全的，仅用于快速检查\n  auto now = std::chrono::steady_clock::now();\n  auto elapsed = now - state.last_call_time;\n  return elapsed >= state.interval;\n}\n\n// 重置节流状态\ntemplate <typename... Args>\nauto reset(ThrottleState<Args...>& state) -> void {\n  std::lock_guard lock(state.mutex);\n  state.has_pending = false;\n  state.last_call_time = std::chrono::steady_clock::time_point{};\n}\n\n// 节流调用（带参数版本）\n// 返回 true 表示本次调用被执行，false 表示被节流跳过（已缓存参数）\ntemplate <typename Func, typename... Args>\nauto call(ThrottleState<Args...>& state, Func&& func, Args... args) -> bool {\n  std::lock_guard lock(state.mutex);\n\n  auto now = std::chrono::steady_clock::now();\n  auto elapsed = now - state.last_call_time;\n\n  if (elapsed >= state.interval) {\n    // 满足间隔，立即执行\n    state.last_call_time = now;\n    state.has_pending = false;\n    std::forward<Func>(func)(args...);\n    return true;\n  } else {\n    // 间隔不足，缓存参数（Leading Edge executed, Trailing Edge scheduled）\n    state.has_pending = true;\n    state.pending_args = std::make_tuple(args...);\n    return false;\n  }\n}\n\n// 节流调用（无参数版本）\ntemplate <typename Func>\nauto call(ThrottleState<void>& state, Func&& func) -> bool {\n  std::lock_guard lock(state.mutex);\n\n  auto now = std::chrono::steady_clock::now();\n  auto elapsed = now - state.last_call_time;\n\n  if (elapsed >= state.interval) {\n    state.last_call_time = now;\n    state.has_pending = false;\n    std::forward<Func>(func)();\n    return true;\n  } else {\n    state.has_pending = true;\n    return false;\n  }\n}\n\n// 强制执行待处理的调用 (Trailing Edge)\n// 如果有 pending 调用，则执行它并清空 pending 标志\n// 返回 true 表示执行了 pending 调用，false 表示没有 pending\ntemplate <typename Func, typename... Args>\nauto flush(ThrottleState<Args...>& state, Func&& func) -> bool {\n  std::lock_guard lock(state.mutex);\n\n  if (!state.has_pending) {\n    return false;\n  }\n\n  // 执行缓存的参数\n  std::apply(std::forward<Func>(func), state.pending_args);\n\n  state.has_pending = false;\n  state.last_call_time = std::chrono::steady_clock::now();\n  return true;\n}\n\n// 强制执行待处理的调用（无参数版本）\ntemplate <typename Func>\nauto flush(ThrottleState<void>& state, Func&& func) -> bool {\n  std::lock_guard lock(state.mutex);\n\n  if (!state.has_pending) {\n    return false;\n  }\n\n  std::forward<Func>(func)();\n\n  state.has_pending = false;\n  state.last_call_time = std::chrono::steady_clock::now();\n  return true;\n}\n\n}  // namespace Utils::Throttle\n"
  },
  {
    "path": "src/utils/time.ixx",
    "content": "module;\n\nexport module Utils.Time;\n\nimport std;\nimport Vendor.Windows;\n\nnamespace Utils::Time {\n\n// 获取当前毫秒时间戳\nexport auto current_millis() -> std::int64_t {\n  return std::chrono::duration_cast<std::chrono::milliseconds>(\n             std::chrono::system_clock::now().time_since_epoch())\n      .count();\n}\n\n// file_time_type 转 system_clock time_point\nexport auto file_time_to_system_clock(const std::filesystem::file_time_type& file_time)\n    -> std::chrono::system_clock::time_point {\n  return std::chrono::clock_cast<std::chrono::system_clock>(file_time);\n}\n\n// 文件时间转换为毫秒时间戳\nexport auto file_time_to_millis(const std::filesystem::file_time_type& file_time) -> std::int64_t {\n  auto system_time = file_time_to_system_clock(file_time);\n  return std::chrono::duration_cast<std::chrono::milliseconds>(system_time.time_since_epoch())\n      .count();\n}\n\n// 文件时间转换为秒时间戳\nexport auto file_time_to_seconds(const std::filesystem::file_time_type& file_time) -> std::int64_t {\n  auto system_time = file_time_to_system_clock(file_time);\n  return std::chrono::duration_cast<std::chrono::seconds>(system_time.time_since_epoch()).count();\n}\n\n// 获取文件创建时间的毫秒时间戳\nexport auto get_file_creation_time_millis(const std::filesystem::path& file_path)\n    -> std::expected<std::int64_t, std::string> {\n  Vendor::Windows::WIN32_FILE_ATTRIBUTE_DATA fileAttr;\n\n  if (!Vendor::Windows::GetFileAttributesExW(file_path.c_str(),\n                                             Vendor::Windows::kGetFileExInfoStandard, &fileAttr)) {\n    Vendor::Windows::DWORD error = Vendor::Windows::GetLastError();\n    return std::unexpected(std::format(\"Failed to get file attributes: {}\", error));\n  }\n\n  // 转换创建时间\n  Vendor::Windows::FILETIME& creationTime = fileAttr.ftCreationTime;\n\n  Vendor::Windows::ULARGE_INTEGER ull;\n  ull.LowPart = creationTime.dwLowDateTime;\n  ull.HighPart = creationTime.dwHighDateTime;\n\n  // Windows FILETIME 转 Unix 毫秒时间戳\n  constexpr std::uint64_t EPOCH_DIFF = 116444736000000000ULL;  // 1970-1601 差值（100纳秒）\n  const std::uint64_t unix_time_100ns = ull.QuadPart - EPOCH_DIFF;\n  const std::uint64_t unix_time_millis = unix_time_100ns / 10000;  // 转换为毫秒\n\n  return static_cast<std::int64_t>(unix_time_millis);\n}\n\n}  // namespace Utils::Time\n"
  },
  {
    "path": "src/utils/timer/timeout.cpp",
    "content": "module;\n\n#include <windows.h>\n\nmodule Utils.Timeout;\n\nimport std;\nimport Utils.Logger;\n\nnamespace Utils::Timeout {\n\nstruct Timeout::shared_state {\n  std::mutex mutex;\n  std::function<void()> callback;\n  // true 表示存在待触发任务；触发/取消后置回 false。\n  std::atomic<bool> pending{false};\n};\n\nauto CALLBACK Timeout::threadpool_callback(PTP_CALLBACK_INSTANCE, PVOID context, PTP_TIMER)\n    -> void {\n  auto* shared = static_cast<shared_state*>(context);\n  std::function<void()> callback;\n  {\n    std::lock_guard lock(shared->mutex);\n    // 回调触发时先把状态切换为非 pending，再搬运回调，避免竞态下重复执行。\n    callback = shared->callback;\n    shared->pending.store(false, std::memory_order_release);\n    shared->callback = nullptr;\n  }\n\n  if (callback) {\n    try {\n      callback();\n    } catch (...) {\n      Logger().error(\"Exception in timeout callback\");\n    }\n  }\n}\n\nTimeout::Timeout() {\n  m_shared = std::make_unique<shared_state>();\n  (void)create_timer_object();\n}\n\nTimeout::~Timeout() {\n  cancel();\n  if (m_timer) {\n    CloseThreadpoolTimer(m_timer);\n    m_timer = nullptr;\n  }\n  m_shared.reset();\n}\n\nauto Timeout::create_timer_object() -> std::expected<void, timeout_error> {\n  if (m_timer) {\n    return {};\n  }\n\n  auto* timer = CreateThreadpoolTimer(Timeout::threadpool_callback, m_shared.get(), nullptr);\n  if (!timer) {\n    Logger().error(\"Failed to create threadpool timer\");\n    return std::unexpected(timeout_error::create_timer_failed);\n  }\n\n  m_timer = timer;\n  return {};\n}\n\nauto Timeout::set_timeout(std::chrono::milliseconds delay, std::function<void()> callback)\n    -> std::expected<void, timeout_error> {\n  if (!callback) {\n    return std::unexpected(timeout_error::invalid_callback);\n  }\n\n  if (auto create_result = create_timer_object(); !create_result) {\n    return create_result;\n  }\n\n  // 与 JS setTimeout 一致：同一实例再次设置时覆盖上一次任务。\n  cancel();\n\n  {\n    std::lock_guard lock(m_shared->mutex);\n    m_shared->callback = std::move(callback);\n    m_shared->pending.store(true, std::memory_order_release);\n  }\n\n  ULONGLONG delay_100ns = static_cast<ULONGLONG>(delay.count()) * 10000ULL;\n  FILETIME due_time{};\n  ULARGE_INTEGER due{};\n  due.QuadPart = static_cast<ULONGLONG>(0) - delay_100ns;\n  due_time.dwLowDateTime = due.LowPart;\n  due_time.dwHighDateTime = due.HighPart;\n\n  // period=0 表示一次性定时器。\n  SetThreadpoolTimer(m_timer, &due_time, 0, 0);\n  return {};\n}\n\nauto Timeout::cancel() -> void {\n  if (!m_timer) {\n    if (m_shared) {\n      std::lock_guard lock(m_shared->mutex);\n      m_shared->pending.store(false, std::memory_order_release);\n      m_shared->callback = nullptr;\n    }\n    return;\n  }\n\n  // 先取消未来触发，再等待可能正在执行的回调结束，确保析构安全。\n  SetThreadpoolTimer(m_timer, nullptr, 0, 0);\n  WaitForThreadpoolTimerCallbacks(m_timer, TRUE);\n\n  std::lock_guard lock(m_shared->mutex);\n  m_shared->pending.store(false, std::memory_order_release);\n  m_shared->callback = nullptr;\n}\n\nauto Timeout::is_pending() const -> bool {\n  if (!m_shared) {\n    return false;\n  }\n  return m_shared->pending.load(std::memory_order_acquire);\n}\n\n}  // namespace Utils::Timeout\n"
  },
  {
    "path": "src/utils/timer/timeout.ixx",
    "content": "module;\n\nexport module Utils.Timeout;\n\nimport std;\nimport <windows.h>;\n\nnamespace Utils::Timeout {\n\n// 超时错误类型：只保留当前可明确上报的错误。\nenum class timeout_error { invalid_callback, create_timer_failed };\n\nexport class Timeout {\n public:\n  Timeout();\n  ~Timeout();\n\n  Timeout(const Timeout&) = delete;\n  Timeout& operator=(const Timeout&) = delete;\n  Timeout(Timeout&&) = delete;\n  Timeout& operator=(Timeout&&) = delete;\n\n  // 设置一次性延迟任务（等价于 setTimeout）。\n  // 若已有 pending 任务，会先取消再覆盖。\n  auto set_timeout(std::chrono::milliseconds delay, std::function<void()> callback)\n      -> std::expected<void, timeout_error>;\n  // 取消当前待触发任务；可重复调用（幂等）。\n  auto cancel() -> void;\n  // 是否存在尚未触发的延迟任务。\n  auto is_pending() const -> bool;\n\n private:\n  struct shared_state;\n  // Windows 线程池回调入口：在系统线程池线程执行。\n  static auto CALLBACK threadpool_callback(PTP_CALLBACK_INSTANCE instance, PVOID context,\n                                           PTP_TIMER timer) -> void;\n  std::unique_ptr<shared_state> m_shared;\n  PTP_TIMER m_timer = nullptr;\n\n  auto create_timer_object() -> std::expected<void, timeout_error>;\n};\n\n}  // namespace Utils::Timeout\n"
  },
  {
    "path": "src/vendor/build_config.ixx",
    "content": "module;\n\nexport module Vendor.BuildConfig;\n\nnamespace Vendor::BuildConfig {\n\nexport constexpr bool is_debug_build() noexcept {\n#ifdef NDEBUG\n  return false;\n#else\n  return true;\n#endif\n}\n\n}  // namespace Vendor::BuildConfig"
  },
  {
    "path": "src/vendor/shellapi.ixx",
    "content": "module;\r\n\r\n#include <windows.h>  // 必须放在最前面\r\n\r\n#include <shellapi.h>\r\n#include <shlobj_core.h>\r\n\r\nexport module Vendor.ShellApi;\r\n\r\nnamespace Vendor::ShellApi {\r\n\r\n// 导出 Shell API 相关类型\r\nexport using NOTIFYICONDATAW = ::NOTIFYICONDATAW;\r\nexport using PNOTIFYICONDATAW = ::PNOTIFYICONDATAW;\r\nexport using DROPFILES = ::DROPFILES;\r\n\r\n// 导出函数\r\nexport auto Shell_NotifyIconW(DWORD dwMessage, PNOTIFYICONDATAW lpData) -> BOOL {\r\n  return ::Shell_NotifyIconW(dwMessage, lpData);\r\n}\r\n\r\n// 导出常量 (使用 k 前缀风格，保持原命名大小写)\r\n// For dwMessage parameter of Shell_NotifyIconW\r\nexport constexpr auto kNIM_ADD = NIM_ADD;\r\nexport constexpr auto kNIM_DELETE = NIM_DELETE;\r\n\r\n// For uFlags member of NOTIFYICONDATAW\r\nexport constexpr auto kNIF_ICON = NIF_ICON;\r\nexport constexpr auto kNIF_MESSAGE = NIF_MESSAGE;\r\nexport constexpr auto kNIF_TIP = NIF_TIP;\r\n\r\n// ShellExecute 相关类型和常量\r\nexport using SHELLEXECUTEINFOW = ::SHELLEXECUTEINFOW;\r\nexport constexpr auto kSEE_MASK_NOCLOSEPROCESS = SEE_MASK_NOCLOSEPROCESS;\r\nexport constexpr auto kSEE_MASK_NOASYNC = SEE_MASK_NOASYNC;\r\nexport constexpr auto kSW_HIDE = SW_HIDE;\r\nexport constexpr auto kSW_SHOWNORMAL = SW_SHOWNORMAL;\r\n\r\n// ShellExecute 函数\r\nexport auto ShellExecuteExW(SHELLEXECUTEINFOW* lpExecInfo) -> BOOL {\r\n  return ::ShellExecuteExW(lpExecInfo);\r\n}\r\n\r\n// SHFileOperation 相关类型和常量\r\nexport using SHFILEOPSTRUCTW = ::SHFILEOPSTRUCTW;\r\nexport using LPSHFILEOPSTRUCTW = ::LPSHFILEOPSTRUCTW;\r\nexport constexpr auto kCF_HDROP = CF_HDROP;\r\nexport constexpr auto kFO_DELETE = FO_DELETE;\r\nexport constexpr auto kFOF_ALLOWUNDO = FOF_ALLOWUNDO;\r\nexport constexpr auto kFOF_NOCONFIRMATION = FOF_NOCONFIRMATION;\r\nexport constexpr auto kFOF_NOERRORUI = FOF_NOERRORUI;\r\nexport constexpr auto kFOF_SILENT = FOF_SILENT;\r\n\r\nexport auto SHFileOperationW(LPSHFILEOPSTRUCTW lpFileOp) -> int {\r\n  return ::SHFileOperationW(lpFileOp);\r\n}\r\n\r\n// 常用文件夹 ID\r\nexport const auto& kFOLDERID_LocalAppData = FOLDERID_LocalAppData;\r\nexport const auto& kFOLDERID_Videos = FOLDERID_Videos;\r\n\r\n// SHGetKnownFolderPath 函数\r\nexport auto SHGetKnownFolderPath(REFKNOWNFOLDERID rfid, DWORD dwFlags, HANDLE hToken,\r\n                                 PWSTR* ppszPath) -> HRESULT {\r\n  return ::SHGetKnownFolderPath(rfid, dwFlags, hToken, ppszPath);\r\n}\r\n\r\n// CoTaskMemFree 函数\r\nexport auto CoTaskMemFree(LPVOID pv) -> void { ::CoTaskMemFree(pv); }\r\n\r\n}  // namespace Vendor::ShellApi\r\n"
  },
  {
    "path": "src/vendor/version.ixx",
    "content": "module;\n\nexport module Vendor.Version;\n\nimport std;\n\nnamespace Vendor::Version {\n\nexport auto get_app_version() -> std::string { return \"2.0.8.0\"; }\n\n}  // namespace Vendor::Version\n"
  },
  {
    "path": "src/vendor/wil.ixx",
    "content": "module;\n\n#include <wil/result.h>\n\nexport module Vendor.WIL;\n\nnamespace Vendor::WIL {\n\n// Modern error handling - replaces THROW_IF_FAILED macro\nexport auto throw_if_failed(HRESULT hr) -> void {\n  if (FAILED(hr)) {\n    throw wil::ResultException(hr);\n  }\n}\n\n}  // namespace Vendor::WIL\n"
  },
  {
    "path": "src/vendor/windows.ixx",
    "content": "module;\n\n#include <windows.h>\n\nexport module Vendor.Windows;\n\nnamespace Vendor::Windows {\n\n// Types\nexport using BOOL = ::BOOL;\nexport using DWORD = ::DWORD;\nexport using UINT = ::UINT;\nexport using LANGID = ::LANGID;\nexport using WPARAM = ::WPARAM;\nexport using LPARAM = ::LPARAM;\nexport using LRESULT = ::LRESULT;\nexport using HWND = ::HWND;\nexport using HHOOK = ::HHOOK;\nexport using HINSTANCE = ::HINSTANCE;\nexport using LPWSTR = ::LPWSTR;\nexport using LPCWSTR = ::LPCWSTR;\nexport using POINT = ::POINT;\nexport using RECT = ::RECT;\nexport using SIZE = ::SIZE;\nexport using MSG = ::MSG;\nexport using OSVERSIONINFOEXW = ::OSVERSIONINFOEXW;\nexport using LPOSVERSIONINFOW = ::LPOSVERSIONINFOW;\nexport using WIN32_FILE_ATTRIBUTE_DATA = ::WIN32_FILE_ATTRIBUTE_DATA;\nexport using FILETIME = ::FILETIME;\nexport using ULARGE_INTEGER = ::ULARGE_INTEGER;\nexport using LPVOID = ::LPVOID;\nexport using GET_FILEEX_INFO_LEVELS = ::GET_FILEEX_INFO_LEVELS;\nexport using HANDLE = ::HANDLE;\nexport using HRESULT = ::HRESULT;\n\n// Constants\nexport constexpr UINT kMB_ICONERROR = MB_ICONERROR;\nexport constexpr UINT kWM_USER = WM_USER;\nexport constexpr UINT kWM_QUIT = WM_QUIT;\nexport constexpr UINT kPM_REMOVE = PM_REMOVE;\nexport constexpr DWORD kQS_ALLINPUT = QS_ALLINPUT;\nexport constexpr DWORD kMWMO_INPUTAVAILABLE = MWMO_INPUTAVAILABLE;\nexport constexpr UINT kMOD_CONTROL = MOD_CONTROL;\nexport constexpr UINT kMOD_ALT = MOD_ALT;\nexport constexpr UINT kSWP_NOZORDER = SWP_NOZORDER;\nexport constexpr UINT kSWP_NOACTIVATE = SWP_NOACTIVATE;\nexport constexpr GET_FILEEX_INFO_LEVELS kGetFileExInfoStandard = ::GetFileExInfoStandard;\nexport constexpr DWORD kMAX_PATH = MAX_PATH;\nexport constexpr DWORD kERROR_CANCELLED = ERROR_CANCELLED;\n\n// System metrics\nexport auto GetSystemMetrics(int nIndex) -> int { return ::GetSystemMetrics(nIndex); }\nexport auto GetScreenWidth() -> int { return ::GetSystemMetrics(SM_CXSCREEN); }\nexport auto GetScreenHeight() -> int { return ::GetSystemMetrics(SM_CYSCREEN); }\nexport auto GetUserDefaultUILanguage() -> LANGID { return ::GetUserDefaultUILanguage(); }\n\n// Message box\nexport auto MessageBoxW(HWND hWnd, const wchar_t* lpText, const wchar_t* lpCaption, UINT uType)\n    -> int {\n  return ::MessageBoxW(hWnd, lpText, lpCaption, uType);\n}\nexport auto MessageBoxA(HWND hWnd, const char* lpText, const char* lpCaption, UINT uType) -> int {\n  return ::MessageBoxA(hWnd, lpText, lpCaption, uType);\n}\n\n// Message loop\nexport auto GetWindowMessage(MSG* lpMsg, HWND hWnd, UINT wMsgFilterMin, UINT wMsgFilterMax)\n    -> BOOL {\n  return ::GetMessageW(lpMsg, hWnd, wMsgFilterMin, wMsgFilterMax);\n}\nexport auto PeekMessageW(MSG* lpMsg, HWND hWnd, UINT wMsgFilterMin, UINT wMsgFilterMax,\n                         UINT wRemoveMsg) -> BOOL {\n  return ::PeekMessageW(lpMsg, hWnd, wMsgFilterMin, wMsgFilterMax, wRemoveMsg);\n}\nexport auto TranslateWindowMessage(const MSG* lpMsg) -> BOOL { return ::TranslateMessage(lpMsg); }\nexport auto DispatchWindowMessageW(const MSG* lpMsg) -> LRESULT {\n  return ::DispatchMessageW(lpMsg);\n}\n\n// Window operations\nexport auto GetForegroundWindow() -> HWND { return ::GetForegroundWindow(); }\nexport auto GetWindowRect(HWND hWnd, RECT* lpRect) -> BOOL { return ::GetWindowRect(hWnd, lpRect); }\nexport auto SetWindowPos(HWND hWnd, HWND hWndInsertAfter, int x, int y, int cx, int cy, UINT uFlags)\n    -> BOOL {\n  return ::SetWindowPos(hWnd, hWndInsertAfter, x, y, cx, cy, uFlags);\n}\nexport auto InvalidateRect(HWND hWnd, const RECT* lpRect, BOOL bErase) -> BOOL {\n  return ::InvalidateRect(hWnd, lpRect, bErase);\n}\n\n// Application control\nexport auto PostQuitMessage(int nExitCode) -> void { ::PostQuitMessage(nExitCode); }\nexport auto GetCurrentProcessId() -> DWORD { return ::GetCurrentProcessId(); }\n\n// Synchronization\nexport auto MsgWaitForMultipleObjectsEx(DWORD nCount, const HANDLE* pHandles, DWORD dwMilliseconds,\n                                        DWORD dwWakeMask, DWORD dwFlags) -> DWORD {\n  return ::MsgWaitForMultipleObjectsEx(nCount, pHandles, dwMilliseconds, dwWakeMask, dwFlags);\n}\n\n// File operations\nexport auto GetFileAttributesExW(LPCWSTR lpFileName, GET_FILEEX_INFO_LEVELS fInfoLevelId,\n                                 LPVOID lpFileInformation) -> BOOL {\n  return ::GetFileAttributesExW(lpFileName, fInfoLevelId, lpFileInformation);\n}\n\n// INI configuration\nexport auto GetPrivateProfileStringW(LPCWSTR lpAppName, LPCWSTR lpKeyName, LPCWSTR lpDefault,\n                                     LPWSTR lpReturnedString, DWORD nSize, LPCWSTR lpFileName)\n    -> DWORD {\n  return ::GetPrivateProfileStringW(lpAppName, lpKeyName, lpDefault, lpReturnedString, nSize,\n                                    lpFileName);\n}\n\n// Error handling\nexport auto GetLastError() -> DWORD { return ::GetLastError(); }\n\n// Handle operations\nexport auto CloseHandle(HANDLE hObject) -> BOOL { return ::CloseHandle(hObject); }\n\n// HRESULT handling functions (replacing macros)\nexport constexpr auto _HRESULT_FROM_WIN32(DWORD x) -> HRESULT {\n  return static_cast<HRESULT>(x) <= 0\n             ? static_cast<HRESULT>(x)\n             : static_cast<HRESULT>((x & 0x0000FFFF) | (0x7 << 16) | 0x80000000);\n}\n\nexport constexpr auto _SUCCEEDED(HRESULT hr) -> bool { return SUCCEEDED(hr); }\n\nexport constexpr auto _FAILED(HRESULT hr) -> bool { return FAILED(hr); }\n\n}  // namespace Vendor::Windows\n"
  },
  {
    "path": "src/vendor/winhttp.ixx",
    "content": "module;\n\n#include <windows.h>\n#include <winhttp.h>\n\nexport module Vendor.WinHttp;\n\nimport std;\n\nnamespace Vendor::WinHttp {\n\n// 导出 WinHTTP 相关类型\nexport using HINTERNET = ::HINTERNET;\nexport using URL_COMPONENTS = ::URL_COMPONENTS;\nexport using DWORD = ::DWORD;\nexport using INTERNET_PORT = ::INTERNET_PORT;\nexport using BOOL = ::BOOL;\nexport using DWORD_PTR = ::DWORD_PTR;\nexport using LPVOID = ::LPVOID;\nexport using WINHTTP_STATUS_CALLBACK = ::WINHTTP_STATUS_CALLBACK;\nexport using WINHTTP_ASYNC_RESULT = ::WINHTTP_ASYNC_RESULT;\n\n// 导出 WinHTTP 函数\nexport auto WinHttpOpen(const wchar_t* pszAgentW, DWORD dwAccessType, const wchar_t* pszProxyW,\n                        const wchar_t* pszProxyBypassW, DWORD dwFlags) -> HINTERNET {\n  return ::WinHttpOpen(pszAgentW, dwAccessType, pszProxyW, pszProxyBypassW, dwFlags);\n}\n\nexport auto WinHttpCrackUrl(const wchar_t* pwszUrl, DWORD dwUrlLength, DWORD dwFlags,\n                            URL_COMPONENTS* lpUrlComponents) -> BOOL {\n  return ::WinHttpCrackUrl(pwszUrl, dwUrlLength, dwFlags, lpUrlComponents);\n}\n\nexport auto WinHttpConnect(HINTERNET hSession, const wchar_t* pswzServerName,\n                           INTERNET_PORT nServerPort, DWORD dwReserved) -> HINTERNET {\n  return ::WinHttpConnect(hSession, pswzServerName, nServerPort, dwReserved);\n}\n\nexport auto WinHttpOpenRequest(HINTERNET hConnect, const wchar_t* pwszVerb,\n                               const wchar_t* pwszObjectName, const wchar_t* pwszVersion,\n                               const wchar_t* pwszReferrer, const wchar_t** ppwszAcceptTypes,\n                               DWORD dwFlags) -> HINTERNET {\n  return ::WinHttpOpenRequest(hConnect, pwszVerb, pwszObjectName, pwszVersion, pwszReferrer,\n                              ppwszAcceptTypes, dwFlags);\n}\n\nexport auto WinHttpSendRequest(HINTERNET hRequest, const wchar_t* lpszHeaders,\n                               DWORD dwHeadersLength, void* lpOptional, DWORD dwOptionalLength,\n                               DWORD dwTotalLength, DWORD_PTR dwContext) -> BOOL {\n  return ::WinHttpSendRequest(hRequest, lpszHeaders, dwHeadersLength, lpOptional, dwOptionalLength,\n                              dwTotalLength, dwContext);\n}\n\nexport auto WinHttpReceiveResponse(HINTERNET hRequest, void* lpReserved) -> BOOL {\n  return ::WinHttpReceiveResponse(hRequest, lpReserved);\n}\n\nexport auto WinHttpQueryHeaders(HINTERNET hRequest, DWORD dwInfoLevel, const wchar_t* pwszName,\n                                void* lpBuffer, DWORD* lpdwBufferLength, DWORD* lpdwIndex) -> BOOL {\n  return ::WinHttpQueryHeaders(hRequest, dwInfoLevel, pwszName, lpBuffer, lpdwBufferLength,\n                               lpdwIndex);\n}\n\nexport auto WinHttpQueryDataAvailable(HINTERNET hRequest, DWORD* lpdwNumberOfBytesAvailable)\n    -> BOOL {\n  return ::WinHttpQueryDataAvailable(hRequest, lpdwNumberOfBytesAvailable);\n}\n\nexport auto WinHttpReadData(HINTERNET hRequest, void* lpBuffer, DWORD dwNumberOfBytesToRead,\n                            DWORD* lpdwNumberOfBytesRead) -> BOOL {\n  return ::WinHttpReadData(hRequest, lpBuffer, dwNumberOfBytesToRead, lpdwNumberOfBytesRead);\n}\n\nexport auto WinHttpCloseHandle(HINTERNET hInternet) -> BOOL {\n  return ::WinHttpCloseHandle(hInternet);\n}\n\nexport auto WinHttpSetStatusCallback(HINTERNET hInternet,\n                                     WINHTTP_STATUS_CALLBACK lpfnInternetCallback,\n                                     DWORD dwNotificationFlags, DWORD_PTR dwReserved)\n    -> WINHTTP_STATUS_CALLBACK {\n  return ::WinHttpSetStatusCallback(hInternet, lpfnInternetCallback, dwNotificationFlags,\n                                    dwReserved);\n}\n\nexport auto WinHttpSetOption(HINTERNET hInternet, DWORD dwOption, LPVOID lpBuffer,\n                             DWORD dwBufferLength) -> BOOL {\n  return ::WinHttpSetOption(hInternet, dwOption, lpBuffer, dwBufferLength);\n}\n\nexport auto WinHttpSetTimeouts(HINTERNET hInternet, int nResolveTimeout, int nConnectTimeout,\n                               int nSendTimeout, int nReceiveTimeout) -> BOOL {\n  return ::WinHttpSetTimeouts(hInternet, nResolveTimeout, nConnectTimeout, nSendTimeout,\n                              nReceiveTimeout);\n}\n\nexport struct UniqueHInternet {\n  HINTERNET handle = nullptr;\n\n  UniqueHInternet() = default;\n  explicit UniqueHInternet(HINTERNET h) : handle(h) {}\n  ~UniqueHInternet() {\n    if (handle) ::WinHttpCloseHandle(handle);\n  }\n  UniqueHInternet(const UniqueHInternet&) = delete;\n  UniqueHInternet& operator=(const UniqueHInternet&) = delete;\n  UniqueHInternet(UniqueHInternet&& o) noexcept : handle(std::exchange(o.handle, nullptr)) {}\n  UniqueHInternet& operator=(UniqueHInternet&& o) noexcept {\n    if (this != &o) {\n      if (handle) ::WinHttpCloseHandle(handle);\n      handle = std::exchange(o.handle, nullptr);\n    }\n    return *this;\n  }\n  explicit operator bool() const { return handle != nullptr; }\n  auto get() const -> HINTERNET { return handle; }\n};\n\n// 导出常量 (使用 k 前缀风格)\n// Access types\nexport constexpr auto kWINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY = WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY;\n\n// Proxy constants\nexport constexpr const wchar_t* kWINHTTP_NO_PROXY_NAME = WINHTTP_NO_PROXY_NAME;\nexport constexpr const wchar_t* kWINHTTP_NO_PROXY_BYPASS = WINHTTP_NO_PROXY_BYPASS;\nexport constexpr auto kWINHTTP_FLAG_ASYNC = WINHTTP_FLAG_ASYNC;\n\n// Request constants\nexport constexpr const wchar_t* kWINHTTP_NO_REFERER = WINHTTP_NO_REFERER;\nexport constexpr const wchar_t** kWINHTTP_DEFAULT_ACCEPT_TYPES = WINHTTP_DEFAULT_ACCEPT_TYPES;\nexport constexpr auto kWINHTTP_FLAG_SECURE = WINHTTP_FLAG_SECURE;\n\n// Header constants\nexport constexpr const wchar_t* kWINHTTP_NO_ADDITIONAL_HEADERS = WINHTTP_NO_ADDITIONAL_HEADERS;\nexport constexpr void* kWINHTTP_NO_REQUEST_DATA = WINHTTP_NO_REQUEST_DATA;\n\n// Query constants\nexport constexpr auto kWINHTTP_QUERY_STATUS_CODE = WINHTTP_QUERY_STATUS_CODE;\nexport constexpr auto kWINHTTP_QUERY_FLAG_NUMBER = WINHTTP_QUERY_FLAG_NUMBER;\nexport constexpr const wchar_t* kWINHTTP_HEADER_NAME_BY_INDEX = WINHTTP_HEADER_NAME_BY_INDEX;\nexport constexpr auto kWINHTTP_QUERY_RAW_HEADERS_CRLF = WINHTTP_QUERY_RAW_HEADERS_CRLF;\n// Note: WINHTTP_NO_HEADER_INDEX is NULL, so we just use nullptr directly\n\n// Async callback flags\nexport constexpr auto kWINHTTP_CALLBACK_STATUS_SENDREQUEST_COMPLETE =\n    WINHTTP_CALLBACK_STATUS_SENDREQUEST_COMPLETE;\nexport constexpr auto kWINHTTP_CALLBACK_STATUS_HEADERS_AVAILABLE =\n    WINHTTP_CALLBACK_STATUS_HEADERS_AVAILABLE;\nexport constexpr auto kWINHTTP_CALLBACK_STATUS_DATA_AVAILABLE =\n    WINHTTP_CALLBACK_STATUS_DATA_AVAILABLE;\nexport constexpr auto kWINHTTP_CALLBACK_STATUS_READ_COMPLETE =\n    WINHTTP_CALLBACK_STATUS_READ_COMPLETE;\nexport constexpr auto kWINHTTP_CALLBACK_STATUS_REQUEST_ERROR =\n    WINHTTP_CALLBACK_STATUS_REQUEST_ERROR;\nexport constexpr auto kWINHTTP_CALLBACK_STATUS_HANDLE_CLOSING =\n    WINHTTP_CALLBACK_STATUS_HANDLE_CLOSING;\nexport constexpr auto kWINHTTP_CALLBACK_FLAG_ALL_COMPLETIONS =\n    WINHTTP_CALLBACK_FLAG_ALL_COMPLETIONS;\nexport constexpr auto kWINHTTP_CALLBACK_FLAG_HANDLES = WINHTTP_CALLBACK_FLAG_HANDLES;\nexport constexpr auto kWINHTTP_CALLBACK_FLAG_REDIRECT = WINHTTP_CALLBACK_FLAG_REDIRECT;\nexport inline auto kWINHTTP_INVALID_STATUS_CALLBACK = WINHTTP_INVALID_STATUS_CALLBACK;\n\n// Option constants\nexport constexpr auto kWINHTTP_OPTION_CONTEXT_VALUE = WINHTTP_OPTION_CONTEXT_VALUE;\n\n// Common error constants\nexport constexpr auto kERROR_IO_PENDING = ERROR_IO_PENDING;\n\n// URL scheme constants\nexport constexpr auto kINTERNET_SCHEME_HTTPS = INTERNET_SCHEME_HTTPS;\n\n}  // namespace Vendor::WinHttp\n"
  },
  {
    "path": "src/vendor/xxhash.ixx",
    "content": "module;\n\n#include <xxhash.h>\n\n#include <string>\n#include <vector>\n#include <format>\n\nexport module Vendor.XXHash;\n\nnamespace Vendor::XXHash {\n\n// 计算字符向量的哈希值并返回16进制字符串 - scanner.cpp中实际使用的函数\nexport auto HashCharVectorToHex(const std::vector<char>& data) -> std::string {\n  auto hash = XXH3_64bits(data.data(), data.size());\n  return std::format(\"{:016x}\", hash);\n}\n\n}  // namespace Vendor::XXHash"
  },
  {
    "path": "tasks/build-all.lua",
    "content": "-- 构建完整的发布版本（包含Web应用）\ntask(\"build-all\")\n    set_menu {\n        usage = \"xmake build-all\",\n        description = \"Build release version with web app\"\n    }\n    \n    on_run(function ()\n        import(\"core.project.config\")\n        import(\"core.project.project\")\n        \n        -- 1. 构建release版本\n        print(\"Building release version...\")\n        os.exec(\"xmake config -m release\")\n        os.exec(\"xmake build\")\n        \n        -- 2. 构建web应用\n        print(\"Building web app...\")\n        local old_dir = os.curdir()\n        os.cd(\"web\")\n        \n        if os.host() == \"windows\" then\n            os.exec(\"npm.cmd run build\")\n        else\n            os.exec(\"npm run build\")\n        end\n        \n        os.cd(old_dir)\n        \n        -- 3. 复制web资源\n        print(\"Copying web resources...\")\n        local web_dist = path.join(os.projectdir(), \"web/dist\")\n        config.load()\n        local target = project.target(\"SpinningMomo\")\n        local outputdir = target:targetdir()\n        local web_target = path.join(outputdir, \"resources/web\")\n        \n        os.mkdir(path.join(outputdir, \"resources\"))\n        os.cp(web_dist .. \"/*\", web_target)\n        \n        print(\"Build completed: \" .. outputdir)\n    end)\n"
  },
  {
    "path": "tasks/release.lua",
    "content": "-- 构建release版本并智能恢复配置\ntask(\"release\")\n    set_menu {\n        usage = \"xmake release\",\n        description = \"Build in release mode and auto restore debug config\"\n    }\n    \n    on_run(function ()\n        import(\"core.project.config\")\n        \n        -- 获取当前配置状态\n        config.load()\n        local current_mode = config.get(\"mode\")\n        local should_restore = (current_mode == \"debug\")\n        \n        -- 构建release版本\n        os.exec(\"xmake config -m release\")\n        local ok = os.exec(\"xmake build\")\n        \n        if ok == 0 and should_restore then\n            os.exec(\"xmake config -m debug\")\n        end\n        \n    end)\n"
  },
  {
    "path": "tasks/vs.lua",
    "content": "-- 生成Visual Studio项目文件\ntask(\"vs\")\n    set_menu {\n        usage = \"xmake vs\",\n        description = \"Generate Visual Studio project files for debug and release modes\"\n    }\n    \n    on_run(function ()\n        print(\"Generating Visual Studio project files...\")\n        os.exec(\"xmake project -k vsxmake -m \\\"debug,release\\\"\")\n        print(\"Visual Studio project files generated successfully!\")\n    end)"
  },
  {
    "path": "version.json",
    "content": "{\n  \"version\": \"2.0.8\"\n}\n"
  },
  {
    "path": "web/.gitignore",
    "content": "# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\nlerna-debug.log*\n\nnode_modules\ndist\ndist-ssr\n*.local\n\n# Editor directories and files\n.vscode/*\n!.vscode/extensions.json\n.idea\n.DS_Store\n*.suo\n*.ntvs*\n*.njsproj\n*.sln\n*.sw?\n"
  },
  {
    "path": "web/.prettierrc.json",
    "content": "{\n    \"semi\": false,\n    \"singleQuote\": true,\n    \"jsxSingleQuote\": true,\n    \"trailingComma\": \"es5\",\n    \"printWidth\": 100,\n    \"tabWidth\": 2,\n    \"useTabs\": false,\n    \"bracketSpacing\": true,\n    \"arrowParens\": \"always\",\n    \"plugins\": [\"prettier-plugin-tailwindcss\"],\n    \"tailwindStylesheet\": \"./src/index.css\"\n  }"
  },
  {
    "path": "web/.vscode/extensions.json",
    "content": "{\n  \"recommendations\": [\"Vue.volar\"]\n}\n"
  },
  {
    "path": "web/README.md",
    "content": "# Vue 3 + TypeScript + Vite\n\nThis template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.\n\nLearn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).\n"
  },
  {
    "path": "web/components.json",
    "content": "{\n  \"$schema\": \"https://shadcn-vue.com/schema.json\",\n  \"style\": \"new-york\",\n  \"typescript\": true,\n  \"tailwind\": {\n    \"config\": \"\",\n    \"css\": \"src/index.css\",\n    \"baseColor\": \"neutral\",\n    \"cssVariables\": true,\n    \"prefix\": \"\"\n  },\n  \"iconLibrary\": \"lucide\",\n  \"aliases\": {\n    \"components\": \"@/components\",\n    \"utils\": \"@/lib/utils\",\n    \"ui\": \"@/components/ui\",\n    \"lib\": \"@/lib\",\n    \"composables\": \"@/composables\"\n  },\n  \"registries\": {}\n}\n"
  },
  {
    "path": "web/index.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <link rel=\"icon\" type=\"image/png\" href=\"/logo_192x192.png\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <title>web</title>\n  </head>\n  <body>\n    <div id=\"app\"></div>\n    <script type=\"module\" src=\"/src/main.ts\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "web/package.json",
    "content": "{\n  \"name\": \"web\",\n  \"private\": true,\n  \"license\": \"GPL-3.0\",\n  \"version\": \"0.0.0\",\n  \"type\": \"module\",\n  \"scripts\": {\n    \"dev\": \"vite\",\n    \"build\": \"vue-tsc -b && vite build\",\n    \"preview\": \"vite preview\"\n  },\n  \"dependencies\": {\n    \"@fontsource-variable/inter\": \"^5.2.8\",\n    \"@tailwindcss/vite\": \"^4.1.14\",\n    \"@tanstack/vue-virtual\": \"^3.13.12\",\n    \"@vueuse/core\": \"^13.9.0\",\n    \"class-variance-authority\": \"^0.7.1\",\n    \"clsx\": \"^2.1.1\",\n    \"lucide-vue-next\": \"^0.545.0\",\n    \"pinia\": \"^3.0.3\",\n    \"reka-ui\": \"^2.9.0\",\n    \"tailwind-merge\": \"^3.3.1\",\n    \"tailwindcss\": \"^4.1.14\",\n    \"tw-animate-css\": \"^1.4.0\",\n    \"vue\": \"^3.5.22\",\n    \"vue-router\": \"^4.5.1\",\n    \"vue-sonner\": \"^2.0.9\"\n  },\n  \"devDependencies\": {\n    \"@types/node\": \"^24.6.0\",\n    \"@vitejs/plugin-vue\": \"^6.0.1\",\n    \"@vue/tsconfig\": \"^0.8.1\",\n    \"prettier\": \"^3.6.2\",\n    \"prettier-plugin-tailwindcss\": \"^0.6.14\",\n    \"typescript\": \"~5.9.3\",\n    \"vite\": \"npm:rolldown-vite@7.1.14\",\n    \"vite-bundle-analyzer\": \"^1.2.3\",\n    \"vue-tsc\": \"^3.1.0\"\n  },\n  \"overrides\": {\n    \"vite\": \"npm:rolldown-vite@7.1.14\"\n  }\n}\n"
  },
  {
    "path": "web/src/App.vue",
    "content": "<script setup lang=\"ts\">\nimport AppLayout from '@/components/layout/AppLayout.vue'\n</script>\n\n<template>\n  <AppLayout />\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "web/src/components/WindowTitlePickerButton.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed, ref } from 'vue'\nimport { Monitor } from 'lucide-vue-next'\nimport { Button } from '@/components/ui/button'\nimport { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'\nimport { ScrollArea } from '@/components/ui/scroll-area'\nimport { call } from '@/core/rpc'\nimport { cn } from '@/lib/utils'\nimport { useI18n } from '@/composables/useI18n'\n\ninterface VisibleWindowTitleResult {\n  title: string\n}\n\nconst props = defineProps<{\n  disabled?: boolean\n  buttonClass?: string\n}>()\n\nconst emit = defineEmits<{\n  (e: 'select', title: string): void\n}>()\n\nconst { t } = useI18n()\n\nconst isOpen = ref(false)\nconst isLoading = ref(false)\nconst loadFailed = ref(false)\nconst visibleWindows = ref<VisibleWindowTitleResult[]>([])\n\nconst buttonClasses = computed(() => cn('shrink-0', props.buttonClass))\n\nconst shouldQuoteWindowTitle = (title: string) => title.trim() !== title\n\nconst formatWindowTitle = (title: string) => (shouldQuoteWindowTitle(title) ? `\"${title}\"` : title)\n\nconst loadVisibleWindows = async () => {\n  isLoading.value = true\n  loadFailed.value = false\n\n  try {\n    visibleWindows.value = await call<VisibleWindowTitleResult[]>(\n      'windowControl.listVisibleWindows',\n      {}\n    )\n  } catch (error) {\n    visibleWindows.value = []\n    loadFailed.value = true\n    console.error('Failed to list visible windows:', error)\n  } finally {\n    isLoading.value = false\n  }\n}\n\nconst handleOpenChange = (nextOpen: boolean) => {\n  isOpen.value = nextOpen\n  if (nextOpen) {\n    void loadVisibleWindows()\n  }\n}\n\nconst handleSelect = (title: string) => {\n  emit('select', title)\n  isOpen.value = false\n}\n</script>\n\n<template>\n  <Popover :open=\"isOpen\" @update:open=\"handleOpenChange\">\n    <PopoverTrigger as-child>\n      <Button\n        type=\"button\"\n        variant=\"outline\"\n        size=\"icon-sm\"\n        :disabled=\"disabled\"\n        :class=\"buttonClasses\"\n        :title=\"t('common.windowTitlePicker.trigger')\"\n      >\n        <Monitor class=\"size-4\" />\n      </Button>\n    </PopoverTrigger>\n\n    <PopoverContent align=\"end\" class=\"w-80 max-w-[calc(100vw-2rem)] p-1 pr-0\">\n      <div class=\"flex flex-col\">\n        <div v-if=\"isLoading\" class=\"px-2 py-2 text-sm text-muted-foreground\">\n          {{ t('common.windowTitlePicker.loading') }}\n        </div>\n\n        <div v-else-if=\"loadFailed\" class=\"flex items-center justify-between gap-2 px-2 py-2\">\n          <span class=\"text-sm text-muted-foreground\">\n            {{ t('common.windowTitlePicker.loadFailed') }}\n          </span>\n          <Button type=\"button\" variant=\"ghost\" size=\"sm\" @click=\"void loadVisibleWindows()\">\n            {{ t('common.windowTitlePicker.retry') }}\n          </Button>\n        </div>\n\n        <div\n          v-else-if=\"visibleWindows.length === 0\"\n          class=\"px-2 py-2 text-sm text-muted-foreground\"\n        >\n          {{ t('common.windowTitlePicker.empty') }}\n        </div>\n\n        <ScrollArea v-else class=\"max-h-64\">\n          <div>\n            <button\n              v-for=\"window in visibleWindows\"\n              :key=\"window.title\"\n              type=\"button\"\n              class=\"flex w-full rounded-md px-2 py-2 text-left text-sm transition-colors hover:bg-accent hover:text-accent-foreground\"\n              @click=\"handleSelect(window.title)\"\n            >\n              <span class=\"break-all whitespace-pre-wrap text-foreground\">\n                {{ formatWindowTitle(window.title) }}\n              </span>\n            </button>\n          </div>\n        </ScrollArea>\n      </div>\n    </PopoverContent>\n  </Popover>\n</template>\n"
  },
  {
    "path": "web/src/components/layout/ActivityBar.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed } from 'vue'\nimport { useRoute, useRouter } from 'vue-router'\nimport { Home, Images, Map, Settings, Info } from 'lucide-vue-next'\nimport {\n  Sidebar,\n  SidebarContent,\n  SidebarGroup,\n  SidebarGroupContent,\n  SidebarMenu,\n  SidebarMenuItem,\n  SidebarMenuButton,\n} from '@/components/ui/sidebar'\nimport { pushWithViewTransition } from '@/router/viewTransition'\n\ninterface MenuItem {\n  key: string\n  title: string\n  icon: any\n}\n\nwithDefaults(\n  defineProps<{\n    isHome?: boolean\n  }>(),\n  {\n    isHome: false,\n  }\n)\n\nconst baseMenuItems: (MenuItem | { type: 'divider' })[] = [\n  { title: '主页', key: 'home', icon: Home },\n  { title: '图库', key: 'gallery', icon: Images },\n  { title: '地图', key: 'map', icon: Map },\n  { title: '设置', key: 'settings', icon: Settings },\n  { type: 'divider' },\n  { title: '关于', key: 'about', icon: Info },\n]\n\nconst menuItems = computed(() => {\n  if (import.meta.env.PROD) {\n    return baseMenuItems.filter((item) => {\n      if ('key' in item) {\n        return item.key !== 'map'\n      }\n      return true\n    })\n  }\n  return baseMenuItems\n})\n\nconst route = useRoute()\nconst router = useRouter()\n\nconst activeKey = computed(() => {\n  const firstSegment = route.path.split('/')[1]\n  return firstSegment || 'home'\n})\n\nconst handleMenuSelect = (key: string) => {\n  void pushWithViewTransition(router, `/${key}`)\n}\n</script>\n\n<template>\n  <Sidebar\n    collapsible=\"none\"\n    :class=\"['w-14 bg-transparent pt-8', isHome && 'activity-bar-background-blur']\"\n  >\n    <SidebarContent>\n      <SidebarGroup>\n        <SidebarGroupContent class=\"px-0\">\n          <SidebarMenu>\n            <template\n              v-for=\"(item, index) in menuItems\"\n              :key=\"'key' in item ? item.key : `divider-${index}`\"\n            >\n              <!-- Divider -->\n              <div v-if=\"'type' in item && item.type === 'divider'\" class=\"mx-2 my-3\" />\n\n              <!-- Menu Item -->\n              <SidebarMenuItem v-else-if=\"'key' in item\">\n                <SidebarMenuButton\n                  :tooltip=\"item.title\"\n                  show-tooltip-when-expanded\n                  tooltip-variant=\"sidebar\"\n                  :is-active=\"activeKey === item.key\"\n                  @click=\"handleMenuSelect(item.key)\"\n                  class=\"h-10 w-10 [&>svg]:mx-auto [&>svg]:h-5 [&>svg]:w-5 [&>svg]:transition-colors [&[data-active=true]>svg]:text-primary\"\n                >\n                  <component :is=\"item.icon\" :stroke-width=\"1.8\" />\n                </SidebarMenuButton>\n              </SidebarMenuItem>\n            </template>\n          </SidebarMenu>\n        </SidebarGroupContent>\n      </SidebarGroup>\n    </SidebarContent>\n  </Sidebar>\n</template>\n"
  },
  {
    "path": "web/src/components/layout/AppHeader.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed, nextTick, ref, watch } from 'vue'\nimport { useRoute } from 'vue-router'\nimport { useGalleryLayout } from '@/features/gallery/composables'\nimport { useI18n } from '@/composables/useI18n'\nimport { useToast } from '@/composables/useToast'\nimport { call } from '@/core/rpc'\nimport { useTaskStore } from '@/core/tasks/store'\nimport { isWebView } from '@/core/env'\nimport { Button } from '@/components/ui/button'\nimport { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuTrigger,\n} from '@/components/ui/dropdown-menu'\nimport {\n  CircleAlert,\n  CircleCheck,\n  ChevronDown,\n  ChevronUp,\n  ListTodo,\n  Loader2,\n  Minus,\n  PanelLeftClose,\n  PanelLeftOpen,\n  PanelRightClose,\n  PanelRightOpen,\n  Square,\n  Trash2,\n  X,\n} from 'lucide-vue-next'\n\nconst route = useRoute()\nconst { t } = useI18n()\nconst { toast } = useToast()\nconst taskStore = useTaskStore()\nconst showWindowControls = isWebView()\nconst isGalleryPage = computed(() => route.name === 'gallery')\nconst displayTasks = computed(() => taskStore.tasks)\nconst activeTaskCount = computed(() => taskStore.activeTasks.length)\nconst finishedTaskCount = computed(\n  () =>\n    taskStore.tasks.filter((item) => item.status !== 'queued' && item.status !== 'running').length\n)\nconst hasFinishedTasks = computed(() => finishedTaskCount.value > 0)\nconst hasTaskRecords = computed(() => taskStore.tasks.length > 0)\nconst taskButtonText = computed(() => {\n  if (activeTaskCount.value > 0) {\n    return t('app.header.tasks.activeCount', { count: activeTaskCount.value })\n  }\n  return t('app.header.tasks.button')\n})\nconst taskPanelSummaryText = computed(() => {\n  if (activeTaskCount.value > 0) {\n    return t('app.header.tasks.activeCount', { count: activeTaskCount.value })\n  }\n\n  return t('app.header.tasks.recordCount', { count: displayTasks.value.length })\n})\nconst isTaskMenuOpen = ref(false)\nconst isClearingFinished = ref(false)\nconst expandedTaskIds = ref<Record<string, boolean>>({})\nconst overflowingTaskIds = ref<Record<string, boolean>>({})\nconst taskMessageElements = new Map<string, HTMLElement>()\n\nconst { isSidebarOpen, isDetailsOpen, toggleSidebar, toggleDetails } = useGalleryLayout()\n\nconst handleMinimize = () => {\n  call('webview.minimize').catch((err) => {\n    console.error('Failed to minimize window:', err)\n  })\n}\n\nconst handleMaximizeToggle = () => {\n  call('webview.toggleMaximize').catch((err) => {\n    console.error('Failed to toggle maximize window:', err)\n  })\n}\n\nconst handleClose = () => {\n  call('webview.close').catch((err) => {\n    console.error('Failed to close window:', err)\n  })\n}\n\nconst handleToggleSidebar = () => {\n  toggleSidebar()\n}\n\nconst handleToggleDetails = () => {\n  toggleDetails()\n}\n\nfunction resolveTaskTypeLabel(type: string): string {\n  if (type === 'gallery.scanDirectory') {\n    return t('app.header.tasks.type.galleryScan')\n  }\n  if (type === 'update.download') {\n    return t('app.header.tasks.type.updateDownload')\n  }\n  if (type === 'extensions.infinityNikki.initialScan') {\n    return t('app.header.tasks.type.infinityNikkiInitialScan')\n  }\n  if (type === 'extensions.infinityNikki.extractPhotoParams') {\n    return t('app.header.tasks.type.infinityNikkiExtractPhotoParams')\n  }\n  if (type === 'extensions.infinityNikki.initializeScreenshotHardlinks') {\n    return t('app.header.tasks.type.infinityNikkiInitScreenshotHardlinks')\n  }\n  return type\n}\n\nfunction resolveTaskStatusLabel(status: string): string {\n  if (status === 'queued') {\n    return t('app.header.tasks.status.queued')\n  }\n  if (status === 'running') {\n    return t('app.header.tasks.status.running')\n  }\n  if (status === 'succeeded') {\n    return t('app.header.tasks.status.succeeded')\n  }\n  if (status === 'failed') {\n    return t('app.header.tasks.status.failed')\n  }\n  if (status === 'cancelled') {\n    return t('app.header.tasks.status.cancelled')\n  }\n  return status\n}\n\nfunction resolveTaskPercent(task: {\n  progress?: { percent?: number }\n  status: string\n}): number | null {\n  const value = task.progress?.percent\n  if (typeof value === 'number' && Number.isFinite(value)) {\n    return Math.max(0, Math.min(100, Math.round(value)))\n  }\n  if (task.status === 'succeeded') {\n    return 100\n  }\n  return null\n}\n\nfunction resolveTaskMessage(task: {\n  status: string\n  errorMessage?: string\n  progress?: { message?: string }\n}): string | null {\n  if (task.status === 'failed' && task.errorMessage) {\n    return task.errorMessage\n  }\n\n  return task.progress?.message ?? null\n}\n\nfunction setTaskMessageRef(taskId: string, element: unknown): void {\n  if (element instanceof HTMLElement) {\n    taskMessageElements.set(taskId, element)\n    return\n  }\n\n  taskMessageElements.delete(taskId)\n}\n\nfunction isTaskMessageExpanded(taskId: string): boolean {\n  return expandedTaskIds.value[taskId] === true\n}\n\nfunction isTaskMessageExpandable(taskId: string): boolean {\n  return overflowingTaskIds.value[taskId] === true\n}\n\nfunction toggleTaskMessageExpanded(taskId: string): void {\n  expandedTaskIds.value = {\n    ...expandedTaskIds.value,\n    [taskId]: !isTaskMessageExpanded(taskId),\n  }\n}\n\nfunction pruneTaskUiState(): void {\n  const activeTaskIds = new Set(displayTasks.value.map((task) => task.taskId))\n\n  expandedTaskIds.value = Object.fromEntries(\n    Object.entries(expandedTaskIds.value).filter(([taskId]) => activeTaskIds.has(taskId))\n  )\n  overflowingTaskIds.value = Object.fromEntries(\n    Object.entries(overflowingTaskIds.value).filter(([taskId]) => activeTaskIds.has(taskId))\n  )\n\n  for (const taskId of taskMessageElements.keys()) {\n    if (!activeTaskIds.has(taskId)) {\n      taskMessageElements.delete(taskId)\n    }\n  }\n}\n\nasync function measureOverflowingTaskMessages(): Promise<void> {\n  if (!isTaskMenuOpen.value) {\n    return\n  }\n\n  await nextTick()\n\n  const nextOverflowingTaskIds: Record<string, boolean> = {}\n  for (const task of displayTasks.value) {\n    const message = resolveTaskMessage(task)\n    if (task.status !== 'failed' || !message) {\n      continue\n    }\n\n    if (isTaskMessageExpanded(task.taskId)) {\n      nextOverflowingTaskIds[task.taskId] = overflowingTaskIds.value[task.taskId] ?? false\n      continue\n    }\n\n    const element = taskMessageElements.get(task.taskId)\n    nextOverflowingTaskIds[task.taskId] =\n      element?.scrollHeight !== undefined\n        ? element.scrollHeight > element.clientHeight + 1\n        : (overflowingTaskIds.value[task.taskId] ?? false)\n  }\n\n  overflowingTaskIds.value = nextOverflowingTaskIds\n}\n\nasync function handleClearFinished(): Promise<void> {\n  if (isClearingFinished.value || !hasFinishedTasks.value) {\n    return\n  }\n\n  isClearingFinished.value = true\n  try {\n    await taskStore.clearFinished()\n    pruneTaskUiState()\n  } catch (error) {\n    const message = error instanceof Error ? error.message : String(error)\n    toast.error(t('app.header.tasks.clearFailedTitle'), {\n      description: message,\n    })\n  } finally {\n    isClearingFinished.value = false\n  }\n}\n\nwatch(\n  displayTasks,\n  () => {\n    pruneTaskUiState()\n    if (isTaskMenuOpen.value) {\n      void measureOverflowingTaskMessages()\n    }\n  },\n  { deep: true }\n)\n\nwatch(\n  [isTaskMenuOpen, expandedTaskIds],\n  ([isOpen]) => {\n    if (!isOpen) {\n      return\n    }\n\n    void measureOverflowingTaskMessages()\n  },\n  { deep: true }\n)\n</script>\n\n<template>\n  <header class=\"flex h-10 items-center justify-between gap-2 bg-transparent pr-1 pl-4\">\n    <!-- 可拖动区域 -->\n    <div class=\"mt-1.5 h-full flex-1\">\n      <div class=\"drag-region h-full\" />\n    </div>\n\n    <!-- 图库布局控制 -->\n    <div v-if=\"isGalleryPage\" class=\"flex gap-1\">\n      <Button\n        variant=\"ghost\"\n        size=\"icon\"\n        class=\"h-8 w-8 hover:bg-black/10 dark:hover:bg-white/10\"\n        :class=\"[!isSidebarOpen && 'text-muted-foreground']\"\n        :title=\"\n          isSidebarOpen\n            ? t('app.header.gallery.toggleSidebar.hide')\n            : t('app.header.gallery.toggleSidebar.show')\n        \"\n        @click=\"handleToggleSidebar\"\n      >\n        <component :is=\"isSidebarOpen ? PanelLeftClose : PanelLeftOpen\" class=\"h-4 w-4\" />\n      </Button>\n\n      <Button\n        variant=\"ghost\"\n        size=\"icon\"\n        class=\"h-8 w-8 hover:bg-black/10 dark:hover:bg-white/10\"\n        :class=\"[!isDetailsOpen && 'text-muted-foreground']\"\n        :title=\"\n          isDetailsOpen\n            ? t('app.header.gallery.toggleDetails.hide')\n            : t('app.header.gallery.toggleDetails.show')\n        \"\n        @click=\"handleToggleDetails\"\n      >\n        <component :is=\"isDetailsOpen ? PanelRightClose : PanelRightOpen\" class=\"h-4 w-4\" />\n      </Button>\n    </div>\n\n    <div v-if=\"hasTaskRecords\" class=\"flex items-center\">\n      <DropdownMenu v-model:open=\"isTaskMenuOpen\">\n        <DropdownMenuTrigger as-child>\n          <Button variant=\"ghost\" size=\"sm\" class=\"h-8 hover:bg-black/10 dark:hover:bg-white/10\">\n            <Loader2 v-if=\"activeTaskCount > 0\" class=\"mr-1.5 h-3.5 w-3.5 animate-spin\" />\n            <ListTodo v-else class=\"mr-1.5 h-3.5 w-3.5 opacity-70\" />\n            <span class=\"text-xs\">{{ taskButtonText }}</span>\n            <ChevronDown class=\"ml-1 h-3.5 w-3.5 opacity-70\" />\n          </Button>\n        </DropdownMenuTrigger>\n        <DropdownMenuContent align=\"end\" class=\"w-[28rem] p-0\">\n          <div class=\"flex items-center justify-between gap-3 border-b border-border/60 px-3 py-2\">\n            <div class=\"min-w-0\">\n              <p class=\"text-sm font-medium text-foreground\">{{ t('app.header.tasks.button') }}</p>\n              <p class=\"text-[11px] text-muted-foreground\">\n                {{ taskPanelSummaryText }}\n              </p>\n            </div>\n            <Button\n              variant=\"ghost\"\n              size=\"sm\"\n              class=\"h-7 px-2 text-[11px]\"\n              :disabled=\"!hasFinishedTasks || isClearingFinished\"\n              @click=\"handleClearFinished\"\n            >\n              <Loader2 v-if=\"isClearingFinished\" class=\"mr-1.5 h-3 w-3 animate-spin\" />\n              <Trash2 v-else class=\"mr-1.5 h-3 w-3\" />\n              {{ t('app.header.tasks.clearFinished') }}\n            </Button>\n          </div>\n\n          <div v-if=\"displayTasks.length === 0\" class=\"px-3 py-4 text-xs text-muted-foreground\">\n            {{ t('app.header.tasks.none') }}\n          </div>\n\n          <ScrollArea v-else type=\"always\" class=\"max-h-[28rem]\">\n            <template #scrollbar>\n              <ScrollBar\n                class=\"w-3 p-[1px]\"\n                thumb-class=\"bg-muted-foreground/35 hover:bg-muted-foreground/50\"\n              />\n            </template>\n            <div class=\"space-y-2 p-2\">\n              <div\n                v-for=\"task in displayTasks\"\n                :key=\"task.taskId\"\n                class=\"rounded-md border border-border/60 p-2\"\n              >\n                <div class=\"flex items-center justify-between gap-2\">\n                  <span class=\"truncate text-xs font-medium text-foreground\">{{\n                    resolveTaskTypeLabel(task.type)\n                  }}</span>\n                  <span\n                    class=\"inline-flex items-center gap-1 text-[11px]\"\n                    :class=\"[\n                      task.status === 'failed'\n                        ? 'text-destructive'\n                        : task.status === 'succeeded'\n                          ? 'text-emerald-600'\n                          : 'text-muted-foreground',\n                    ]\"\n                  >\n                    <Loader2\n                      v-if=\"task.status === 'queued' || task.status === 'running'\"\n                      class=\"h-3 w-3 animate-spin\"\n                    />\n                    <CircleCheck v-else-if=\"task.status === 'succeeded'\" class=\"h-3 w-3\" />\n                    <CircleAlert v-else class=\"h-3 w-3\" />\n                    {{ resolveTaskStatusLabel(task.status) }}\n                  </span>\n                </div>\n\n                <p\n                  v-if=\"task.context\"\n                  class=\"mt-1 text-[11px] leading-4 break-all text-muted-foreground\"\n                >\n                  {{ task.context }}\n                </p>\n\n                <div v-if=\"resolveTaskMessage(task)\" class=\"mt-1 flex items-start gap-2\">\n                  <p\n                    :ref=\"(element) => setTaskMessageRef(task.taskId, element)\"\n                    class=\"min-w-0 flex-1 text-[11px] leading-4 break-words\"\n                    :class=\"[\n                      task.status === 'failed' ? 'text-destructive' : 'text-muted-foreground',\n                      isTaskMessageExpanded(task.taskId) ? 'line-clamp-none' : 'line-clamp-1',\n                    ]\"\n                  >\n                    {{ resolveTaskMessage(task) }}\n                  </p>\n                  <Button\n                    v-if=\"task.status === 'failed' && isTaskMessageExpandable(task.taskId)\"\n                    variant=\"ghost\"\n                    size=\"sm\"\n                    class=\"h-5 px-1.5 text-[10px] text-muted-foreground\"\n                    @click.stop=\"toggleTaskMessageExpanded(task.taskId)\"\n                  >\n                    {{\n                      isTaskMessageExpanded(task.taskId)\n                        ? t('app.header.tasks.collapse')\n                        : t('app.header.tasks.expand')\n                    }}\n                    <component\n                      :is=\"isTaskMessageExpanded(task.taskId) ? ChevronUp : ChevronDown\"\n                      class=\"ml-1 h-3 w-3\"\n                    />\n                  </Button>\n                </div>\n\n                <div v-if=\"resolveTaskPercent(task) !== null\" class=\"mt-2 space-y-1\">\n                  <div class=\"h-1.5 overflow-hidden rounded-full bg-muted\">\n                    <div\n                      class=\"h-full transition-all\"\n                      :class=\"task.status === 'failed' ? 'bg-destructive' : 'bg-primary'\"\n                      :style=\"{ width: `${resolveTaskPercent(task)}%` }\"\n                    />\n                  </div>\n                  <div class=\"text-right text-[10px] text-muted-foreground\">\n                    {{ resolveTaskPercent(task) }}%\n                  </div>\n                </div>\n              </div>\n            </div>\n          </ScrollArea>\n        </DropdownMenuContent>\n      </DropdownMenu>\n    </div>\n\n    <!-- 窗口控制按钮 -->\n    <div v-if=\"showWindowControls\" class=\"flex gap-2\">\n      <Button\n        variant=\"ghost\"\n        size=\"icon\"\n        class=\"h-8 w-8 text-foreground hover:bg-black/10 dark:hover:bg-white/10\"\n        @click=\"handleMinimize\"\n        title=\"Minimize\"\n      >\n        <Minus class=\"h-4 w-4\" />\n      </Button>\n\n      <Button\n        variant=\"ghost\"\n        size=\"icon\"\n        class=\"h-8 w-8 text-foreground hover:bg-black/10 dark:hover:bg-white/10\"\n        @click=\"handleMaximizeToggle\"\n        title=\"Maximize / Restore\"\n      >\n        <Square class=\"h-4 w-4\" />\n      </Button>\n\n      <Button\n        variant=\"ghost\"\n        size=\"icon\"\n        class=\"h-8 w-8 text-foreground hover:bg-destructive hover:text-destructive-foreground\"\n        @click=\"handleClose\"\n        title=\"Close\"\n      >\n        <X class=\"h-4 w-4\" />\n      </Button>\n    </div>\n  </header>\n</template>\n\n<style scoped>\n.drag-region {\n  -webkit-app-region: drag;\n  app-region: drag;\n}\n</style>\n"
  },
  {
    "path": "web/src/components/layout/AppLayout.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed } from 'vue'\nimport { useRoute } from 'vue-router'\nimport { useSettingsStore } from '@/features/settings/store'\nimport { resolveBackgroundImageUrl } from '@/features/settings/backgroundPath'\nimport { SidebarProvider } from '@/components/ui/sidebar'\nimport { Toaster } from '@/components/ui/sonner'\nimport ActivityBar from './ActivityBar.vue'\nimport AppHeader from './AppHeader.vue'\nimport ContentArea from './ContentArea.vue'\nimport GalleryDebugOverlay from './GalleryDebugOverlay.vue'\nimport WindowResizeOverlay from './WindowResizeOverlay.vue'\nimport 'vue-sonner/style.css'\n\nconst route = useRoute()\nconst settingsStore = useSettingsStore()\nconst isDev = import.meta.env.DEV\nconst isWelcome = computed(() => route.name === 'welcome')\nconst isHome = computed(() => route.name === 'home')\nconst hasBackgroundImage = computed(() =>\n  Boolean(resolveBackgroundImageUrl(settingsStore.appSettings.ui.background))\n)\n</script>\n\n<template>\n  <SidebarProvider>\n    <div class=\"relative h-screen w-screen overflow-hidden bg-transparent\">\n      <WindowResizeOverlay />\n\n      <div class=\"pointer-events-none absolute inset-0 z-0\">\n        <div\n          class=\"app-background-image absolute inset-0\"\n          :class=\"[isHome && 'app-background-image-no-blur']\"\n        />\n        <div\n          class=\"app-background-overlay absolute inset-0\"\n          :class=\"[isHome && hasBackgroundImage && 'app-background-overlay-home-clip']\"\n        />\n      </div>\n\n      <div class=\"relative z-10 flex h-full w-full flex-row\">\n        <!-- 左侧 ActivityBar -->\n        <ActivityBar v-if=\"!isWelcome\" :is-home=\"isHome\" />\n\n        <!-- 右侧：Header + 主内容区 -->\n        <div\n          class=\"relative flex min-h-0 flex-1 flex-col overflow-hidden rounded-lg text-foreground\"\n          :class=\"[!isHome && !isWelcome && 'surface-middle']\"\n        >\n          <!-- 窗口控制栏 -->\n          <AppHeader />\n          <!-- 主内容区域 -->\n          <div class=\"relative z-10 min-h-0 flex-1 overflow-auto\">\n            <ContentArea />\n          </div>\n        </div>\n      </div>\n      <GalleryDebugOverlay v-if=\"isDev\" />\n    </div>\n\n    <!-- Toast 通知 -->\n    <Toaster position=\"bottom-right\" />\n  </SidebarProvider>\n</template>\n"
  },
  {
    "path": "web/src/components/layout/ContentArea.vue",
    "content": "<script setup lang=\"ts\">\nimport MapIframeHost from '@/features/map/components/MapIframeHost.vue'\n</script>\n\n<template>\n  <div class=\"flex h-full w-full flex-1 flex-col\">\n    <main class=\"route-scene relative h-full flex-1 overflow-auto\">\n      <router-view />\n      <MapIframeHost />\n    </main>\n  </div>\n</template>\n"
  },
  {
    "path": "web/src/components/layout/GalleryDebugOverlay.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed, ref } from 'vue'\nimport { useRoute } from 'vue-router'\nimport { useGalleryStore } from '@/features/gallery/store'\n\nconst route = useRoute()\nconst galleryStore = useGalleryStore()\nconst isCollapsed = ref(true)\n\nconst debugState = computed(() => {\n  const loadedPages = [...galleryStore.paginatedAssets.keys()].sort((left, right) => left - right)\n\n  return {\n    route: String(route.name ?? ''),\n    queryVersion: galleryStore.queryVersion,\n    isRefreshing: galleryStore.isRefreshing,\n    isLoading: galleryStore.isLoading,\n    totalCount: galleryStore.totalCount,\n    currentPage: galleryStore.currentPage,\n    perPage: galleryStore.perPage,\n    visibleRange: `${galleryStore.visibleRange.startIndex ?? '-'} ~ ${galleryStore.visibleRange.endIndex ?? '-'}`,\n    loadedPages: loadedPages.length > 0 ? loadedPages.join(', ') : '-',\n    activeAssetId: galleryStore.selection.activeAssetId ?? '-',\n    activeIndex: galleryStore.selection.activeIndex ?? '-',\n    selectedCount: galleryStore.selection.selectedIds.size,\n    anchorIndex: galleryStore.selection.anchorIndex ?? '-',\n    lightboxOpen: galleryStore.lightbox.isOpen,\n    lightboxClosing: galleryStore.lightbox.isClosing,\n    detailsFocus: galleryStore.detailsPanel.type,\n    sort: `${galleryStore.sortBy} / ${galleryStore.sortOrder}`,\n    includeSubfolders: galleryStore.includeSubfolders,\n    filterFolderId: galleryStore.filter.folderId ?? '-',\n    filterType: galleryStore.filter.type ?? '-',\n    filterSearch: galleryStore.filter.searchQuery?.trim() || '-',\n    timelineBuckets: galleryStore.timelineBuckets.length,\n  }\n})\n</script>\n\n<template>\n  <div\n    class=\"absolute bottom-2 left-2 z-[9900] max-w-[420px] rounded-md border border-white/15 bg-black/75 px-3 py-2 text-[11px] leading-4 text-white shadow-lg backdrop-blur-sm\"\n  >\n    <div class=\"mb-1 flex items-center justify-between gap-2\">\n      <div class=\"font-semibold tracking-wide text-white/90\">Gallery Debug</div>\n      <div class=\"flex items-center gap-1\">\n        <button\n          type=\"button\"\n          class=\"h-5 rounded border border-white/25 px-1.5 text-[10px] leading-none text-white/85 transition hover:bg-white/15\"\n          @click=\"isCollapsed = !isCollapsed\"\n        >\n          {{ isCollapsed ? '展开' : '收起' }}\n        </button>\n      </div>\n    </div>\n    <div v-if=\"!isCollapsed\">\n      <div>route: {{ debugState.route }}</div>\n      <div>\n        query: v{{ debugState.queryVersion }} / refreshing={{ debugState.isRefreshing }} /\n        loading={{ debugState.isLoading }}\n      </div>\n      <div>\n        page: {{ debugState.currentPage }} / total={{ debugState.totalCount }} / per={{\n          debugState.perPage\n        }}\n      </div>\n      <div>visibleRange: {{ debugState.visibleRange }}</div>\n      <div>loadedPages: {{ debugState.loadedPages }}</div>\n      <div>active: id={{ debugState.activeAssetId }} / index={{ debugState.activeIndex }}</div>\n      <div>selected={{ debugState.selectedCount }} / anchor={{ debugState.anchorIndex }}</div>\n      <div>\n        lightbox: open={{ debugState.lightboxOpen }} / closing={{ debugState.lightboxClosing }}\n      </div>\n      <div>detailsFocus: {{ debugState.detailsFocus }}</div>\n      <div>sort: {{ debugState.sort }}</div>\n      <div>includeSubfolders: {{ debugState.includeSubfolders }}</div>\n      <div>filter: folder={{ debugState.filterFolderId }} / type={{ debugState.filterType }}</div>\n      <div>search: {{ debugState.filterSearch }}</div>\n      <div>timelineBuckets: {{ debugState.timelineBuckets }}</div>\n    </div>\n  </div>\n</template>\n"
  },
  {
    "path": "web/src/components/layout/WindowResizeOverlay.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed, onBeforeUnmount, onMounted, ref } from 'vue'\nimport { isWebView } from '@/core/env'\nimport { call, off, on } from '@/core/rpc'\n\ntype ResizeEdge =\n  | 'top'\n  | 'right'\n  | 'bottom'\n  | 'left'\n  | 'topLeft'\n  | 'topRight'\n  | 'bottomLeft'\n  | 'bottomRight'\n\ntype ResizeHandle = {\n  edge: ResizeEdge\n  className: string\n  cursor: string\n}\n\ntype WindowState = {\n  maximized: boolean\n  fullscreen: boolean\n}\n\nconst windowState = ref<WindowState>({\n  maximized: false,\n  fullscreen: false,\n})\n\nconst showResizeOverlay = computed(\n  () => isWebView() && !windowState.value.maximized && !windowState.value.fullscreen\n)\n\nconst resizeHandles: ResizeHandle[] = [\n  { edge: 'top', className: 'left-0 top-0 h-1.5 w-full', cursor: 'n-resize' },\n  { edge: 'right', className: 'right-0 top-0 h-full w-1.5', cursor: 'e-resize' },\n  { edge: 'bottom', className: 'bottom-0 left-0 h-1.5 w-full', cursor: 's-resize' },\n  { edge: 'left', className: 'left-0 top-0 h-full w-1.5', cursor: 'w-resize' },\n  { edge: 'topLeft', className: 'left-0 top-0 h-3 w-3', cursor: 'nw-resize' },\n  { edge: 'topRight', className: 'right-0 top-0 h-3 w-3', cursor: 'ne-resize' },\n  { edge: 'bottomLeft', className: 'bottom-0 left-0 h-3 w-3', cursor: 'sw-resize' },\n  { edge: 'bottomRight', className: 'bottom-0 right-0 h-3 w-3', cursor: 'se-resize' },\n]\n\nfunction handleMouseDown(edge: ResizeEdge, event: MouseEvent): void {\n  if (event.button !== 0) {\n    return\n  }\n\n  event.preventDefault()\n  event.stopPropagation()\n\n  if (!window.chrome?.webview) {\n    return\n  }\n\n  window.chrome.webview.postMessage({ type: 'window.beginResize', edge })\n}\n\nfunction applyWindowState(params: unknown): void {\n  if (!params || typeof params !== 'object') {\n    return\n  }\n\n  const nextState = params as Partial<WindowState>\n  windowState.value = {\n    maximized: nextState.maximized === true,\n    fullscreen: nextState.fullscreen === true,\n  }\n}\n\nfunction handleWindowStateChanged(params: unknown): void {\n  applyWindowState(params)\n}\n\nonMounted(() => {\n  if (!isWebView()) {\n    return\n  }\n\n  on('window.stateChanged', handleWindowStateChanged)\n  call<WindowState>('webview.getWindowState')\n    .then((state) => {\n      applyWindowState(state)\n    })\n    .catch((error) => {\n      console.error('Failed to get initial window state:', error)\n    })\n})\n\nonBeforeUnmount(() => {\n  if (!isWebView()) {\n    return\n  }\n\n  off('window.stateChanged', handleWindowStateChanged)\n})\n</script>\n\n<template>\n  <div v-if=\"showResizeOverlay\" class=\"pointer-events-none absolute inset-0 z-500 select-none\">\n    <button\n      v-for=\"handle in resizeHandles\"\n      :key=\"handle.edge\"\n      type=\"button\"\n      tabindex=\"-1\"\n      aria-hidden=\"true\"\n      class=\"pointer-events-auto absolute border-0 bg-transparent p-0 opacity-0\"\n      :class=\"handle.className\"\n      :style=\"{ cursor: handle.cursor }\"\n      @mousedown=\"(event) => handleMouseDown(handle.edge, event)\"\n    />\n  </div>\n</template>\n"
  },
  {
    "path": "web/src/components/layout/index.ts",
    "content": "// Layout 组件统一导出\nexport { default as AppLayout } from './AppLayout.vue'\nexport { default as ActivityBar } from './ActivityBar.vue'\nexport { default as AppHeader } from './AppHeader.vue'\nexport { default as ContentArea } from './ContentArea.vue'\n"
  },
  {
    "path": "web/src/components/ui/accordion/Accordion.vue",
    "content": "<script setup lang=\"ts\">\nimport type { AccordionRootEmits, AccordionRootProps } from 'reka-ui'\nimport { AccordionRoot, useForwardPropsEmits } from 'reka-ui'\n\nconst props = defineProps<AccordionRootProps>()\nconst emits = defineEmits<AccordionRootEmits>()\n\nconst forwarded = useForwardPropsEmits(props, emits)\n</script>\n\n<template>\n  <AccordionRoot v-slot=\"slotProps\" data-slot=\"accordion\" v-bind=\"forwarded\">\n    <slot v-bind=\"slotProps\" />\n  </AccordionRoot>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/accordion/AccordionContent.vue",
    "content": "<script setup lang=\"ts\">\nimport type { AccordionContentProps } from 'reka-ui'\nimport type { HTMLAttributes } from 'vue'\nimport { reactiveOmit } from '@vueuse/core'\nimport { AccordionContent } from 'reka-ui'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<AccordionContentProps & { class?: HTMLAttributes['class'] }>()\n\nconst delegatedProps = reactiveOmit(props, 'class')\n</script>\n\n<template>\n  <AccordionContent\n    data-slot=\"accordion-content\"\n    v-bind=\"delegatedProps\"\n    class=\"overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down\"\n  >\n    <div :class=\"cn('pt-0 pb-4', props.class)\">\n      <slot />\n    </div>\n  </AccordionContent>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/accordion/AccordionItem.vue",
    "content": "<script setup lang=\"ts\">\nimport type { AccordionItemProps } from 'reka-ui'\nimport type { HTMLAttributes } from 'vue'\nimport { reactiveOmit } from '@vueuse/core'\nimport { AccordionItem, useForwardProps } from 'reka-ui'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<AccordionItemProps & { class?: HTMLAttributes['class'] }>()\n\nconst delegatedProps = reactiveOmit(props, 'class')\n\nconst forwardedProps = useForwardProps(delegatedProps)\n</script>\n\n<template>\n  <AccordionItem\n    v-slot=\"slotProps\"\n    data-slot=\"accordion-item\"\n    v-bind=\"forwardedProps\"\n    :class=\"cn('border-b last:border-b-0', props.class)\"\n  >\n    <slot v-bind=\"slotProps\" />\n  </AccordionItem>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/accordion/AccordionTrigger.vue",
    "content": "<script setup lang=\"ts\">\nimport type { AccordionTriggerProps } from 'reka-ui'\nimport type { HTMLAttributes } from 'vue'\nimport { reactiveOmit } from '@vueuse/core'\nimport { ChevronDown } from 'lucide-vue-next'\nimport { AccordionHeader, AccordionTrigger } from 'reka-ui'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<AccordionTriggerProps & { class?: HTMLAttributes['class'] }>()\n\nconst delegatedProps = reactiveOmit(props, 'class')\n</script>\n\n<template>\n  <AccordionHeader class=\"flex\">\n    <AccordionTrigger\n      data-slot=\"accordion-trigger\"\n      v-bind=\"delegatedProps\"\n      :class=\"\n        cn(\n          'flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180',\n          props.class\n        )\n      \"\n    >\n      <slot />\n      <slot name=\"icon\">\n        <ChevronDown\n          class=\"pointer-events-none size-4 shrink-0 translate-y-0.5 text-muted-foreground transition-transform duration-200\"\n        />\n      </slot>\n    </AccordionTrigger>\n  </AccordionHeader>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/accordion/index.ts",
    "content": "export { default as Accordion } from './Accordion.vue'\nexport { default as AccordionContent } from './AccordionContent.vue'\nexport { default as AccordionItem } from './AccordionItem.vue'\nexport { default as AccordionTrigger } from './AccordionTrigger.vue'\n"
  },
  {
    "path": "web/src/components/ui/alert/Alert.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from \"vue\"\nimport type { AlertVariants } from \".\"\nimport { cn } from \"@/lib/utils\"\nimport { alertVariants } from \".\"\n\nconst props = defineProps<{\n  class?: HTMLAttributes[\"class\"]\n  variant?: AlertVariants[\"variant\"]\n}>()\n</script>\n\n<template>\n  <div\n    data-slot=\"alert\"\n    :class=\"cn(alertVariants({ variant }), props.class)\"\n    role=\"alert\"\n  >\n    <slot />\n  </div>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/alert/AlertDescription.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from \"vue\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<{\n  class?: HTMLAttributes[\"class\"]\n}>()\n</script>\n\n<template>\n  <div\n    data-slot=\"alert-description\"\n    :class=\"cn('text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed', props.class)\"\n  >\n    <slot />\n  </div>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/alert/AlertTitle.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from \"vue\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<{\n  class?: HTMLAttributes[\"class\"]\n}>()\n</script>\n\n<template>\n  <div\n    data-slot=\"alert-title\"\n    :class=\"cn('col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight', props.class)\"\n  >\n    <slot />\n  </div>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/alert/index.ts",
    "content": "import type { VariantProps } from \"class-variance-authority\"\nimport { cva } from \"class-variance-authority\"\n\nexport { default as Alert } from \"./Alert.vue\"\nexport { default as AlertDescription } from \"./AlertDescription.vue\"\nexport { default as AlertTitle } from \"./AlertTitle.vue\"\n\nexport const alertVariants = cva(\n  \"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current\",\n  {\n    variants: {\n      variant: {\n        default: \"bg-card text-card-foreground\",\n        destructive:\n          \"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n    },\n  },\n)\n\nexport type AlertVariants = VariantProps<typeof alertVariants>\n"
  },
  {
    "path": "web/src/components/ui/alert-dialog/AlertDialog.vue",
    "content": "<script setup lang=\"ts\">\nimport type { AlertDialogEmits, AlertDialogProps } from \"reka-ui\"\nimport { AlertDialogRoot, useForwardPropsEmits } from \"reka-ui\"\n\nconst props = defineProps<AlertDialogProps>()\nconst emits = defineEmits<AlertDialogEmits>()\n\nconst forwarded = useForwardPropsEmits(props, emits)\n</script>\n\n<template>\n  <AlertDialogRoot data-slot=\"alert-dialog\" v-bind=\"forwarded\">\n    <slot />\n  </AlertDialogRoot>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/alert-dialog/AlertDialogAction.vue",
    "content": "<script setup lang=\"ts\">\nimport type { AlertDialogActionProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\"\nimport { reactiveOmit } from \"@vueuse/core\"\nimport { AlertDialogAction } from \"reka-ui\"\nimport { cn } from \"@/lib/utils\"\nimport { buttonVariants } from '@/components/ui/button'\n\nconst props = defineProps<AlertDialogActionProps & { class?: HTMLAttributes[\"class\"] }>()\n\nconst delegatedProps = reactiveOmit(props, \"class\")\n</script>\n\n<template>\n  <AlertDialogAction v-bind=\"delegatedProps\" :class=\"cn(buttonVariants(), props.class)\">\n    <slot />\n  </AlertDialogAction>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/alert-dialog/AlertDialogCancel.vue",
    "content": "<script setup lang=\"ts\">\nimport type { AlertDialogCancelProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\"\nimport { reactiveOmit } from \"@vueuse/core\"\nimport { AlertDialogCancel } from \"reka-ui\"\nimport { cn } from \"@/lib/utils\"\nimport { buttonVariants } from '@/components/ui/button'\n\nconst props = defineProps<AlertDialogCancelProps & { class?: HTMLAttributes[\"class\"] }>()\n\nconst delegatedProps = reactiveOmit(props, \"class\")\n</script>\n\n<template>\n  <AlertDialogCancel\n    v-bind=\"delegatedProps\"\n    :class=\"cn(\n      buttonVariants({ variant: 'outline' }),\n      'mt-2 sm:mt-0',\n      props.class,\n    )\"\n  >\n    <slot />\n  </AlertDialogCancel>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/alert-dialog/AlertDialogContent.vue",
    "content": "<script setup lang=\"ts\">\nimport type { AlertDialogContentEmits, AlertDialogContentProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\"\nimport { reactiveOmit } from \"@vueuse/core\"\nimport {\n  AlertDialogContent,\n\n  AlertDialogOverlay,\n  AlertDialogPortal,\n  useForwardPropsEmits,\n} from \"reka-ui\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<AlertDialogContentProps & { class?: HTMLAttributes[\"class\"] }>()\nconst emits = defineEmits<AlertDialogContentEmits>()\n\nconst delegatedProps = reactiveOmit(props, \"class\")\n\nconst forwarded = useForwardPropsEmits(delegatedProps, emits)\n</script>\n\n<template>\n  <AlertDialogPortal>\n    <AlertDialogOverlay\n      data-slot=\"alert-dialog-overlay\"\n      class=\"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80\"\n    />\n    <AlertDialogContent\n      data-slot=\"alert-dialog-content\"\n      v-bind=\"forwarded\"\n      :class=\"\n        cn(\n          'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',\n          props.class,\n        )\n      \"\n    >\n      <slot />\n    </AlertDialogContent>\n  </AlertDialogPortal>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/alert-dialog/AlertDialogDescription.vue",
    "content": "<script setup lang=\"ts\">\nimport type { AlertDialogDescriptionProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\"\nimport { reactiveOmit } from \"@vueuse/core\"\nimport {\n  AlertDialogDescription,\n\n} from \"reka-ui\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<AlertDialogDescriptionProps & { class?: HTMLAttributes[\"class\"] }>()\n\nconst delegatedProps = reactiveOmit(props, \"class\")\n</script>\n\n<template>\n  <AlertDialogDescription\n    data-slot=\"alert-dialog-description\"\n    v-bind=\"delegatedProps\"\n    :class=\"cn('text-muted-foreground text-sm', props.class)\"\n  >\n    <slot />\n  </AlertDialogDescription>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/alert-dialog/AlertDialogFooter.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from \"vue\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<{\n  class?: HTMLAttributes[\"class\"]\n}>()\n</script>\n\n<template>\n  <div\n    data-slot=\"alert-dialog-footer\"\n    :class=\"\n      cn(\n        'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',\n        props.class,\n      )\n    \"\n  >\n    <slot />\n  </div>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/alert-dialog/AlertDialogHeader.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from \"vue\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<{\n  class?: HTMLAttributes[\"class\"]\n}>()\n</script>\n\n<template>\n  <div\n    data-slot=\"alert-dialog-header\"\n    :class=\"cn('flex flex-col gap-2 text-center sm:text-left', props.class)\"\n  >\n    <slot />\n  </div>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/alert-dialog/AlertDialogTitle.vue",
    "content": "<script setup lang=\"ts\">\nimport type { AlertDialogTitleProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\"\nimport { reactiveOmit } from \"@vueuse/core\"\nimport { AlertDialogTitle } from \"reka-ui\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<AlertDialogTitleProps & { class?: HTMLAttributes[\"class\"] }>()\n\nconst delegatedProps = reactiveOmit(props, \"class\")\n</script>\n\n<template>\n  <AlertDialogTitle\n    data-slot=\"alert-dialog-title\"\n    v-bind=\"delegatedProps\"\n    :class=\"cn('text-lg font-semibold', props.class)\"\n  >\n    <slot />\n  </AlertDialogTitle>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/alert-dialog/AlertDialogTrigger.vue",
    "content": "<script setup lang=\"ts\">\nimport type { AlertDialogTriggerProps } from \"reka-ui\"\nimport { AlertDialogTrigger } from \"reka-ui\"\n\nconst props = defineProps<AlertDialogTriggerProps>()\n</script>\n\n<template>\n  <AlertDialogTrigger data-slot=\"alert-dialog-trigger\" v-bind=\"props\">\n    <slot />\n  </AlertDialogTrigger>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/alert-dialog/index.ts",
    "content": "export { default as AlertDialog } from \"./AlertDialog.vue\"\nexport { default as AlertDialogAction } from \"./AlertDialogAction.vue\"\nexport { default as AlertDialogCancel } from \"./AlertDialogCancel.vue\"\nexport { default as AlertDialogContent } from \"./AlertDialogContent.vue\"\nexport { default as AlertDialogDescription } from \"./AlertDialogDescription.vue\"\nexport { default as AlertDialogFooter } from \"./AlertDialogFooter.vue\"\nexport { default as AlertDialogHeader } from \"./AlertDialogHeader.vue\"\nexport { default as AlertDialogTitle } from \"./AlertDialogTitle.vue\"\nexport { default as AlertDialogTrigger } from \"./AlertDialogTrigger.vue\"\n"
  },
  {
    "path": "web/src/components/ui/badge/Badge.vue",
    "content": "<script setup lang=\"ts\">\nimport type { PrimitiveProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\"\nimport type { BadgeVariants } from \".\"\nimport { reactiveOmit } from \"@vueuse/core\"\nimport { Primitive } from \"reka-ui\"\nimport { cn } from \"@/lib/utils\"\nimport { badgeVariants } from \".\"\n\nconst props = defineProps<PrimitiveProps & {\n  variant?: BadgeVariants[\"variant\"]\n  class?: HTMLAttributes[\"class\"]\n}>()\n\nconst delegatedProps = reactiveOmit(props, \"class\")\n</script>\n\n<template>\n  <Primitive\n    data-slot=\"badge\"\n    :class=\"cn(badgeVariants({ variant }), props.class)\"\n    v-bind=\"delegatedProps\"\n  >\n    <slot />\n  </Primitive>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/badge/index.ts",
    "content": "import type { VariantProps } from \"class-variance-authority\"\nimport { cva } from \"class-variance-authority\"\n\nexport { default as Badge } from \"./Badge.vue\"\n\nexport const badgeVariants = cva(\n  \"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden\",\n  {\n    variants: {\n      variant: {\n        default:\n          \"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90\",\n        secondary:\n          \"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90\",\n        destructive:\n         \"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60\",\n        outline:\n          \"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n    },\n  },\n)\nexport type BadgeVariants = VariantProps<typeof badgeVariants>\n"
  },
  {
    "path": "web/src/components/ui/button/Button.vue",
    "content": "<script setup lang=\"ts\">\nimport type { PrimitiveProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\"\nimport type { ButtonVariants } from \".\"\nimport { Primitive } from \"reka-ui\"\nimport { cn } from \"@/lib/utils\"\nimport { buttonVariants } from \".\"\n\ninterface Props extends PrimitiveProps {\n  variant?: ButtonVariants[\"variant\"]\n  size?: ButtonVariants[\"size\"]\n  class?: HTMLAttributes[\"class\"]\n}\n\nconst props = withDefaults(defineProps<Props>(), {\n  as: \"button\",\n})\n</script>\n\n<template>\n  <Primitive\n    data-slot=\"button\"\n    :as=\"as\"\n    :as-child=\"asChild\"\n    :class=\"cn(buttonVariants({ variant, size }), props.class)\"\n  >\n    <slot />\n  </Primitive>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/button/index.ts",
    "content": "import type { VariantProps } from 'class-variance-authority'\nimport { cva } from 'class-variance-authority'\n\nexport { default as Button } from './Button.vue'\n\nexport const buttonVariants = cva(\n  \"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all cursor-pointer disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive\",\n  {\n    variants: {\n      variant: {\n        default: 'bg-primary text-primary-foreground hover:bg-primary/90',\n        destructive:\n          'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',\n        outline:\n          'border bg-background hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input',\n        secondary: 'bg-secondary text-secondary-foreground  hover:bg-secondary/80',\n        ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',\n        /** 图库侧栏加号、工具条/灯箱图标按钮等：与 SettingsSidebar 同源 sidebar token */\n        sidebarGhost:\n          'text-sidebar-foreground transition-colors duration-200 ease-out hover:bg-sidebar-hover hover:text-sidebar-accent-foreground focus-visible:border-transparent focus-visible:ring-2 focus-visible:ring-sidebar-ring focus-visible:ring-offset-2',\n        link: 'text-primary underline-offset-4 hover:underline',\n      },\n      size: {\n        default: 'h-9 px-4 py-2 has-[>svg]:px-3',\n        sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',\n        lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',\n        icon: 'size-9',\n        'icon-sm': 'size-8',\n        'icon-xs': 'size-6',\n        'icon-lg': 'size-10',\n      },\n    },\n    defaultVariants: {\n      variant: 'default',\n      size: 'default',\n    },\n  }\n)\n\nexport type ButtonVariants = VariantProps<typeof buttonVariants>\n"
  },
  {
    "path": "web/src/components/ui/checkbox/Checkbox.vue",
    "content": "<script setup lang=\"ts\">\nimport type { CheckboxRootEmits, CheckboxRootProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\"\nimport { reactiveOmit } from \"@vueuse/core\"\nimport { Check } from \"lucide-vue-next\"\nimport { CheckboxIndicator, CheckboxRoot, useForwardPropsEmits } from \"reka-ui\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<CheckboxRootProps & { class?: HTMLAttributes[\"class\"] }>()\nconst emits = defineEmits<CheckboxRootEmits>()\n\nconst delegatedProps = reactiveOmit(props, \"class\")\n\nconst forwarded = useForwardPropsEmits(delegatedProps, emits)\n</script>\n\n<template>\n  <CheckboxRoot\n    data-slot=\"checkbox\"\n    v-bind=\"forwarded\"\n    :class=\"\n      cn('peer border-input data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',\n         props.class)\"\n  >\n    <CheckboxIndicator\n      data-slot=\"checkbox-indicator\"\n      class=\"flex items-center justify-center text-current transition-none\"\n    >\n      <slot>\n        <Check class=\"size-3.5\" />\n      </slot>\n    </CheckboxIndicator>\n  </CheckboxRoot>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/checkbox/index.ts",
    "content": "export { default as Checkbox } from \"./Checkbox.vue\"\n"
  },
  {
    "path": "web/src/components/ui/color-picker/ColorPicker.vue",
    "content": "<script setup lang=\"ts\">\nimport { ref, computed, watch } from 'vue'\nimport { Input } from '@/components/ui/input'\nimport { hexToHsv, hsvToHex, normalizeToHex } from './colorUtils'\n\nconst props = withDefaults(\n  defineProps<{\n    modelValue: string\n    showHexInput?: boolean\n  }>(),\n  {\n    showHexInput: true,\n  }\n)\n\nconst emit = defineEmits<{\n  (e: 'update:modelValue', value: string): void\n}>()\n\nconst hsv = ref(hexToHsv(props.modelValue))\n\n// Keep HSV in sync with external modelValue changes\nwatch(\n  () => props.modelValue,\n  (newHex) => {\n    const currentHex = hsvToHex(hsv.value)\n    if (newHex !== currentHex) {\n      hsv.value = hexToHsv(normalizeToHex(newHex))\n    }\n  }\n)\n\nconst emitColor = () => {\n  emit('update:modelValue', hsvToHex(hsv.value))\n}\n\nconst handleHexInput = (e: Event) => {\n  const target = e.target as HTMLInputElement\n  const newHex = target.value\n  if (/^#?[0-9A-Fa-f]{6}$/.test(newHex)) {\n    const normalizedHex = newHex.startsWith('#') ? newHex.toUpperCase() : '#' + newHex.toUpperCase()\n    hsv.value = hexToHsv(normalizedHex)\n    emitColor()\n  }\n}\n\n// --- Saturation & Brightness Board ---\nconst boardRef = ref<HTMLElement | null>(null)\nconst isDraggingBoard = ref(false)\n\nconst handleBoardPointerDown = (e: PointerEvent) => {\n  if (!boardRef.value) return\n  isDraggingBoard.value = true\n  boardRef.value.setPointerCapture(e.pointerId)\n  updateBoardPosition(e)\n}\n\nconst handleBoardPointerMove = (e: PointerEvent) => {\n  if (!isDraggingBoard.value) return\n  updateBoardPosition(e)\n}\n\nconst handleBoardPointerUp = (e: PointerEvent) => {\n  if (!boardRef.value) return\n  isDraggingBoard.value = false\n  boardRef.value.releasePointerCapture(e.pointerId)\n}\n\nconst updateBoardPosition = (e: PointerEvent) => {\n  if (!boardRef.value) return\n  const rect = boardRef.value.getBoundingClientRect()\n\n  let x = e.clientX - rect.left\n  let y = e.clientY - rect.top\n\n  x = Math.max(0, Math.min(x, rect.width))\n  y = Math.max(0, Math.min(y, rect.height))\n\n  hsv.value.s = (x / rect.width) * 100\n  hsv.value.v = 100 - (y / rect.height) * 100\n\n  emitColor()\n}\n\n// --- Hue Slider ---\nconst hueRef = ref<HTMLElement | null>(null)\nconst isDraggingHue = ref(false)\n\nconst handleHuePointerDown = (e: PointerEvent) => {\n  if (!hueRef.value) return\n  isDraggingHue.value = true\n  hueRef.value.setPointerCapture(e.pointerId)\n  updateHuePosition(e)\n}\n\nconst handleHuePointerMove = (e: PointerEvent) => {\n  if (!isDraggingHue.value) return\n  updateHuePosition(e)\n}\n\nconst handleHuePointerUp = (e: PointerEvent) => {\n  if (!hueRef.value) return\n  isDraggingHue.value = false\n  hueRef.value.releasePointerCapture(e.pointerId)\n}\n\nconst updateHuePosition = (e: PointerEvent) => {\n  if (!hueRef.value) return\n  const rect = hueRef.value.getBoundingClientRect()\n\n  let x = e.clientX - rect.left\n  x = Math.max(0, Math.min(x, rect.width))\n\n  hsv.value.h = (x / rect.width) * 360\n  emitColor()\n}\n\n// Compute Styles\nconst boardBackground = computed(() => {\n  return `hsl(${hsv.value.h}, 100%, 50%)`\n})\n\nconst thumbPosition = computed(() => {\n  return {\n    left: `${hsv.value.s}%`,\n    top: `${100 - hsv.value.v}%`,\n  }\n})\n\nconst hueThumbPosition = computed(() => {\n  return {\n    left: `${(hsv.value.h / 360) * 100}%`,\n  }\n})\n\nconst currentColorRender = computed(() => hsvToHex(hsv.value))\n</script>\n\n<template>\n  <div class=\"flex w-[200px] flex-col gap-3\">\n    <!-- Saturation/Brightness Board -->\n    <div\n      ref=\"boardRef\"\n      class=\"relative h-[150px] w-full cursor-crosshair overflow-hidden rounded-md border border-border/80\"\n      :style=\"{ backgroundColor: boardBackground }\"\n      @pointerdown=\"handleBoardPointerDown\"\n      @pointermove=\"handleBoardPointerMove\"\n      @pointerup=\"handleBoardPointerUp\"\n    >\n      <div\n        class=\"pointer-events-none absolute inset-0 bg-gradient-to-r from-white to-transparent\"\n      />\n      <div\n        class=\"pointer-events-none absolute inset-0 bg-gradient-to-t from-black to-transparent\"\n      />\n\n      <!-- Board Thumb -->\n      <div\n        class=\"pointer-events-none absolute h-3 w-3 -translate-x-1.5 -translate-y-1.5 rounded-full border border-white shadow-[0_0_2px_rgba(0,0,0,0.6)]\"\n        :style=\"{\n          left: thumbPosition.left,\n          top: thumbPosition.top,\n          backgroundColor: currentColorRender,\n        }\"\n      />\n    </div>\n\n    <!-- Hue Slider -->\n    <div\n      ref=\"hueRef\"\n      class=\"h-slider relative h-[12px] w-full cursor-pointer rounded-full border border-border/80\"\n      @pointerdown=\"handleHuePointerDown\"\n      @pointermove=\"handleHuePointerMove\"\n      @pointerup=\"handleHuePointerUp\"\n    >\n      <!-- Hue Thumb -->\n      <div\n        class=\"pointer-events-none absolute top-1/2 h-3 w-3 -translate-x-1.5 -translate-y-1/2 rounded-full border border-white bg-white shadow-[0_0_2px_rgba(0,0,0,0.6)]\"\n        :style=\"{ left: hueThumbPosition.left }\"\n      />\n    </div>\n\n    <!-- Hex Input & Preview -->\n    <div v-if=\"props.showHexInput\" class=\"mt-1 flex items-center gap-2\">\n      <div\n        class=\"h-6 w-6 shrink-0 rounded-md border border-border/80\"\n        :style=\"{ backgroundColor: currentColorRender }\"\n      />\n      <Input\n        :model-value=\"currentColorRender\"\n        @input=\"handleHexInput\"\n        class=\"h-7 flex-1 font-mono text-xs uppercase\"\n        placeholder=\"#000000\"\n      />\n    </div>\n  </div>\n</template>\n\n<style scoped>\n.h-slider {\n  background: linear-gradient(\n    to right,\n    #ff0000 0%,\n    #ffff00 17%,\n    #00ff00 33%,\n    #00ffff 50%,\n    #0000ff 67%,\n    #ff00ff 83%,\n    #ff0000 100%\n  );\n}\n</style>\n"
  },
  {
    "path": "web/src/components/ui/color-picker/colorUtils.ts",
    "content": "/**\n * Color utility functions for custom color picker.\n * Focuses on Hex <-> HSV conversions.\n */\n\nexport interface HSV {\n  h: number // 0-360\n  s: number // 0-100\n  v: number // 0-100\n}\n\nexport const rgbToHex = (r: number, g: number, b: number): string => {\n  const toHex = (value: number) => {\n    const normalized = Math.max(0, Math.min(255, Math.round(value)))\n    return normalized.toString(16).padStart(2, '0')\n  }\n\n  return `#${toHex(r)}${toHex(g)}${toHex(b)}`.toUpperCase()\n}\n\n/**\n * Converts a HEX color string to HSV object.\n * @param hex A HEX color string (e.g., '#FF0000' or 'FF0000')\n * @returns HSV object\n */\nexport const hexToHsv = (hex: string): HSV => {\n  let r = 0,\n    g = 0,\n    b = 0\n\n  if (hex.startsWith('#')) {\n    hex = hex.slice(1)\n  }\n\n  if (hex.length === 3) {\n    r = parseInt(hex.charAt(0) + hex.charAt(0), 16)\n    g = parseInt(hex.charAt(1) + hex.charAt(1), 16)\n    b = parseInt(hex.charAt(2) + hex.charAt(2), 16)\n  } else if (hex.length === 6) {\n    r = parseInt(hex.slice(0, 2), 16)\n    g = parseInt(hex.slice(2, 4), 16)\n    b = parseInt(hex.slice(4, 6), 16)\n  }\n\n  r /= 255\n  g /= 255\n  b /= 255\n\n  const max = Math.max(r, g, b)\n  const min = Math.min(r, g, b)\n  const d = max - min\n\n  let h = 0\n  const s = max === 0 ? 0 : d / max\n  const v = max\n\n  if (max !== min) {\n    switch (max) {\n      case r:\n        h = (g - b) / d + (g < b ? 6 : 0)\n        break\n      case g:\n        h = (b - r) / d + 2\n        break\n      case b:\n        h = (r - g) / d + 4\n        break\n    }\n    h /= 6\n  }\n\n  return {\n    h: Math.round(h * 360),\n    s: Math.round(s * 100),\n    v: Math.round(v * 100),\n  }\n}\n\n/**\n * Converts an HSV object to a HEX color string.\n * @param hsv HSV object\n * @returns A HEX color string (e.g., '#FF0000')\n */\nexport const hsvToHex = ({ h, s, v }: HSV): string => {\n  h = h / 360\n  s = s / 100\n  v = v / 100\n\n  let r = 0,\n    g = 0,\n    b = 0\n\n  const i = Math.floor(h * 6)\n  const f = h * 6 - i\n  const p = v * (1 - s)\n  const q = v * (1 - f * s)\n  const t = v * (1 - (1 - f) * s)\n\n  switch (i % 6) {\n    case 0:\n      r = v\n      g = t\n      b = p\n      break\n    case 1:\n      r = q\n      g = v\n      b = p\n      break\n    case 2:\n      r = p\n      g = v\n      b = t\n      break\n    case 3:\n      r = p\n      g = q\n      b = v\n      break\n    case 4:\n      r = t\n      g = p\n      b = v\n      break\n    case 5:\n      r = v\n      g = p\n      b = q\n      break\n  }\n\n  const toHex = (c: number) => {\n    const hexVal = Math.round(c * 255).toString(16)\n    return hexVal.length === 1 ? '0' + hexVal : hexVal\n  }\n\n  return `#${toHex(r)}${toHex(g)}${toHex(b)}`.toUpperCase()\n}\n\n/**\n * Normalizes an arbitrary string into a valid HEX color.\n * If invalid, returns the fallback color.\n */\nexport const normalizeToHex = (value: string, fallback: string = '#000000'): string => {\n  const normalized = value.trim().toUpperCase()\n  const hexPattern = /^#[0-9A-F]{6}$/\n  return hexPattern.test(normalized) ? normalized : fallback\n}\n"
  },
  {
    "path": "web/src/components/ui/context-menu/ContextMenu.vue",
    "content": "<script setup lang=\"ts\">\nimport type { ContextMenuRootEmits, ContextMenuRootProps } from \"reka-ui\"\nimport { ContextMenuRoot, useForwardPropsEmits } from \"reka-ui\"\n\nconst props = defineProps<ContextMenuRootProps>()\nconst emits = defineEmits<ContextMenuRootEmits>()\n\nconst forwarded = useForwardPropsEmits(props, emits)\n</script>\n\n<template>\n  <ContextMenuRoot\n    data-slot=\"context-menu\"\n    v-bind=\"forwarded\"\n  >\n    <slot />\n  </ContextMenuRoot>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/context-menu/ContextMenuCheckboxItem.vue",
    "content": "<script setup lang=\"ts\">\nimport type { ContextMenuCheckboxItemEmits, ContextMenuCheckboxItemProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\"\nimport { reactiveOmit } from \"@vueuse/core\"\nimport { Check } from \"lucide-vue-next\"\nimport {\n  ContextMenuCheckboxItem,\n\n  ContextMenuItemIndicator,\n  useForwardPropsEmits,\n} from \"reka-ui\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<ContextMenuCheckboxItemProps & { class?: HTMLAttributes[\"class\"] }>()\nconst emits = defineEmits<ContextMenuCheckboxItemEmits>()\n\nconst delegatedProps = reactiveOmit(props, \"class\")\n\nconst forwarded = useForwardPropsEmits(delegatedProps, emits)\n</script>\n\n<template>\n  <ContextMenuCheckboxItem\n    data-slot=\"context-menu-checkbox-item\"\n    v-bind=\"forwarded\"\n    :class=\"cn(\n      'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\\'size-\\'])]:size-4',\n      props.class,\n    )\"\n  >\n    <span class=\"pointer-events-none absolute left-2 flex size-3.5 items-center justify-center\">\n      <ContextMenuItemIndicator>\n        <Check class=\"size-4\" />\n      </ContextMenuItemIndicator>\n    </span>\n    <slot />\n  </ContextMenuCheckboxItem>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/context-menu/ContextMenuContent.vue",
    "content": "<script setup lang=\"ts\">\nimport type { ContextMenuContentEmits, ContextMenuContentProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\"\nimport { reactiveOmit } from \"@vueuse/core\"\nimport {\n  ContextMenuContent,\n\n  ContextMenuPortal,\n  useForwardPropsEmits,\n} from \"reka-ui\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<ContextMenuContentProps & { class?: HTMLAttributes[\"class\"] }>()\nconst emits = defineEmits<ContextMenuContentEmits>()\n\nconst delegatedProps = reactiveOmit(props, \"class\")\n\nconst forwarded = useForwardPropsEmits(delegatedProps, emits)\n</script>\n\n<template>\n  <ContextMenuPortal>\n    <ContextMenuContent\n      data-slot=\"context-menu-content\"\n      v-bind=\"forwarded\"\n      :class=\"cn(\n        'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--reka-context-menu-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',\n        props.class,\n      )\"\n    >\n      <slot />\n    </ContextMenuContent>\n  </ContextMenuPortal>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/context-menu/ContextMenuGroup.vue",
    "content": "<script setup lang=\"ts\">\nimport type { ContextMenuGroupProps } from \"reka-ui\"\nimport { ContextMenuGroup } from \"reka-ui\"\n\nconst props = defineProps<ContextMenuGroupProps>()\n</script>\n\n<template>\n  <ContextMenuGroup\n    data-slot=\"context-menu-group\"\n    v-bind=\"props\"\n  >\n    <slot />\n  </ContextMenuGroup>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/context-menu/ContextMenuItem.vue",
    "content": "<script setup lang=\"ts\">\nimport type { ContextMenuItemEmits, ContextMenuItemProps } from 'reka-ui'\nimport type { HTMLAttributes } from 'vue'\nimport { reactiveOmit } from '@vueuse/core'\nimport { ContextMenuItem, useForwardPropsEmits } from 'reka-ui'\nimport { cn } from '@/lib/utils'\n\nconst props = withDefaults(\n  defineProps<\n    ContextMenuItemProps & {\n      class?: HTMLAttributes['class']\n      inset?: boolean\n      variant?: 'default' | 'destructive'\n    }\n  >(),\n  {\n    variant: 'default',\n  }\n)\nconst emits = defineEmits<ContextMenuItemEmits>()\n\nconst delegatedProps = reactiveOmit(props, 'class')\n\nconst forwarded = useForwardPropsEmits(delegatedProps, emits)\n</script>\n\n<template>\n  <ContextMenuItem\n    data-slot=\"context-menu-item\"\n    :data-inset=\"inset ? '' : undefined\"\n    :data-variant=\"variant\"\n    v-bind=\"forwarded\"\n    :class=\"\n      cn(\n        'relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\\'size-\\'])]:size-4 [&_svg:not([class*=\\'text-\\'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:!text-destructive',\n        props.class\n      )\n    \"\n  >\n    <slot />\n  </ContextMenuItem>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/context-menu/ContextMenuLabel.vue",
    "content": "<script setup lang=\"ts\">\nimport type { ContextMenuLabelProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\"\nimport { reactiveOmit } from \"@vueuse/core\"\nimport { ContextMenuLabel } from \"reka-ui\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<ContextMenuLabelProps & { class?: HTMLAttributes[\"class\"], inset?: boolean }>()\n\nconst delegatedProps = reactiveOmit(props, \"class\")\n</script>\n\n<template>\n  <ContextMenuLabel\n    data-slot=\"context-menu-label\"\n    :data-inset=\"inset ? '' : undefined\"\n    v-bind=\"delegatedProps\"\n    :class=\"cn('text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8', props.class)\"\n  >\n    <slot />\n  </ContextMenuLabel>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/context-menu/ContextMenuPortal.vue",
    "content": "<script setup lang=\"ts\">\nimport type { ContextMenuPortalProps } from \"reka-ui\"\nimport { ContextMenuPortal } from \"reka-ui\"\n\nconst props = defineProps<ContextMenuPortalProps>()\n</script>\n\n<template>\n  <ContextMenuPortal\n    data-slot=\"context-menu-portal\"\n    v-bind=\"props\"\n  >\n    <slot />\n  </ContextMenuPortal>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/context-menu/ContextMenuRadioGroup.vue",
    "content": "<script setup lang=\"ts\">\nimport type { ContextMenuRadioGroupEmits, ContextMenuRadioGroupProps } from \"reka-ui\"\nimport {\n  ContextMenuRadioGroup,\n\n  useForwardPropsEmits,\n} from \"reka-ui\"\n\nconst props = defineProps<ContextMenuRadioGroupProps>()\nconst emits = defineEmits<ContextMenuRadioGroupEmits>()\n\nconst forwarded = useForwardPropsEmits(props, emits)\n</script>\n\n<template>\n  <ContextMenuRadioGroup\n    data-slot=\"context-menu-radio-group\"\n    v-bind=\"forwarded\"\n  >\n    <slot />\n  </ContextMenuRadioGroup>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/context-menu/ContextMenuRadioItem.vue",
    "content": "<script setup lang=\"ts\">\nimport type { ContextMenuRadioItemEmits, ContextMenuRadioItemProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\"\nimport { reactiveOmit } from \"@vueuse/core\"\nimport { Circle } from \"lucide-vue-next\"\nimport {\n  ContextMenuItemIndicator,\n  ContextMenuRadioItem,\n\n  useForwardPropsEmits,\n} from \"reka-ui\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<ContextMenuRadioItemProps & { class?: HTMLAttributes[\"class\"] }>()\nconst emits = defineEmits<ContextMenuRadioItemEmits>()\n\nconst delegatedProps = reactiveOmit(props, \"class\")\n\nconst forwarded = useForwardPropsEmits(delegatedProps, emits)\n</script>\n\n<template>\n  <ContextMenuRadioItem\n    data-slot=\"context-menu-radio-item\"\n    v-bind=\"forwarded\"\n    :class=\"cn(\n      'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\\'size-\\'])]:size-4',\n      props.class,\n    )\"\n  >\n    <span class=\"pointer-events-none absolute left-2 flex size-3.5 items-center justify-center\">\n      <ContextMenuItemIndicator>\n        <Circle class=\"size-2 fill-current\" />\n      </ContextMenuItemIndicator>\n    </span>\n    <slot />\n  </ContextMenuRadioItem>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/context-menu/ContextMenuSeparator.vue",
    "content": "<script setup lang=\"ts\">\nimport type { ContextMenuSeparatorProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\"\nimport { reactiveOmit } from \"@vueuse/core\"\nimport {\n  ContextMenuSeparator,\n\n} from \"reka-ui\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<ContextMenuSeparatorProps & { class?: HTMLAttributes[\"class\"] }>()\n\nconst delegatedProps = reactiveOmit(props, \"class\")\n</script>\n\n<template>\n  <ContextMenuSeparator\n    data-slot=\"context-menu-separator\"\n    v-bind=\"delegatedProps\"\n    :class=\"cn('bg-border -mx-1 my-1 h-px', props.class)\"\n  />\n</template>\n"
  },
  {
    "path": "web/src/components/ui/context-menu/ContextMenuShortcut.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from \"vue\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<{\n  class?: HTMLAttributes[\"class\"]\n}>()\n</script>\n\n<template>\n  <span\n    data-slot=\"context-menu-shortcut\"\n    :class=\"cn('text-muted-foreground ml-auto text-xs tracking-widest', props.class)\"\n  >\n    <slot />\n  </span>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/context-menu/ContextMenuSub.vue",
    "content": "<script setup lang=\"ts\">\nimport type { ContextMenuSubEmits, ContextMenuSubProps } from \"reka-ui\"\nimport {\n  ContextMenuSub,\n\n  useForwardPropsEmits,\n} from \"reka-ui\"\n\nconst props = defineProps<ContextMenuSubProps>()\nconst emits = defineEmits<ContextMenuSubEmits>()\n\nconst forwarded = useForwardPropsEmits(props, emits)\n</script>\n\n<template>\n  <ContextMenuSub\n    data-slot=\"context-menu-sub\"\n    v-bind=\"forwarded\"\n  >\n    <slot />\n  </ContextMenuSub>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/context-menu/ContextMenuSubContent.vue",
    "content": "<script setup lang=\"ts\">\nimport type { DropdownMenuSubContentEmits, DropdownMenuSubContentProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\"\nimport { reactiveOmit } from \"@vueuse/core\"\nimport {\n  ContextMenuSubContent,\n\n  useForwardPropsEmits,\n} from \"reka-ui\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<DropdownMenuSubContentProps & { class?: HTMLAttributes[\"class\"] }>()\nconst emits = defineEmits<DropdownMenuSubContentEmits>()\n\nconst delegatedProps = reactiveOmit(props, \"class\")\n\nconst forwarded = useForwardPropsEmits(delegatedProps, emits)\n</script>\n\n<template>\n  <ContextMenuSubContent\n    data-slot=\"context-menu-sub-content\"\n    v-bind=\"forwarded\"\n    :class=\"\n      cn(\n        'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--reka-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',\n        props.class,\n      )\n    \"\n  >\n    <slot />\n  </ContextMenuSubContent>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/context-menu/ContextMenuSubTrigger.vue",
    "content": "<script setup lang=\"ts\">\nimport type { ContextMenuSubTriggerProps } from 'reka-ui'\nimport type { HTMLAttributes } from 'vue'\nimport { reactiveOmit } from '@vueuse/core'\nimport { ChevronRight } from 'lucide-vue-next'\nimport { ContextMenuSubTrigger, useForwardProps } from 'reka-ui'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<\n  ContextMenuSubTriggerProps & { class?: HTMLAttributes['class']; inset?: boolean }\n>()\n\nconst delegatedProps = reactiveOmit(props, 'class')\n\nconst forwardedProps = useForwardProps(delegatedProps)\n</script>\n\n<template>\n  <ContextMenuSubTrigger\n    data-slot=\"context-menu-sub-trigger\"\n    :data-inset=\"inset ? '' : undefined\"\n    v-bind=\"forwardedProps\"\n    :class=\"\n      cn(\n        'flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\\'size-\\'])]:size-4',\n        props.class\n      )\n    \"\n  >\n    <slot />\n    <ChevronRight class=\"ml-auto\" />\n  </ContextMenuSubTrigger>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/context-menu/ContextMenuTrigger.vue",
    "content": "<script setup lang=\"ts\">\nimport type { ContextMenuTriggerProps } from \"reka-ui\"\nimport { ContextMenuTrigger, useForwardProps } from \"reka-ui\"\n\nconst props = defineProps<ContextMenuTriggerProps>()\n\nconst forwardedProps = useForwardProps(props)\n</script>\n\n<template>\n  <ContextMenuTrigger\n    data-slot=\"context-menu-trigger\"\n    v-bind=\"forwardedProps\"\n  >\n    <slot />\n  </ContextMenuTrigger>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/context-menu/index.ts",
    "content": "export { default as ContextMenu } from \"./ContextMenu.vue\"\nexport { default as ContextMenuCheckboxItem } from \"./ContextMenuCheckboxItem.vue\"\nexport { default as ContextMenuContent } from \"./ContextMenuContent.vue\"\nexport { default as ContextMenuGroup } from \"./ContextMenuGroup.vue\"\nexport { default as ContextMenuItem } from \"./ContextMenuItem.vue\"\nexport { default as ContextMenuLabel } from \"./ContextMenuLabel.vue\"\nexport { default as ContextMenuRadioGroup } from \"./ContextMenuRadioGroup.vue\"\nexport { default as ContextMenuRadioItem } from \"./ContextMenuRadioItem.vue\"\nexport { default as ContextMenuSeparator } from \"./ContextMenuSeparator.vue\"\nexport { default as ContextMenuShortcut } from \"./ContextMenuShortcut.vue\"\nexport { default as ContextMenuSub } from \"./ContextMenuSub.vue\"\nexport { default as ContextMenuSubContent } from \"./ContextMenuSubContent.vue\"\nexport { default as ContextMenuSubTrigger } from \"./ContextMenuSubTrigger.vue\"\nexport { default as ContextMenuTrigger } from \"./ContextMenuTrigger.vue\"\n"
  },
  {
    "path": "web/src/components/ui/dialog/Dialog.vue",
    "content": "<script setup lang=\"ts\">\nimport type { DialogRootEmits, DialogRootProps } from 'reka-ui'\nimport { DialogRoot, useForwardPropsEmits } from 'reka-ui'\n\nconst props = defineProps<DialogRootProps>()\nconst emits = defineEmits<DialogRootEmits>()\n\nconst forwarded = useForwardPropsEmits(props, emits)\n</script>\n\n<template>\n  <DialogRoot v-slot=\"slotProps\" data-slot=\"dialog\" v-bind=\"forwarded\">\n    <slot v-bind=\"slotProps\" />\n  </DialogRoot>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/dialog/DialogClose.vue",
    "content": "<script setup lang=\"ts\">\nimport type { DialogCloseProps } from 'reka-ui'\nimport { DialogClose } from 'reka-ui'\n\nconst props = defineProps<DialogCloseProps>()\n</script>\n\n<template>\n  <DialogClose data-slot=\"dialog-close\" v-bind=\"props\">\n    <slot />\n  </DialogClose>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/dialog/DialogContent.vue",
    "content": "<script setup lang=\"ts\">\nimport type { DialogContentEmits, DialogContentProps } from 'reka-ui'\nimport type { HTMLAttributes } from 'vue'\nimport { reactiveOmit } from '@vueuse/core'\nimport { X } from 'lucide-vue-next'\nimport { DialogClose, DialogContent, DialogPortal, useForwardPropsEmits } from 'reka-ui'\nimport { cn } from '@/lib/utils'\nimport DialogOverlay from './DialogOverlay.vue'\n\ndefineOptions({\n  inheritAttrs: false,\n})\n\nconst props = withDefaults(\n  defineProps<\n    DialogContentProps & { class?: HTMLAttributes['class']; showCloseButton?: boolean }\n  >(),\n  {\n    showCloseButton: true,\n  }\n)\nconst emits = defineEmits<DialogContentEmits>()\n\nconst delegatedProps = reactiveOmit(props, 'class')\n\nconst forwarded = useForwardPropsEmits(delegatedProps, emits)\n</script>\n\n<template>\n  <DialogPortal>\n    <DialogOverlay />\n    <DialogContent\n      data-slot=\"dialog-content\"\n      v-bind=\"{ ...$attrs, ...forwarded }\"\n      :class=\"\n        cn(\n          'fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg',\n          props.class\n        )\n      \"\n    >\n      <slot />\n\n      <DialogClose\n        v-if=\"showCloseButton\"\n        data-slot=\"dialog-close\"\n        class=\"absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\"\n      >\n        <X />\n        <span class=\"sr-only\">Close</span>\n      </DialogClose>\n    </DialogContent>\n  </DialogPortal>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/dialog/DialogDescription.vue",
    "content": "<script setup lang=\"ts\">\nimport type { DialogDescriptionProps } from 'reka-ui'\nimport type { HTMLAttributes } from 'vue'\nimport { reactiveOmit } from '@vueuse/core'\nimport { DialogDescription, useForwardProps } from 'reka-ui'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes['class'] }>()\n\nconst delegatedProps = reactiveOmit(props, 'class')\n\nconst forwardedProps = useForwardProps(delegatedProps)\n</script>\n\n<template>\n  <DialogDescription\n    data-slot=\"dialog-description\"\n    v-bind=\"forwardedProps\"\n    :class=\"cn('text-sm text-muted-foreground', props.class)\"\n  >\n    <slot />\n  </DialogDescription>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/dialog/DialogFooter.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from 'vue'\nimport { DialogClose } from 'reka-ui'\nimport { cn } from '@/lib/utils'\nimport { Button } from '@/components/ui/button'\n\nconst props = withDefaults(\n  defineProps<{\n    class?: HTMLAttributes['class']\n    showCloseButton?: boolean\n  }>(),\n  {\n    showCloseButton: false,\n  }\n)\n</script>\n\n<template>\n  <div\n    data-slot=\"dialog-footer\"\n    :class=\"cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', props.class)\"\n  >\n    <slot />\n    <DialogClose v-if=\"showCloseButton\" as-child>\n      <Button variant=\"outline\"> Close </Button>\n    </DialogClose>\n  </div>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/dialog/DialogHeader.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from 'vue'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<{\n  class?: HTMLAttributes['class']\n}>()\n</script>\n\n<template>\n  <div\n    data-slot=\"dialog-header\"\n    :class=\"cn('flex flex-col gap-2 text-center sm:text-left', props.class)\"\n  >\n    <slot />\n  </div>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/dialog/DialogOverlay.vue",
    "content": "<script setup lang=\"ts\">\nimport type { DialogOverlayProps } from 'reka-ui'\nimport type { HTMLAttributes } from 'vue'\nimport { reactiveOmit } from '@vueuse/core'\nimport { DialogOverlay } from 'reka-ui'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<DialogOverlayProps & { class?: HTMLAttributes['class'] }>()\n\nconst delegatedProps = reactiveOmit(props, 'class')\n</script>\n\n<template>\n  <DialogOverlay\n    data-slot=\"dialog-overlay\"\n    v-bind=\"delegatedProps\"\n    :class=\"\n      cn(\n        'fixed inset-0 z-50 bg-black/80 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',\n        props.class\n      )\n    \"\n  >\n    <slot />\n  </DialogOverlay>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/dialog/DialogScrollContent.vue",
    "content": "<script setup lang=\"ts\">\nimport type { DialogContentEmits, DialogContentProps } from 'reka-ui'\nimport type { HTMLAttributes } from 'vue'\nimport { reactiveOmit } from '@vueuse/core'\nimport { X } from 'lucide-vue-next'\nimport {\n  DialogClose,\n  DialogContent,\n  DialogOverlay,\n  DialogPortal,\n  useForwardPropsEmits,\n} from 'reka-ui'\nimport { cn } from '@/lib/utils'\n\ndefineOptions({\n  inheritAttrs: false,\n})\n\nconst props = defineProps<DialogContentProps & { class?: HTMLAttributes['class'] }>()\nconst emits = defineEmits<DialogContentEmits>()\n\nconst delegatedProps = reactiveOmit(props, 'class')\n\nconst forwarded = useForwardPropsEmits(delegatedProps, emits)\n</script>\n\n<template>\n  <DialogPortal>\n    <DialogOverlay\n      class=\"fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0\"\n    >\n      <DialogContent\n        :class=\"\n          cn(\n            'relative z-50 my-8 grid w-full max-w-lg gap-4 border border-border bg-background p-6 shadow-lg duration-200 sm:rounded-lg md:w-full',\n            props.class\n          )\n        \"\n        v-bind=\"{ ...$attrs, ...forwarded }\"\n        @pointer-down-outside=\"\n          (event) => {\n            const originalEvent = event.detail.originalEvent\n            const target = originalEvent.target as HTMLElement\n            if (\n              originalEvent.offsetX > target.clientWidth ||\n              originalEvent.offsetY > target.clientHeight\n            ) {\n              event.preventDefault()\n            }\n          }\n        \"\n      >\n        <slot />\n\n        <DialogClose\n          class=\"absolute top-4 right-4 rounded-md p-0.5 transition-colors hover:bg-secondary\"\n        >\n          <X class=\"h-4 w-4\" />\n          <span class=\"sr-only\">Close</span>\n        </DialogClose>\n      </DialogContent>\n    </DialogOverlay>\n  </DialogPortal>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/dialog/DialogTitle.vue",
    "content": "<script setup lang=\"ts\">\nimport type { DialogTitleProps } from 'reka-ui'\nimport type { HTMLAttributes } from 'vue'\nimport { reactiveOmit } from '@vueuse/core'\nimport { DialogTitle, useForwardProps } from 'reka-ui'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<DialogTitleProps & { class?: HTMLAttributes['class'] }>()\n\nconst delegatedProps = reactiveOmit(props, 'class')\n\nconst forwardedProps = useForwardProps(delegatedProps)\n</script>\n\n<template>\n  <DialogTitle\n    data-slot=\"dialog-title\"\n    v-bind=\"forwardedProps\"\n    :class=\"cn('text-lg leading-none font-semibold', props.class)\"\n  >\n    <slot />\n  </DialogTitle>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/dialog/DialogTrigger.vue",
    "content": "<script setup lang=\"ts\">\nimport type { DialogTriggerProps } from 'reka-ui'\nimport { DialogTrigger } from 'reka-ui'\n\nconst props = defineProps<DialogTriggerProps>()\n</script>\n\n<template>\n  <DialogTrigger data-slot=\"dialog-trigger\" v-bind=\"props\">\n    <slot />\n  </DialogTrigger>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/dialog/index.ts",
    "content": "export { default as Dialog } from './Dialog.vue'\nexport { default as DialogClose } from './DialogClose.vue'\nexport { default as DialogContent } from './DialogContent.vue'\nexport { default as DialogDescription } from './DialogDescription.vue'\nexport { default as DialogFooter } from './DialogFooter.vue'\nexport { default as DialogHeader } from './DialogHeader.vue'\nexport { default as DialogOverlay } from './DialogOverlay.vue'\nexport { default as DialogScrollContent } from './DialogScrollContent.vue'\nexport { default as DialogTitle } from './DialogTitle.vue'\nexport { default as DialogTrigger } from './DialogTrigger.vue'\n"
  },
  {
    "path": "web/src/components/ui/dropdown-menu/DropdownMenu.vue",
    "content": "<script setup lang=\"ts\">\nimport type { DropdownMenuRootEmits, DropdownMenuRootProps } from \"reka-ui\"\nimport { DropdownMenuRoot, useForwardPropsEmits } from \"reka-ui\"\n\nconst props = defineProps<DropdownMenuRootProps>()\nconst emits = defineEmits<DropdownMenuRootEmits>()\n\nconst forwarded = useForwardPropsEmits(props, emits)\n</script>\n\n<template>\n  <DropdownMenuRoot\n    data-slot=\"dropdown-menu\"\n    v-bind=\"forwarded\"\n  >\n    <slot />\n  </DropdownMenuRoot>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue",
    "content": "<script setup lang=\"ts\">\nimport type { DropdownMenuCheckboxItemEmits, DropdownMenuCheckboxItemProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\"\nimport { reactiveOmit } from \"@vueuse/core\"\nimport { Check } from \"lucide-vue-next\"\nimport {\n  DropdownMenuCheckboxItem,\n\n  DropdownMenuItemIndicator,\n  useForwardPropsEmits,\n} from \"reka-ui\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<DropdownMenuCheckboxItemProps & { class?: HTMLAttributes[\"class\"] }>()\nconst emits = defineEmits<DropdownMenuCheckboxItemEmits>()\n\nconst delegatedProps = reactiveOmit(props, \"class\")\n\nconst forwarded = useForwardPropsEmits(delegatedProps, emits)\n</script>\n\n<template>\n  <DropdownMenuCheckboxItem\n    data-slot=\"dropdown-menu-checkbox-item\"\n    v-bind=\"forwarded\"\n    :class=\" cn(\n      'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\\'size-\\'])]:size-4',\n      props.class,\n    )\"\n  >\n    <span class=\"pointer-events-none absolute left-2 flex size-3.5 items-center justify-center\">\n      <DropdownMenuItemIndicator>\n        <Check class=\"size-4\" />\n      </DropdownMenuItemIndicator>\n    </span>\n    <slot />\n  </DropdownMenuCheckboxItem>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/dropdown-menu/DropdownMenuContent.vue",
    "content": "<script setup lang=\"ts\">\nimport type { DropdownMenuContentEmits, DropdownMenuContentProps } from 'reka-ui'\nimport type { HTMLAttributes } from 'vue'\nimport { useAttrs } from 'vue'\nimport { reactiveOmit } from '@vueuse/core'\nimport { DropdownMenuContent, DropdownMenuPortal, useForwardPropsEmits } from 'reka-ui'\nimport { cn } from '@/lib/utils'\n\ndefineOptions({\n  inheritAttrs: false,\n})\n\nconst props = withDefaults(\n  defineProps<DropdownMenuContentProps & { class?: HTMLAttributes['class'] }>(),\n  {\n    sideOffset: 4,\n  }\n)\nconst emits = defineEmits<DropdownMenuContentEmits>()\n\nconst delegatedProps = reactiveOmit(props, 'class')\n\nconst forwarded = useForwardPropsEmits(delegatedProps, emits)\nconst attrs = useAttrs()\n</script>\n\n<template>\n  <DropdownMenuPortal>\n    <DropdownMenuContent\n      data-slot=\"dropdown-menu-content\"\n      v-bind=\"{ ...forwarded, ...attrs }\"\n      :class=\"\n        cn(\n          'z-50 max-h-(--reka-dropdown-menu-content-available-height) min-w-[8rem] origin-(--reka-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',\n          props.class\n        )\n      \"\n    >\n      <slot />\n    </DropdownMenuContent>\n  </DropdownMenuPortal>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/dropdown-menu/DropdownMenuGroup.vue",
    "content": "<script setup lang=\"ts\">\nimport type { DropdownMenuGroupProps } from \"reka-ui\"\nimport { DropdownMenuGroup } from \"reka-ui\"\n\nconst props = defineProps<DropdownMenuGroupProps>()\n</script>\n\n<template>\n  <DropdownMenuGroup\n    data-slot=\"dropdown-menu-group\"\n    v-bind=\"props\"\n  >\n    <slot />\n  </DropdownMenuGroup>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/dropdown-menu/DropdownMenuItem.vue",
    "content": "<script setup lang=\"ts\">\nimport type { DropdownMenuItemProps } from 'reka-ui'\nimport type { HTMLAttributes } from 'vue'\nimport { reactiveOmit } from '@vueuse/core'\nimport { DropdownMenuItem, useForwardProps } from 'reka-ui'\nimport { cn } from '@/lib/utils'\n\nconst props = withDefaults(\n  defineProps<\n    DropdownMenuItemProps & {\n      class?: HTMLAttributes['class']\n      inset?: boolean\n      variant?: 'default' | 'destructive'\n    }\n  >(),\n  {\n    variant: 'default',\n  }\n)\n\nconst delegatedProps = reactiveOmit(props, 'inset', 'variant', 'class')\n\nconst forwardedProps = useForwardProps(delegatedProps)\n</script>\n\n<template>\n  <DropdownMenuItem\n    data-slot=\"dropdown-menu-item\"\n    :data-inset=\"inset ? '' : undefined\"\n    :data-variant=\"variant\"\n    v-bind=\"forwardedProps\"\n    :class=\"\n      cn(\n        'relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\\'size-\\'])]:size-4 [&_svg:not([class*=\\'text-\\'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:!text-destructive',\n        props.class\n      )\n    \"\n  >\n    <slot />\n  </DropdownMenuItem>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/dropdown-menu/DropdownMenuLabel.vue",
    "content": "<script setup lang=\"ts\">\nimport type { DropdownMenuLabelProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\"\nimport { reactiveOmit } from \"@vueuse/core\"\nimport { DropdownMenuLabel, useForwardProps } from \"reka-ui\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<DropdownMenuLabelProps & { class?: HTMLAttributes[\"class\"], inset?: boolean }>()\n\nconst delegatedProps = reactiveOmit(props, \"class\", \"inset\")\nconst forwardedProps = useForwardProps(delegatedProps)\n</script>\n\n<template>\n  <DropdownMenuLabel\n    data-slot=\"dropdown-menu-label\"\n    :data-inset=\"inset ? '' : undefined\"\n    v-bind=\"forwardedProps\"\n    :class=\"cn('px-2 py-1.5 text-sm font-medium data-[inset]:pl-8', props.class)\"\n  >\n    <slot />\n  </DropdownMenuLabel>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue",
    "content": "<script setup lang=\"ts\">\nimport type { DropdownMenuRadioGroupEmits, DropdownMenuRadioGroupProps } from \"reka-ui\"\nimport {\n  DropdownMenuRadioGroup,\n\n  useForwardPropsEmits,\n} from \"reka-ui\"\n\nconst props = defineProps<DropdownMenuRadioGroupProps>()\nconst emits = defineEmits<DropdownMenuRadioGroupEmits>()\n\nconst forwarded = useForwardPropsEmits(props, emits)\n</script>\n\n<template>\n  <DropdownMenuRadioGroup\n    data-slot=\"dropdown-menu-radio-group\"\n    v-bind=\"forwarded\"\n  >\n    <slot />\n  </DropdownMenuRadioGroup>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/dropdown-menu/DropdownMenuRadioItem.vue",
    "content": "<script setup lang=\"ts\">\nimport type { DropdownMenuRadioItemEmits, DropdownMenuRadioItemProps } from 'reka-ui'\nimport type { HTMLAttributes } from 'vue'\nimport { reactiveOmit } from '@vueuse/core'\nimport { Check } from 'lucide-vue-next'\nimport { DropdownMenuItemIndicator, DropdownMenuRadioItem, useForwardPropsEmits } from 'reka-ui'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<DropdownMenuRadioItemProps & { class?: HTMLAttributes['class'] }>()\n\nconst emits = defineEmits<DropdownMenuRadioItemEmits>()\n\nconst delegatedProps = reactiveOmit(props, 'class')\n\nconst forwarded = useForwardPropsEmits(delegatedProps, emits)\n</script>\n\n<template>\n  <DropdownMenuRadioItem\n    data-slot=\"dropdown-menu-radio-item\"\n    v-bind=\"forwarded\"\n    :class=\"\n      cn(\n        'relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\\'size-\\'])]:size-4',\n        props.class\n      )\n    \"\n  >\n    <span class=\"pointer-events-none absolute left-2 flex size-3.5 items-center justify-center\">\n      <DropdownMenuItemIndicator>\n        <Check class=\"size-3\" />\n      </DropdownMenuItemIndicator>\n    </span>\n    <slot />\n  </DropdownMenuRadioItem>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/dropdown-menu/DropdownMenuSeparator.vue",
    "content": "<script setup lang=\"ts\">\nimport type { DropdownMenuSeparatorProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\"\nimport { reactiveOmit } from \"@vueuse/core\"\nimport {\n  DropdownMenuSeparator,\n\n} from \"reka-ui\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<DropdownMenuSeparatorProps & {\n  class?: HTMLAttributes[\"class\"]\n}>()\n\nconst delegatedProps = reactiveOmit(props, \"class\")\n</script>\n\n<template>\n  <DropdownMenuSeparator\n    data-slot=\"dropdown-menu-separator\"\n    v-bind=\"delegatedProps\"\n    :class=\"cn('bg-border -mx-1 my-1 h-px', props.class)\"\n  />\n</template>\n"
  },
  {
    "path": "web/src/components/ui/dropdown-menu/DropdownMenuShortcut.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from \"vue\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<{\n  class?: HTMLAttributes[\"class\"]\n}>()\n</script>\n\n<template>\n  <span\n    data-slot=\"dropdown-menu-shortcut\"\n    :class=\"cn('text-muted-foreground ml-auto text-xs tracking-widest', props.class)\"\n  >\n    <slot />\n  </span>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/dropdown-menu/DropdownMenuSub.vue",
    "content": "<script setup lang=\"ts\">\nimport type { DropdownMenuSubEmits, DropdownMenuSubProps } from \"reka-ui\"\nimport {\n  DropdownMenuSub,\n\n  useForwardPropsEmits,\n} from \"reka-ui\"\n\nconst props = defineProps<DropdownMenuSubProps>()\nconst emits = defineEmits<DropdownMenuSubEmits>()\n\nconst forwarded = useForwardPropsEmits(props, emits)\n</script>\n\n<template>\n  <DropdownMenuSub data-slot=\"dropdown-menu-sub\" v-bind=\"forwarded\">\n    <slot />\n  </DropdownMenuSub>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/dropdown-menu/DropdownMenuSubContent.vue",
    "content": "<script setup lang=\"ts\">\nimport type { DropdownMenuSubContentEmits, DropdownMenuSubContentProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\"\nimport { reactiveOmit } from \"@vueuse/core\"\nimport {\n  DropdownMenuSubContent,\n\n  useForwardPropsEmits,\n} from \"reka-ui\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<DropdownMenuSubContentProps & { class?: HTMLAttributes[\"class\"] }>()\nconst emits = defineEmits<DropdownMenuSubContentEmits>()\n\nconst delegatedProps = reactiveOmit(props, \"class\")\n\nconst forwarded = useForwardPropsEmits(delegatedProps, emits)\n</script>\n\n<template>\n  <DropdownMenuSubContent\n    data-slot=\"dropdown-menu-sub-content\"\n    v-bind=\"forwarded\"\n    :class=\"cn('bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--reka-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg', props.class)\"\n  >\n    <slot />\n  </DropdownMenuSubContent>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue",
    "content": "<script setup lang=\"ts\">\nimport type { DropdownMenuSubTriggerProps } from 'reka-ui'\nimport type { HTMLAttributes } from 'vue'\nimport { reactiveOmit } from '@vueuse/core'\nimport { ChevronRight } from 'lucide-vue-next'\nimport { DropdownMenuSubTrigger, useForwardProps } from 'reka-ui'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<\n  DropdownMenuSubTriggerProps & { class?: HTMLAttributes['class']; inset?: boolean }\n>()\n\nconst delegatedProps = reactiveOmit(props, 'class', 'inset')\nconst forwardedProps = useForwardProps(delegatedProps)\n</script>\n\n<template>\n  <DropdownMenuSubTrigger\n    data-slot=\"dropdown-menu-sub-trigger\"\n    v-bind=\"forwardedProps\"\n    :class=\"\n      cn(\n        'flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\\'size-\\'])]:size-4',\n        props.class\n      )\n    \"\n  >\n    <slot />\n    <ChevronRight class=\"ml-auto size-4\" />\n  </DropdownMenuSubTrigger>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/dropdown-menu/DropdownMenuTrigger.vue",
    "content": "<script setup lang=\"ts\">\nimport type { DropdownMenuTriggerProps } from \"reka-ui\"\nimport { DropdownMenuTrigger, useForwardProps } from \"reka-ui\"\n\nconst props = defineProps<DropdownMenuTriggerProps>()\n\nconst forwardedProps = useForwardProps(props)\n</script>\n\n<template>\n  <DropdownMenuTrigger\n    data-slot=\"dropdown-menu-trigger\"\n    v-bind=\"forwardedProps\"\n  >\n    <slot />\n  </DropdownMenuTrigger>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/dropdown-menu/index.ts",
    "content": "export { default as DropdownMenu } from \"./DropdownMenu.vue\"\n\nexport { default as DropdownMenuCheckboxItem } from \"./DropdownMenuCheckboxItem.vue\"\nexport { default as DropdownMenuContent } from \"./DropdownMenuContent.vue\"\nexport { default as DropdownMenuGroup } from \"./DropdownMenuGroup.vue\"\nexport { default as DropdownMenuItem } from \"./DropdownMenuItem.vue\"\nexport { default as DropdownMenuLabel } from \"./DropdownMenuLabel.vue\"\nexport { default as DropdownMenuRadioGroup } from \"./DropdownMenuRadioGroup.vue\"\nexport { default as DropdownMenuRadioItem } from \"./DropdownMenuRadioItem.vue\"\nexport { default as DropdownMenuSeparator } from \"./DropdownMenuSeparator.vue\"\nexport { default as DropdownMenuShortcut } from \"./DropdownMenuShortcut.vue\"\nexport { default as DropdownMenuSub } from \"./DropdownMenuSub.vue\"\nexport { default as DropdownMenuSubContent } from \"./DropdownMenuSubContent.vue\"\nexport { default as DropdownMenuSubTrigger } from \"./DropdownMenuSubTrigger.vue\"\nexport { default as DropdownMenuTrigger } from \"./DropdownMenuTrigger.vue\"\nexport { DropdownMenuPortal } from \"reka-ui\"\n"
  },
  {
    "path": "web/src/components/ui/input/Input.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from 'vue'\nimport { useVModel } from '@vueuse/core'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<{\n  defaultValue?: string | number\n  modelValue?: string | number\n  class?: HTMLAttributes['class']\n}>()\n\nconst emits = defineEmits<{\n  (e: 'update:modelValue', payload: string | number): void\n}>()\n\nconst modelValue = useVModel(props, 'modelValue', emits, {\n  passive: true,\n  defaultValue: props.defaultValue,\n})\n</script>\n\n<template>\n  <input\n    v-model=\"modelValue\"\n    data-slot=\"input\"\n    :class=\"\n      cn(\n        'flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30',\n        'focus-visible:border-ring focus-visible:ring-[1px] focus-visible:ring-ring/50',\n        'aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40',\n        props.class\n      )\n    \"\n  />\n</template>\n"
  },
  {
    "path": "web/src/components/ui/input/index.ts",
    "content": "export { default as Input } from \"./Input.vue\"\n"
  },
  {
    "path": "web/src/components/ui/item/Item.vue",
    "content": "<script setup lang=\"ts\">\nimport type { PrimitiveProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\"\nimport type { ItemVariants } from \".\"\nimport { Primitive } from \"reka-ui\"\nimport { cn } from \"@/lib/utils\"\nimport { itemVariants } from \".\"\n\nconst props = withDefaults(defineProps<PrimitiveProps & {\n  class?: HTMLAttributes[\"class\"]\n  variant?: ItemVariants[\"variant\"]\n  size?: ItemVariants[\"size\"]\n}>(), {\n  as: \"div\",\n})\n</script>\n\n<template>\n  <Primitive\n    data-slot=\"item\"\n    :as=\"as\"\n    :as-child=\"asChild\"\n    :class=\"cn(itemVariants({ variant, size }), props.class)\"\n  >\n    <slot />\n  </Primitive>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/item/ItemActions.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from \"vue\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<{\n  class?: HTMLAttributes[\"class\"]\n}>()\n</script>\n\n<template>\n  <div\n    data-slot=\"item-actions\"\n    :class=\"cn('flex items-center gap-2', props.class)\"\n  >\n    <slot />\n  </div>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/item/ItemContent.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from \"vue\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<{\n  class?: HTMLAttributes[\"class\"]\n}>()\n</script>\n\n<template>\n  <div\n    data-slot=\"item-content\"\n    :class=\"cn('flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none', props.class)\"\n  >\n    <slot />\n  </div>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/item/ItemDescription.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from \"vue\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<{\n  class?: HTMLAttributes[\"class\"]\n}>()\n</script>\n\n<template>\n  <p\n    data-slot=\"item-description\"\n    :class=\"cn(\n      'text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance',\n      '[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4',\n      props.class,\n    )\"\n  >\n    <slot />\n  </p>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/item/ItemFooter.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from \"vue\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<{\n  class?: HTMLAttributes[\"class\"]\n}>()\n</script>\n\n<template>\n  <div\n    data-slot=\"item-footer\"\n    :class=\"cn('flex basis-full items-center justify-between gap-2', props.class)\"\n  >\n    <slot />\n  </div>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/item/ItemGroup.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from \"vue\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<{\n  class?: HTMLAttributes[\"class\"]\n}>()\n</script>\n\n<template>\n  <div\n    role=\"list\"\n    data-slot=\"item-group\"\n    :class=\"cn(\n      'group/item-group flex flex-col',\n      '[&>[data-slot=item]:not(:first-child)]:border-t-0',\n      '[&>[data-slot=item]:not(:first-child):not(:last-child)]:rounded-none',\n      '[&>[data-slot=item]:first-child]:rounded-b-none',\n      '[&>[data-slot=item]:last-child]:rounded-t-none',\n      props.class\n    )\"\n  >\n    <slot />\n  </div>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/item/ItemHeader.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from \"vue\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<{\n  class?: HTMLAttributes[\"class\"]\n}>()\n</script>\n\n<template>\n  <div\n    data-slot=\"item-header\"\n    :class=\"cn('flex basis-full items-center justify-between gap-2', props.class)\"\n  >\n    <slot />\n  </div>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/item/ItemMedia.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from \"vue\"\nimport type { ItemMediaVariants } from \".\"\nimport { cn } from \"@/lib/utils\"\nimport { itemMediaVariants } from \".\"\n\nconst props = defineProps<{\n  class?: HTMLAttributes[\"class\"]\n  variant?: ItemMediaVariants[\"variant\"]\n}>()\n</script>\n\n<template>\n  <div\n    data-slot=\"item-media\"\n    :data-variant=\"props.variant\"\n    :class=\"cn(itemMediaVariants({ variant }), props.class)\"\n  >\n    <slot />\n  </div>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/item/ItemSeparator.vue",
    "content": "<script setup lang=\"ts\">\nimport type { SeparatorProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\"\nimport { cn } from \"@/lib/utils\"\nimport { Separator } from '@/components/ui/separator'\n\nconst props = defineProps<\n  SeparatorProps & { class?: HTMLAttributes[\"class\"] }\n>()\n</script>\n\n<template>\n  <Separator\n    data-slot=\"item-separator\"\n    orientation=\"horizontal\"\n    :class=\"cn('my-0', props.class)\"\n  />\n</template>\n"
  },
  {
    "path": "web/src/components/ui/item/ItemTitle.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from \"vue\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<{\n  class?: HTMLAttributes[\"class\"]\n}>()\n</script>\n\n<template>\n  <div\n    data-slot=\"item-title\"\n    :class=\"cn('flex w-fit items-center gap-2 text-sm leading-snug font-medium', props.class)\"\n  >\n    <slot />\n  </div>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/item/index.ts",
    "content": "import type { VariantProps } from 'class-variance-authority'\nimport { cva } from 'class-variance-authority'\n\nexport { default as Item } from './Item.vue'\nexport { default as ItemActions } from './ItemActions.vue'\nexport { default as ItemContent } from './ItemContent.vue'\nexport { default as ItemDescription } from './ItemDescription.vue'\nexport { default as ItemFooter } from './ItemFooter.vue'\nexport { default as ItemGroup } from './ItemGroup.vue'\nexport { default as ItemHeader } from './ItemHeader.vue'\nexport { default as ItemMedia } from './ItemMedia.vue'\nexport { default as ItemSeparator } from './ItemSeparator.vue'\nexport { default as ItemTitle } from './ItemTitle.vue'\n\nexport const itemVariants = cva(\n  'group/item flex items-center border border-transparent text-sm rounded-md transition-colors [a]:hover:bg-accent/50 [a]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',\n  {\n    variants: {\n      variant: {\n        default: 'bg-transparent',\n        outline: 'border-border',\n        muted: 'bg-muted/50',\n        surface: 'surface-top',\n      },\n      size: {\n        default: 'p-4 gap-4 ',\n        sm: 'py-3 px-4 gap-2.5',\n      },\n    },\n    defaultVariants: {\n      variant: 'default',\n      size: 'default',\n    },\n  }\n)\n\nexport const itemMediaVariants = cva(\n  'flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none group-has-[[data-slot=item-description]]/item:translate-y-0.5',\n  {\n    variants: {\n      variant: {\n        default: 'bg-transparent',\n        icon: \"size-8 border rounded-sm bg-muted [&_svg:not([class*='size-'])]:size-4\",\n        image: 'size-10 rounded-sm overflow-hidden [&_img]:size-full [&_img]:object-cover',\n      },\n    },\n    defaultVariants: {\n      variant: 'default',\n    },\n  }\n)\n\nexport type ItemVariants = VariantProps<typeof itemVariants>\nexport type ItemMediaVariants = VariantProps<typeof itemMediaVariants>\n"
  },
  {
    "path": "web/src/components/ui/label/Label.vue",
    "content": "<script setup lang=\"ts\">\nimport type { LabelProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\"\nimport { reactiveOmit } from \"@vueuse/core\"\nimport { Label } from \"reka-ui\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<LabelProps & { class?: HTMLAttributes[\"class\"] }>()\n\nconst delegatedProps = reactiveOmit(props, \"class\")\n</script>\n\n<template>\n  <Label\n    data-slot=\"label\"\n    v-bind=\"delegatedProps\"\n    :class=\"\n      cn(\n        'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',\n        props.class,\n      )\n    \"\n  >\n    <slot />\n  </Label>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/label/index.ts",
    "content": "export { default as Label } from \"./Label.vue\"\n"
  },
  {
    "path": "web/src/components/ui/popover/Popover.vue",
    "content": "<script setup lang=\"ts\">\nimport type { PopoverRootEmits, PopoverRootProps } from \"reka-ui\"\nimport { PopoverRoot, useForwardPropsEmits } from \"reka-ui\"\n\nconst props = defineProps<PopoverRootProps>()\nconst emits = defineEmits<PopoverRootEmits>()\n\nconst forwarded = useForwardPropsEmits(props, emits)\n</script>\n\n<template>\n  <PopoverRoot\n    data-slot=\"popover\"\n    v-bind=\"forwarded\"\n  >\n    <slot />\n  </PopoverRoot>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/popover/PopoverAnchor.vue",
    "content": "<script setup lang=\"ts\">\nimport type { PopoverAnchorProps } from \"reka-ui\"\nimport { PopoverAnchor } from \"reka-ui\"\n\nconst props = defineProps<PopoverAnchorProps>()\n</script>\n\n<template>\n  <PopoverAnchor\n    data-slot=\"popover-anchor\"\n    v-bind=\"props\"\n  >\n    <slot />\n  </PopoverAnchor>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/popover/PopoverContent.vue",
    "content": "<script setup lang=\"ts\">\nimport type { PopoverContentEmits, PopoverContentProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\"\nimport { reactiveOmit } from \"@vueuse/core\"\nimport {\n  PopoverContent,\n\n  PopoverPortal,\n  useForwardPropsEmits,\n} from \"reka-ui\"\nimport { cn } from \"@/lib/utils\"\n\ndefineOptions({\n  inheritAttrs: false,\n})\n\nconst props = withDefaults(\n  defineProps<PopoverContentProps & { class?: HTMLAttributes[\"class\"] }>(),\n  {\n    align: \"center\",\n    sideOffset: 4,\n  },\n)\nconst emits = defineEmits<PopoverContentEmits>()\n\nconst delegatedProps = reactiveOmit(props, \"class\")\n\nconst forwarded = useForwardPropsEmits(delegatedProps, emits)\n</script>\n\n<template>\n  <PopoverPortal>\n    <PopoverContent\n      data-slot=\"popover-content\"\n      v-bind=\"{ ...forwarded, ...$attrs }\"\n      :class=\"\n        cn(\n          'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 rounded-md border p-4 shadow-md origin-(--reka-popover-content-transform-origin) outline-hidden',\n          props.class,\n        )\n      \"\n    >\n      <slot />\n    </PopoverContent>\n  </PopoverPortal>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/popover/PopoverTrigger.vue",
    "content": "<script setup lang=\"ts\">\nimport type { PopoverTriggerProps } from \"reka-ui\"\nimport { PopoverTrigger } from \"reka-ui\"\n\nconst props = defineProps<PopoverTriggerProps>()\n</script>\n\n<template>\n  <PopoverTrigger\n    data-slot=\"popover-trigger\"\n    v-bind=\"props\"\n  >\n    <slot />\n  </PopoverTrigger>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/popover/index.ts",
    "content": "export { default as Popover } from \"./Popover.vue\"\nexport { default as PopoverAnchor } from \"./PopoverAnchor.vue\"\nexport { default as PopoverContent } from \"./PopoverContent.vue\"\nexport { default as PopoverTrigger } from \"./PopoverTrigger.vue\"\n"
  },
  {
    "path": "web/src/components/ui/scroll-area/ScrollArea.vue",
    "content": "<script setup lang=\"ts\">\nimport type { ScrollAreaRootProps } from 'reka-ui'\nimport type { HTMLAttributes } from 'vue'\nimport { ref, computed } from 'vue'\nimport { reactiveOmit, unrefElement } from '@vueuse/core'\nimport { ScrollAreaCorner, ScrollAreaRoot, ScrollAreaViewport } from 'reka-ui'\nimport { cn } from '@/lib/utils'\nimport ScrollBar from './ScrollBar.vue'\n\nconst props = defineProps<\n  ScrollAreaRootProps & {\n    class?: HTMLAttributes['class']\n  }\n>()\n\nconst delegatedProps = reactiveOmit(props, 'class')\n\n// 暴露内部 viewport 的 ref，用于虚拟滚动等需要直接访问滚动容器的场景\nconst viewportComponentRef = ref<InstanceType<typeof ScrollAreaViewport> | null>(null)\n\n// 通过 computed 获取真实的滚动容器元素\n// unrefElement 返回的是 slot 内容的根元素，需要获取其父元素（真正的滚动容器）\nconst viewportElement = computed(\n  () => unrefElement(viewportComponentRef)?.parentElement as HTMLElement | null\n)\n\ndefineExpose({\n  viewportElement,\n})\n</script>\n\n<template>\n  <ScrollAreaRoot\n    data-slot=\"scroll-area\"\n    v-bind=\"delegatedProps\"\n    :class=\"cn('relative flex flex-col', props.class)\"\n  >\n    <ScrollAreaViewport\n      ref=\"viewportComponentRef\"\n      data-slot=\"scroll-area-viewport\"\n      class=\"min-h-0 w-full flex-1 rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1\"\n    >\n      <slot />\n    </ScrollAreaViewport>\n    <slot name=\"scrollbar\">\n      <ScrollBar />\n    </slot>\n    <ScrollAreaCorner />\n  </ScrollAreaRoot>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/scroll-area/ScrollBar.vue",
    "content": "<script setup lang=\"ts\">\nimport type { ScrollAreaScrollbarProps } from 'reka-ui'\nimport type { HTMLAttributes } from 'vue'\nimport { reactiveOmit } from '@vueuse/core'\nimport { ScrollAreaScrollbar, ScrollAreaThumb } from 'reka-ui'\nimport { cn } from '@/lib/utils'\n\nconst props = withDefaults(\n  defineProps<\n    ScrollAreaScrollbarProps & {\n      class?: HTMLAttributes['class']\n      thumbClass?: HTMLAttributes['class']\n    }\n  >(),\n  {\n    orientation: 'vertical',\n  }\n)\n\nconst delegatedProps = reactiveOmit(props, 'class', 'thumbClass')\n</script>\n\n<template>\n  <ScrollAreaScrollbar\n    data-slot=\"scroll-area-scrollbar\"\n    v-bind=\"delegatedProps\"\n    :class=\"\n      cn(\n        'flex touch-none p-px transition-colors select-none',\n        orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent',\n        orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent',\n        props.class\n      )\n    \"\n  >\n    <ScrollAreaThumb\n      data-slot=\"scroll-area-thumb\"\n      :class=\"cn('relative flex-1 rounded-full bg-border', props.thumbClass)\"\n    />\n  </ScrollAreaScrollbar>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/scroll-area/index.ts",
    "content": "export { default as ScrollArea } from \"./ScrollArea.vue\"\nexport { default as ScrollBar } from \"./ScrollBar.vue\"\n"
  },
  {
    "path": "web/src/components/ui/select/Select.vue",
    "content": "<script setup lang=\"ts\">\nimport type { SelectRootEmits, SelectRootProps } from \"reka-ui\"\nimport { SelectRoot, useForwardPropsEmits } from \"reka-ui\"\n\nconst props = defineProps<SelectRootProps>()\nconst emits = defineEmits<SelectRootEmits>()\n\nconst forwarded = useForwardPropsEmits(props, emits)\n</script>\n\n<template>\n  <SelectRoot\n    data-slot=\"select\"\n    v-bind=\"forwarded\"\n  >\n    <slot />\n  </SelectRoot>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/select/SelectContent.vue",
    "content": "<script setup lang=\"ts\">\nimport type { SelectContentEmits, SelectContentProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\"\nimport { reactiveOmit } from \"@vueuse/core\"\nimport {\n  SelectContent,\n\n  SelectPortal,\n  SelectViewport,\n  useForwardPropsEmits,\n} from \"reka-ui\"\nimport { cn } from \"@/lib/utils\"\nimport { SelectScrollDownButton, SelectScrollUpButton } from \".\"\n\ndefineOptions({\n  inheritAttrs: false,\n})\n\nconst props = withDefaults(\n  defineProps<SelectContentProps & { class?: HTMLAttributes[\"class\"] }>(),\n  {\n    position: \"popper\",\n  },\n)\nconst emits = defineEmits<SelectContentEmits>()\n\nconst delegatedProps = reactiveOmit(props, \"class\")\n\nconst forwarded = useForwardPropsEmits(delegatedProps, emits)\n</script>\n\n<template>\n  <SelectPortal>\n    <SelectContent\n      data-slot=\"select-content\"\n      v-bind=\"{ ...forwarded, ...$attrs }\"\n      :class=\"cn(\n        'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--reka-select-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border shadow-md',\n        position === 'popper'\n          && 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',\n        props.class,\n      )\n      \"\n    >\n      <SelectScrollUpButton />\n      <SelectViewport :class=\"cn('p-1', position === 'popper' && 'h-[var(--reka-select-trigger-height)] w-full min-w-[var(--reka-select-trigger-width)] scroll-my-1')\">\n        <slot />\n      </SelectViewport>\n      <SelectScrollDownButton />\n    </SelectContent>\n  </SelectPortal>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/select/SelectGroup.vue",
    "content": "<script setup lang=\"ts\">\nimport type { SelectGroupProps } from \"reka-ui\"\nimport { SelectGroup } from \"reka-ui\"\n\nconst props = defineProps<SelectGroupProps>()\n</script>\n\n<template>\n  <SelectGroup\n    data-slot=\"select-group\"\n    v-bind=\"props\"\n  >\n    <slot />\n  </SelectGroup>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/select/SelectItem.vue",
    "content": "<script setup lang=\"ts\">\nimport type { SelectItemProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\"\nimport { reactiveOmit } from \"@vueuse/core\"\nimport { Check } from \"lucide-vue-next\"\nimport {\n  SelectItem,\n  SelectItemIndicator,\n\n  SelectItemText,\n  useForwardProps,\n} from \"reka-ui\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<SelectItemProps & { class?: HTMLAttributes[\"class\"] }>()\n\nconst delegatedProps = reactiveOmit(props, \"class\")\n\nconst forwardedProps = useForwardProps(delegatedProps)\n</script>\n\n<template>\n  <SelectItem\n    data-slot=\"select-item\"\n    v-bind=\"forwardedProps\"\n    :class=\"\n      cn(\n        'focus:bg-accent focus:text-accent-foreground [&_svg:not([class*=\\'text-\\'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\\'size-\\'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2',\n        props.class,\n      )\n    \"\n  >\n    <span class=\"absolute right-2 flex size-3.5 items-center justify-center\">\n      <SelectItemIndicator>\n        <Check class=\"size-4\" />\n      </SelectItemIndicator>\n    </span>\n\n    <SelectItemText>\n      <slot />\n    </SelectItemText>\n  </SelectItem>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/select/SelectItemText.vue",
    "content": "<script setup lang=\"ts\">\nimport type { SelectItemTextProps } from \"reka-ui\"\nimport { SelectItemText } from \"reka-ui\"\n\nconst props = defineProps<SelectItemTextProps>()\n</script>\n\n<template>\n  <SelectItemText\n    data-slot=\"select-item-text\"\n    v-bind=\"props\"\n  >\n    <slot />\n  </SelectItemText>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/select/SelectLabel.vue",
    "content": "<script setup lang=\"ts\">\nimport type { SelectLabelProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\"\nimport { SelectLabel } from \"reka-ui\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<SelectLabelProps & { class?: HTMLAttributes[\"class\"] }>()\n</script>\n\n<template>\n  <SelectLabel\n    data-slot=\"select-label\"\n    :class=\"cn('px-2 py-1.5 text-sm font-medium', props.class)\"\n  >\n    <slot />\n  </SelectLabel>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/select/SelectScrollDownButton.vue",
    "content": "<script setup lang=\"ts\">\nimport type { SelectScrollDownButtonProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\"\nimport { reactiveOmit } from \"@vueuse/core\"\nimport { ChevronDown } from \"lucide-vue-next\"\nimport { SelectScrollDownButton, useForwardProps } from \"reka-ui\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<SelectScrollDownButtonProps & { class?: HTMLAttributes[\"class\"] }>()\n\nconst delegatedProps = reactiveOmit(props, \"class\")\n\nconst forwardedProps = useForwardProps(delegatedProps)\n</script>\n\n<template>\n  <SelectScrollDownButton\n    data-slot=\"select-scroll-down-button\"\n    v-bind=\"forwardedProps\"\n    :class=\"cn('flex cursor-default items-center justify-center py-1', props.class)\"\n  >\n    <slot>\n      <ChevronDown class=\"size-4\" />\n    </slot>\n  </SelectScrollDownButton>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/select/SelectScrollUpButton.vue",
    "content": "<script setup lang=\"ts\">\nimport type { SelectScrollUpButtonProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\"\nimport { reactiveOmit } from \"@vueuse/core\"\nimport { ChevronUp } from \"lucide-vue-next\"\nimport { SelectScrollUpButton, useForwardProps } from \"reka-ui\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<SelectScrollUpButtonProps & { class?: HTMLAttributes[\"class\"] }>()\n\nconst delegatedProps = reactiveOmit(props, \"class\")\n\nconst forwardedProps = useForwardProps(delegatedProps)\n</script>\n\n<template>\n  <SelectScrollUpButton\n    data-slot=\"select-scroll-up-button\"\n    v-bind=\"forwardedProps\"\n    :class=\"cn('flex cursor-default items-center justify-center py-1', props.class)\"\n  >\n    <slot>\n      <ChevronUp class=\"size-4\" />\n    </slot>\n  </SelectScrollUpButton>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/select/SelectSeparator.vue",
    "content": "<script setup lang=\"ts\">\nimport type { SelectSeparatorProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\"\nimport { reactiveOmit } from \"@vueuse/core\"\nimport { SelectSeparator } from \"reka-ui\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<SelectSeparatorProps & { class?: HTMLAttributes[\"class\"] }>()\n\nconst delegatedProps = reactiveOmit(props, \"class\")\n</script>\n\n<template>\n  <SelectSeparator\n    data-slot=\"select-separator\"\n    v-bind=\"delegatedProps\"\n    :class=\"cn('bg-border pointer-events-none -mx-1 my-1 h-px', props.class)\"\n  />\n</template>\n"
  },
  {
    "path": "web/src/components/ui/select/SelectTrigger.vue",
    "content": "<script setup lang=\"ts\">\nimport type { SelectTriggerProps } from 'reka-ui'\nimport type { HTMLAttributes } from 'vue'\nimport { reactiveOmit } from '@vueuse/core'\nimport { ChevronDown } from 'lucide-vue-next'\nimport { SelectIcon, SelectTrigger, useForwardProps } from 'reka-ui'\nimport { cn } from '@/lib/utils'\n\nconst props = withDefaults(\n  defineProps<SelectTriggerProps & { class?: HTMLAttributes['class']; size?: 'sm' | 'default' }>(),\n  { size: 'default' }\n)\n\nconst delegatedProps = reactiveOmit(props, 'class', 'size')\nconst forwardedProps = useForwardProps(delegatedProps)\n</script>\n\n<template>\n  <SelectTrigger\n    data-slot=\"select-trigger\"\n    :data-size=\"size\"\n    v-bind=\"forwardedProps\"\n    :class=\"\n      cn(\n        'flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\\'size-\\'])]:size-4 [&_svg:not([class*=\\'text-\\'])]:text-muted-foreground',\n        props.class\n      )\n    \"\n  >\n    <slot />\n    <SelectIcon as-child>\n      <ChevronDown class=\"size-4 opacity-50\" />\n    </SelectIcon>\n  </SelectTrigger>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/select/SelectValue.vue",
    "content": "<script setup lang=\"ts\">\nimport type { SelectValueProps } from \"reka-ui\"\nimport { SelectValue } from \"reka-ui\"\n\nconst props = defineProps<SelectValueProps>()\n</script>\n\n<template>\n  <SelectValue\n    data-slot=\"select-value\"\n    v-bind=\"props\"\n  >\n    <slot />\n  </SelectValue>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/select/index.ts",
    "content": "export { default as Select } from \"./Select.vue\"\nexport { default as SelectContent } from \"./SelectContent.vue\"\nexport { default as SelectGroup } from \"./SelectGroup.vue\"\nexport { default as SelectItem } from \"./SelectItem.vue\"\nexport { default as SelectItemText } from \"./SelectItemText.vue\"\nexport { default as SelectLabel } from \"./SelectLabel.vue\"\nexport { default as SelectScrollDownButton } from \"./SelectScrollDownButton.vue\"\nexport { default as SelectScrollUpButton } from \"./SelectScrollUpButton.vue\"\nexport { default as SelectSeparator } from \"./SelectSeparator.vue\"\nexport { default as SelectTrigger } from \"./SelectTrigger.vue\"\nexport { default as SelectValue } from \"./SelectValue.vue\"\n"
  },
  {
    "path": "web/src/components/ui/separator/Separator.vue",
    "content": "<script setup lang=\"ts\">\nimport type { SeparatorProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\"\nimport { reactiveOmit } from \"@vueuse/core\"\nimport { Separator } from \"reka-ui\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = withDefaults(defineProps<\n  SeparatorProps & { class?: HTMLAttributes[\"class\"] }\n>(), {\n  orientation: \"horizontal\",\n  decorative: true,\n})\n\nconst delegatedProps = reactiveOmit(props, \"class\")\n</script>\n\n<template>\n  <Separator\n    data-slot=\"separator-root\"\n    v-bind=\"delegatedProps\"\n    :class=\"\n      cn(\n        'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',\n        props.class,\n      )\n    \"\n  />\n</template>\n"
  },
  {
    "path": "web/src/components/ui/separator/index.ts",
    "content": "export { default as Separator } from \"./Separator.vue\"\n"
  },
  {
    "path": "web/src/components/ui/sheet/Sheet.vue",
    "content": "<script setup lang=\"ts\">\nimport type { DialogRootEmits, DialogRootProps } from \"reka-ui\"\nimport { DialogRoot, useForwardPropsEmits } from \"reka-ui\"\n\nconst props = defineProps<DialogRootProps>()\nconst emits = defineEmits<DialogRootEmits>()\n\nconst forwarded = useForwardPropsEmits(props, emits)\n</script>\n\n<template>\n  <DialogRoot\n    data-slot=\"sheet\"\n    v-bind=\"forwarded\"\n  >\n    <slot />\n  </DialogRoot>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/sheet/SheetClose.vue",
    "content": "<script setup lang=\"ts\">\nimport type { DialogCloseProps } from \"reka-ui\"\nimport { DialogClose } from \"reka-ui\"\n\nconst props = defineProps<DialogCloseProps>()\n</script>\n\n<template>\n  <DialogClose\n    data-slot=\"sheet-close\"\n    v-bind=\"props\"\n  >\n    <slot />\n  </DialogClose>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/sheet/SheetContent.vue",
    "content": "<script setup lang=\"ts\">\nimport type { DialogContentEmits, DialogContentProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\"\nimport { reactiveOmit } from \"@vueuse/core\"\nimport { X } from \"lucide-vue-next\"\nimport {\n  DialogClose,\n  DialogContent,\n\n  DialogPortal,\n  useForwardPropsEmits,\n} from \"reka-ui\"\nimport { cn } from \"@/lib/utils\"\nimport SheetOverlay from \"./SheetOverlay.vue\"\n\ninterface SheetContentProps extends DialogContentProps {\n  class?: HTMLAttributes[\"class\"]\n  side?: \"top\" | \"right\" | \"bottom\" | \"left\"\n}\n\ndefineOptions({\n  inheritAttrs: false,\n})\n\nconst props = withDefaults(defineProps<SheetContentProps>(), {\n  side: \"right\",\n})\nconst emits = defineEmits<DialogContentEmits>()\n\nconst delegatedProps = reactiveOmit(props, \"class\", \"side\")\n\nconst forwarded = useForwardPropsEmits(delegatedProps, emits)\n</script>\n\n<template>\n  <DialogPortal>\n    <SheetOverlay />\n    <DialogContent\n      data-slot=\"sheet-content\"\n      :class=\"cn(\n        'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',\n        side === 'right'\n          && 'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm',\n        side === 'left'\n          && 'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',\n        side === 'top'\n          && 'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',\n        side === 'bottom'\n          && 'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',\n        props.class)\"\n      v-bind=\"{ ...forwarded, ...$attrs }\"\n    >\n      <slot />\n\n      <DialogClose\n        class=\"ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none\"\n      >\n        <X class=\"size-4\" />\n        <span class=\"sr-only\">Close</span>\n      </DialogClose>\n    </DialogContent>\n  </DialogPortal>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/sheet/SheetDescription.vue",
    "content": "<script setup lang=\"ts\">\nimport type { DialogDescriptionProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\"\nimport { reactiveOmit } from \"@vueuse/core\"\nimport { DialogDescription } from \"reka-ui\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes[\"class\"] }>()\n\nconst delegatedProps = reactiveOmit(props, \"class\")\n</script>\n\n<template>\n  <DialogDescription\n    data-slot=\"sheet-description\"\n    :class=\"cn('text-muted-foreground text-sm', props.class)\"\n    v-bind=\"delegatedProps\"\n  >\n    <slot />\n  </DialogDescription>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/sheet/SheetFooter.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from \"vue\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<{ class?: HTMLAttributes[\"class\"] }>()\n</script>\n\n<template>\n  <div\n    data-slot=\"sheet-footer\"\n    :class=\"cn('mt-auto flex flex-col gap-2 p-4', props.class)\n    \"\n  >\n    <slot />\n  </div>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/sheet/SheetHeader.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from \"vue\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<{ class?: HTMLAttributes[\"class\"] }>()\n</script>\n\n<template>\n  <div\n    data-slot=\"sheet-header\"\n    :class=\"cn('flex flex-col gap-1.5 p-4', props.class)\"\n  >\n    <slot />\n  </div>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/sheet/SheetOverlay.vue",
    "content": "<script setup lang=\"ts\">\nimport type { DialogOverlayProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\"\nimport { reactiveOmit } from \"@vueuse/core\"\nimport { DialogOverlay } from \"reka-ui\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<DialogOverlayProps & { class?: HTMLAttributes[\"class\"] }>()\n\nconst delegatedProps = reactiveOmit(props, \"class\")\n</script>\n\n<template>\n  <DialogOverlay\n    data-slot=\"sheet-overlay\"\n    :class=\"cn('data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80', props.class)\"\n    v-bind=\"delegatedProps\"\n  >\n    <slot />\n  </DialogOverlay>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/sheet/SheetTitle.vue",
    "content": "<script setup lang=\"ts\">\nimport type { DialogTitleProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\"\nimport { reactiveOmit } from \"@vueuse/core\"\nimport { DialogTitle } from \"reka-ui\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<DialogTitleProps & { class?: HTMLAttributes[\"class\"] }>()\n\nconst delegatedProps = reactiveOmit(props, \"class\")\n</script>\n\n<template>\n  <DialogTitle\n    data-slot=\"sheet-title\"\n    :class=\"cn('text-foreground font-semibold', props.class)\"\n    v-bind=\"delegatedProps\"\n  >\n    <slot />\n  </DialogTitle>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/sheet/SheetTrigger.vue",
    "content": "<script setup lang=\"ts\">\nimport type { DialogTriggerProps } from \"reka-ui\"\nimport { DialogTrigger } from \"reka-ui\"\n\nconst props = defineProps<DialogTriggerProps>()\n</script>\n\n<template>\n  <DialogTrigger\n    data-slot=\"sheet-trigger\"\n    v-bind=\"props\"\n  >\n    <slot />\n  </DialogTrigger>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/sheet/index.ts",
    "content": "export { default as Sheet } from \"./Sheet.vue\"\nexport { default as SheetClose } from \"./SheetClose.vue\"\nexport { default as SheetContent } from \"./SheetContent.vue\"\nexport { default as SheetDescription } from \"./SheetDescription.vue\"\nexport { default as SheetFooter } from \"./SheetFooter.vue\"\nexport { default as SheetHeader } from \"./SheetHeader.vue\"\nexport { default as SheetTitle } from \"./SheetTitle.vue\"\nexport { default as SheetTrigger } from \"./SheetTrigger.vue\"\n"
  },
  {
    "path": "web/src/components/ui/sidebar/Sidebar.vue",
    "content": "<script setup lang=\"ts\">\nimport type { SidebarProps } from \".\"\nimport { cn } from \"@/lib/utils\"\nimport { Sheet, SheetContent } from '@/components/ui/sheet'\nimport SheetDescription from '@/components/ui/sheet/SheetDescription.vue'\nimport SheetHeader from '@/components/ui/sheet/SheetHeader.vue'\nimport SheetTitle from '@/components/ui/sheet/SheetTitle.vue'\nimport { SIDEBAR_WIDTH_MOBILE, useSidebar } from \"./utils\"\n\ndefineOptions({\n  inheritAttrs: false,\n})\n\nconst props = withDefaults(defineProps<SidebarProps>(), {\n  side: \"left\",\n  variant: \"sidebar\",\n  collapsible: \"offcanvas\",\n})\n\nconst { isMobile, state, openMobile, setOpenMobile } = useSidebar()\n</script>\n\n<template>\n  <div\n    v-if=\"collapsible === 'none'\"\n    data-slot=\"sidebar\"\n    :class=\"cn('bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col', props.class)\"\n    v-bind=\"$attrs\"\n  >\n    <slot />\n  </div>\n\n  <Sheet v-else-if=\"isMobile\" :open=\"openMobile\" v-bind=\"$attrs\" @update:open=\"setOpenMobile\">\n    <SheetContent\n      data-sidebar=\"sidebar\"\n      data-slot=\"sidebar\"\n      data-mobile=\"true\"\n      :side=\"side\"\n      class=\"bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden\"\n      :style=\"{\n        '--sidebar-width': SIDEBAR_WIDTH_MOBILE,\n      }\"\n    >\n      <SheetHeader class=\"sr-only\">\n        <SheetTitle>Sidebar</SheetTitle>\n        <SheetDescription>Displays the mobile sidebar.</SheetDescription>\n      </SheetHeader>\n      <div class=\"flex h-full w-full flex-col\">\n        <slot />\n      </div>\n    </SheetContent>\n  </Sheet>\n\n  <div\n    v-else\n    class=\"group peer text-sidebar-foreground hidden md:block\"\n    data-slot=\"sidebar\"\n    :data-state=\"state\"\n    :data-collapsible=\"state === 'collapsed' ? collapsible : ''\"\n    :data-variant=\"variant\"\n    :data-side=\"side\"\n  >\n    <!-- This is what handles the sidebar gap on desktop  -->\n    <div\n      :class=\"cn(\n        'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear',\n        'group-data-[collapsible=offcanvas]:w-0',\n        'group-data-[side=right]:rotate-180',\n        variant === 'floating' || variant === 'inset'\n          ? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]'\n          : 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)',\n      )\"\n    />\n    <div\n      :class=\"cn(\n        'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex',\n        side === 'left'\n          ? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'\n          : 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',\n        // Adjust the padding for floating and inset variants.\n        variant === 'floating' || variant === 'inset'\n          ? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'\n          : 'group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l',\n        props.class,\n      )\"\n      v-bind=\"$attrs\"\n    >\n      <div\n        data-sidebar=\"sidebar\"\n        class=\"bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm\"\n      >\n        <slot />\n      </div>\n    </div>\n  </div>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/sidebar/SidebarContent.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from \"vue\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<{\n  class?: HTMLAttributes[\"class\"]\n}>()\n</script>\n\n<template>\n  <div\n    data-slot=\"sidebar-content\"\n    data-sidebar=\"content\"\n    :class=\"cn('flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden', props.class)\"\n  >\n    <slot />\n  </div>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/sidebar/SidebarFooter.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from \"vue\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<{\n  class?: HTMLAttributes[\"class\"]\n}>()\n</script>\n\n<template>\n  <div\n    data-slot=\"sidebar-footer\"\n    data-sidebar=\"footer\"\n    :class=\"cn('flex flex-col gap-2 p-2', props.class)\"\n  >\n    <slot />\n  </div>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/sidebar/SidebarGroup.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from \"vue\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<{\n  class?: HTMLAttributes[\"class\"]\n}>()\n</script>\n\n<template>\n  <div\n    data-slot=\"sidebar-group\"\n    data-sidebar=\"group\"\n    :class=\"cn('relative flex w-full min-w-0 flex-col p-2', props.class)\"\n  >\n    <slot />\n  </div>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/sidebar/SidebarGroupAction.vue",
    "content": "<script setup lang=\"ts\">\nimport type { PrimitiveProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\"\nimport { Primitive } from \"reka-ui\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<PrimitiveProps & {\n  class?: HTMLAttributes[\"class\"]\n}>()\n</script>\n\n<template>\n  <Primitive\n    data-slot=\"sidebar-group-action\"\n    data-sidebar=\"group-action\"\n    :as=\"as\"\n    :as-child=\"asChild\"\n    :class=\"cn(\n      'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',\n      'after:absolute after:-inset-2 md:after:hidden',\n      'group-data-[collapsible=icon]:hidden',\n      props.class,\n    )\"\n  >\n    <slot />\n  </Primitive>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/sidebar/SidebarGroupContent.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from \"vue\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<{\n  class?: HTMLAttributes[\"class\"]\n}>()\n</script>\n\n<template>\n  <div\n    data-slot=\"sidebar-group-content\"\n    data-sidebar=\"group-content\"\n    :class=\"cn('w-full text-sm', props.class)\"\n  >\n    <slot />\n  </div>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/sidebar/SidebarGroupLabel.vue",
    "content": "<script setup lang=\"ts\">\nimport type { PrimitiveProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\"\nimport { Primitive } from \"reka-ui\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<PrimitiveProps & {\n  class?: HTMLAttributes[\"class\"]\n}>()\n</script>\n\n<template>\n  <Primitive\n    data-slot=\"sidebar-group-label\"\n    data-sidebar=\"group-label\"\n    :as=\"as\"\n    :as-child=\"asChild\"\n    :class=\"cn(\n      'text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',\n      'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0',\n      props.class)\"\n  >\n    <slot />\n  </Primitive>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/sidebar/SidebarHeader.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from \"vue\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<{\n  class?: HTMLAttributes[\"class\"]\n}>()\n</script>\n\n<template>\n  <div\n    data-slot=\"sidebar-header\"\n    data-sidebar=\"header\"\n    :class=\"cn('flex flex-col gap-2 p-2', props.class)\"\n  >\n    <slot />\n  </div>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/sidebar/SidebarInput.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from \"vue\"\nimport { cn } from \"@/lib/utils\"\nimport { Input } from '@/components/ui/input'\n\nconst props = defineProps<{\n  class?: HTMLAttributes[\"class\"]\n}>()\n</script>\n\n<template>\n  <Input\n    data-slot=\"sidebar-input\"\n    data-sidebar=\"input\"\n    :class=\"cn(\n      'bg-background h-8 w-full shadow-none',\n      props.class,\n    )\"\n  >\n    <slot />\n  </Input>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/sidebar/SidebarInset.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from \"vue\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<{\n  class?: HTMLAttributes[\"class\"]\n}>()\n</script>\n\n<template>\n  <main\n    data-slot=\"sidebar-inset\"\n    :class=\"cn(\n      'bg-background relative flex w-full flex-1 flex-col',\n      'md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2',\n      props.class,\n    )\"\n  >\n    <slot />\n  </main>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/sidebar/SidebarMenu.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from \"vue\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<{\n  class?: HTMLAttributes[\"class\"]\n}>()\n</script>\n\n<template>\n  <ul\n    data-slot=\"sidebar-menu\"\n    data-sidebar=\"menu\"\n    :class=\"cn('flex w-full min-w-0 flex-col gap-1', props.class)\"\n  >\n    <slot />\n  </ul>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/sidebar/SidebarMenuAction.vue",
    "content": "<script setup lang=\"ts\">\nimport type { PrimitiveProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\"\nimport { Primitive } from \"reka-ui\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = withDefaults(defineProps<PrimitiveProps & {\n  showOnHover?: boolean\n  class?: HTMLAttributes[\"class\"]\n}>(), {\n  as: \"button\",\n})\n</script>\n\n<template>\n  <Primitive\n    data-slot=\"sidebar-menu-action\"\n    data-sidebar=\"menu-action\"\n    :class=\"cn(\n      'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',\n      'after:absolute after:-inset-2 md:after:hidden',\n      'peer-data-[size=sm]/menu-button:top-1',\n      'peer-data-[size=default]/menu-button:top-1.5',\n      'peer-data-[size=lg]/menu-button:top-2.5',\n      'group-data-[collapsible=icon]:hidden',\n      showOnHover\n        && 'peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0',\n      props.class,\n    )\"\n    :as=\"as\"\n    :as-child=\"asChild\"\n  >\n    <slot />\n  </Primitive>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/sidebar/SidebarMenuBadge.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from \"vue\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<{\n  class?: HTMLAttributes[\"class\"]\n}>()\n</script>\n\n<template>\n  <div\n    data-slot=\"sidebar-menu-badge\"\n    data-sidebar=\"menu-badge\"\n    :class=\"cn(\n      'text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none',\n      'peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground',\n      'peer-data-[size=sm]/menu-button:top-1',\n      'peer-data-[size=default]/menu-button:top-1.5',\n      'peer-data-[size=lg]/menu-button:top-2.5',\n      'group-data-[collapsible=icon]:hidden',\n      props.class,\n    )\"\n  >\n    <slot />\n  </div>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/sidebar/SidebarMenuButton.vue",
    "content": "<script setup lang=\"ts\">\nimport type { Component } from 'vue'\nimport type { SidebarMenuButtonProps } from './SidebarMenuButtonChild.vue'\nimport { reactiveOmit } from '@vueuse/core'\nimport { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'\nimport SidebarMenuButtonChild from './SidebarMenuButtonChild.vue'\nimport { useSidebar } from './utils'\n\ndefineOptions({\n  inheritAttrs: false,\n})\n\nconst props = withDefaults(\n  defineProps<\n    SidebarMenuButtonProps & {\n      tooltip?: string | Component\n      showTooltipWhenExpanded?: boolean\n      tooltipVariant?: 'default' | 'sidebar'\n    }\n  >(),\n  {\n    as: 'button',\n    variant: 'default',\n    size: 'default',\n    showTooltipWhenExpanded: false,\n    tooltipVariant: 'default',\n  }\n)\n\nconst { isMobile, state } = useSidebar()\n\nconst delegatedProps = reactiveOmit(props, 'tooltip', 'showTooltipWhenExpanded', 'tooltipVariant')\n</script>\n\n<template>\n  <SidebarMenuButtonChild v-if=\"!tooltip\" v-bind=\"{ ...delegatedProps, ...$attrs }\">\n    <slot />\n  </SidebarMenuButtonChild>\n\n  <Tooltip v-else>\n    <TooltipTrigger as-child>\n      <SidebarMenuButtonChild v-bind=\"{ ...delegatedProps, ...$attrs }\">\n        <slot />\n      </SidebarMenuButtonChild>\n    </TooltipTrigger>\n    <TooltipContent\n      side=\"right\"\n      align=\"center\"\n      :variant=\"tooltipVariant\"\n      :hidden=\"\n        (state !== 'collapsed' && !showTooltipWhenExpanded) ||\n        (isMobile && !showTooltipWhenExpanded)\n      \"\n    >\n      <template v-if=\"typeof tooltip === 'string'\">\n        {{ tooltip }}\n      </template>\n      <component :is=\"tooltip\" v-else />\n    </TooltipContent>\n  </Tooltip>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/sidebar/SidebarMenuButtonChild.vue",
    "content": "<script setup lang=\"ts\">\nimport type { PrimitiveProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\"\nimport type { SidebarMenuButtonVariants } from \".\"\nimport { Primitive } from \"reka-ui\"\nimport { cn } from \"@/lib/utils\"\nimport { sidebarMenuButtonVariants } from \".\"\n\nexport interface SidebarMenuButtonProps extends PrimitiveProps {\n  variant?: SidebarMenuButtonVariants[\"variant\"]\n  size?: SidebarMenuButtonVariants[\"size\"]\n  isActive?: boolean\n  class?: HTMLAttributes[\"class\"]\n}\n\nconst props = withDefaults(defineProps<SidebarMenuButtonProps>(), {\n  as: \"button\",\n  variant: \"default\",\n  size: \"default\",\n})\n</script>\n\n<template>\n  <Primitive\n    data-slot=\"sidebar-menu-button\"\n    data-sidebar=\"menu-button\"\n    :data-size=\"size\"\n    :data-active=\"isActive\"\n    :class=\"cn(sidebarMenuButtonVariants({ variant, size }), props.class)\"\n    :as=\"as\"\n    :as-child=\"asChild\"\n    v-bind=\"$attrs\"\n  >\n    <slot />\n  </Primitive>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/sidebar/SidebarMenuItem.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from \"vue\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<{\n  class?: HTMLAttributes[\"class\"]\n}>()\n</script>\n\n<template>\n  <li\n    data-slot=\"sidebar-menu-item\"\n    data-sidebar=\"menu-item\"\n    :class=\"cn('group/menu-item relative', props.class)\"\n  >\n    <slot />\n  </li>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/sidebar/SidebarMenuSkeleton.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from \"vue\"\nimport { computed } from \"vue\"\nimport { cn } from \"@/lib/utils\"\nimport { Skeleton } from '@/components/ui/skeleton'\n\nconst props = defineProps<{\n  showIcon?: boolean\n  class?: HTMLAttributes[\"class\"]\n}>()\n\nconst width = computed(() => {\n  return `${Math.floor(Math.random() * 40) + 50}%`\n})\n</script>\n\n<template>\n  <div\n    data-slot=\"sidebar-menu-skeleton\"\n    data-sidebar=\"menu-skeleton\"\n    :class=\"cn('flex h-8 items-center gap-2 rounded-md px-2', props.class)\"\n  >\n    <Skeleton\n      v-if=\"showIcon\"\n      class=\"size-4 rounded-md\"\n      data-sidebar=\"menu-skeleton-icon\"\n    />\n\n    <Skeleton\n      class=\"h-4 max-w-(--skeleton-width) flex-1\"\n      data-sidebar=\"menu-skeleton-text\"\n      :style=\"{ '--skeleton-width': width }\"\n    />\n  </div>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/sidebar/SidebarMenuSub.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from \"vue\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<{\n  class?: HTMLAttributes[\"class\"]\n}>()\n</script>\n\n<template>\n  <ul\n    data-slot=\"sidebar-menu-sub\"\n    data-sidebar=\"menu-badge\"\n    :class=\"cn(\n      'border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5',\n      'group-data-[collapsible=icon]:hidden',\n      props.class,\n    )\"\n  >\n    <slot />\n  </ul>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/sidebar/SidebarMenuSubButton.vue",
    "content": "<script setup lang=\"ts\">\nimport type { PrimitiveProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\"\nimport { Primitive } from \"reka-ui\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = withDefaults(defineProps<PrimitiveProps & {\n  size?: \"sm\" | \"md\"\n  isActive?: boolean\n  class?: HTMLAttributes[\"class\"]\n}>(), {\n  as: \"a\",\n  size: \"md\",\n})\n</script>\n\n<template>\n  <Primitive\n    data-slot=\"sidebar-menu-sub-button\"\n    data-sidebar=\"menu-sub-button\"\n    :as=\"as\"\n    :as-child=\"asChild\"\n    :data-size=\"size\"\n    :data-active=\"isActive\"\n    :class=\"cn(\n      'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',\n      'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground',\n      size === 'sm' && 'text-xs',\n      size === 'md' && 'text-sm',\n      'group-data-[collapsible=icon]:hidden',\n      props.class,\n    )\"\n  >\n    <slot />\n  </Primitive>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/sidebar/SidebarMenuSubItem.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from \"vue\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<{\n  class?: HTMLAttributes[\"class\"]\n}>()\n</script>\n\n<template>\n  <li\n    data-slot=\"sidebar-menu-sub-item\"\n    data-sidebar=\"menu-sub-item\"\n    :class=\"cn('group/menu-sub-item relative', props.class)\"\n  >\n    <slot />\n  </li>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/sidebar/SidebarProvider.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes, Ref } from \"vue\"\nimport { defaultDocument, useEventListener, useMediaQuery, useVModel } from \"@vueuse/core\"\nimport { TooltipProvider } from \"reka-ui\"\nimport { computed, ref } from \"vue\"\nimport { cn } from \"@/lib/utils\"\nimport { provideSidebarContext, SIDEBAR_COOKIE_MAX_AGE, SIDEBAR_COOKIE_NAME, SIDEBAR_KEYBOARD_SHORTCUT, SIDEBAR_WIDTH, SIDEBAR_WIDTH_ICON } from \"./utils\"\n\nconst props = withDefaults(defineProps<{\n  defaultOpen?: boolean\n  open?: boolean\n  class?: HTMLAttributes[\"class\"]\n}>(), {\n  defaultOpen: !defaultDocument?.cookie.includes(`${SIDEBAR_COOKIE_NAME}=false`),\n  open: undefined,\n})\n\nconst emits = defineEmits<{\n  \"update:open\": [open: boolean]\n}>()\n\nconst isMobile = useMediaQuery(\"(max-width: 768px)\")\nconst openMobile = ref(false)\n\nconst open = useVModel(props, \"open\", emits, {\n  defaultValue: props.defaultOpen ?? false,\n  passive: (props.open === undefined) as false,\n}) as Ref<boolean>\n\nfunction setOpen(value: boolean) {\n  open.value = value // emits('update:open', value)\n\n  // This sets the cookie to keep the sidebar state.\n  document.cookie = `${SIDEBAR_COOKIE_NAME}=${open.value}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`\n}\n\nfunction setOpenMobile(value: boolean) {\n  openMobile.value = value\n}\n\n// Helper to toggle the sidebar.\nfunction toggleSidebar() {\n  return isMobile.value ? setOpenMobile(!openMobile.value) : setOpen(!open.value)\n}\n\nuseEventListener(\"keydown\", (event: KeyboardEvent) => {\n  if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {\n    event.preventDefault()\n    toggleSidebar()\n  }\n})\n\n// We add a state so that we can do data-state=\"expanded\" or \"collapsed\".\n// This makes it easier to style the sidebar with Tailwind classes.\nconst state = computed(() => open.value ? \"expanded\" : \"collapsed\")\n\nprovideSidebarContext({\n  state,\n  open,\n  setOpen,\n  isMobile,\n  openMobile,\n  setOpenMobile,\n  toggleSidebar,\n})\n</script>\n\n<template>\n  <TooltipProvider :delay-duration=\"0\">\n    <div\n      data-slot=\"sidebar-wrapper\"\n      :style=\"{\n        '--sidebar-width': SIDEBAR_WIDTH,\n        '--sidebar-width-icon': SIDEBAR_WIDTH_ICON,\n      }\"\n      :class=\"cn('group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full', props.class)\"\n      v-bind=\"$attrs\"\n    >\n      <slot />\n    </div>\n  </TooltipProvider>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/sidebar/SidebarRail.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from \"vue\"\nimport { cn } from \"@/lib/utils\"\nimport { useSidebar } from \"./utils\"\n\nconst props = defineProps<{\n  class?: HTMLAttributes[\"class\"]\n}>()\n\nconst { toggleSidebar } = useSidebar()\n</script>\n\n<template>\n  <button\n    data-sidebar=\"rail\"\n    data-slot=\"sidebar-rail\"\n    aria-label=\"Toggle Sidebar\"\n    :tabindex=\"-1\"\n    title=\"Toggle Sidebar\"\n    :class=\"cn(\n      'hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex',\n      'in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize',\n      '[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize',\n      'hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full',\n      '[[data-side=left][data-collapsible=offcanvas]_&]:-right-2',\n      '[[data-side=right][data-collapsible=offcanvas]_&]:-left-2',\n      props.class,\n    )\"\n    @click=\"toggleSidebar\"\n  >\n    <slot />\n  </button>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/sidebar/SidebarSeparator.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from \"vue\"\nimport { cn } from \"@/lib/utils\"\nimport { Separator } from '@/components/ui/separator'\n\nconst props = defineProps<{\n  class?: HTMLAttributes[\"class\"]\n}>()\n</script>\n\n<template>\n  <Separator\n    data-slot=\"sidebar-separator\"\n    data-sidebar=\"separator\"\n    :class=\"cn('bg-sidebar-border mx-2 w-auto', props.class)\"\n  >\n    <slot />\n  </Separator>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/sidebar/SidebarTrigger.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from \"vue\"\nimport { PanelLeft } from \"lucide-vue-next\"\nimport { cn } from \"@/lib/utils\"\nimport { Button } from '@/components/ui/button'\nimport { useSidebar } from \"./utils\"\n\nconst props = defineProps<{\n  class?: HTMLAttributes[\"class\"]\n}>()\n\nconst { toggleSidebar } = useSidebar()\n</script>\n\n<template>\n  <Button\n    data-sidebar=\"trigger\"\n    data-slot=\"sidebar-trigger\"\n    variant=\"ghost\"\n    size=\"icon\"\n    :class=\"cn('h-7 w-7', props.class)\"\n    @click=\"toggleSidebar\"\n  >\n    <PanelLeft />\n    <span class=\"sr-only\">Toggle Sidebar</span>\n  </Button>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/sidebar/index.ts",
    "content": "import type { VariantProps } from 'class-variance-authority'\nimport type { HTMLAttributes } from 'vue'\nimport { cva } from 'class-variance-authority'\n\nexport interface SidebarProps {\n  side?: 'left' | 'right'\n  variant?: 'sidebar' | 'floating' | 'inset'\n  collapsible?: 'offcanvas' | 'icon' | 'none'\n  class?: HTMLAttributes['class']\n}\n\nexport { default as Sidebar } from './Sidebar.vue'\nexport { default as SidebarContent } from './SidebarContent.vue'\nexport { default as SidebarFooter } from './SidebarFooter.vue'\nexport { default as SidebarGroup } from './SidebarGroup.vue'\nexport { default as SidebarGroupAction } from './SidebarGroupAction.vue'\nexport { default as SidebarGroupContent } from './SidebarGroupContent.vue'\nexport { default as SidebarGroupLabel } from './SidebarGroupLabel.vue'\nexport { default as SidebarHeader } from './SidebarHeader.vue'\nexport { default as SidebarInput } from './SidebarInput.vue'\nexport { default as SidebarInset } from './SidebarInset.vue'\nexport { default as SidebarMenu } from './SidebarMenu.vue'\nexport { default as SidebarMenuAction } from './SidebarMenuAction.vue'\nexport { default as SidebarMenuBadge } from './SidebarMenuBadge.vue'\nexport { default as SidebarMenuButton } from './SidebarMenuButton.vue'\nexport { default as SidebarMenuItem } from './SidebarMenuItem.vue'\nexport { default as SidebarMenuSkeleton } from './SidebarMenuSkeleton.vue'\nexport { default as SidebarMenuSub } from './SidebarMenuSub.vue'\nexport { default as SidebarMenuSubButton } from './SidebarMenuSubButton.vue'\nexport { default as SidebarMenuSubItem } from './SidebarMenuSubItem.vue'\nexport { default as SidebarProvider } from './SidebarProvider.vue'\nexport { default as SidebarRail } from './SidebarRail.vue'\nexport { default as SidebarSeparator } from './SidebarSeparator.vue'\nexport { default as SidebarTrigger } from './SidebarTrigger.vue'\n\nexport { useSidebar } from './utils'\n\nexport const sidebarMenuButtonVariants = cva(\n  'peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring duration-200 ease-out transition-[width,height,padding,color,background-color] motion-reduce:transition-none hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:transition-colors',\n  {\n    variants: {\n      variant: {\n        default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',\n        outline:\n          'bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]',\n      },\n      size: {\n        default: 'h-8 text-sm',\n        sm: 'h-7 text-xs',\n        lg: 'h-12 text-sm group-data-[collapsible=icon]:p-0!',\n      },\n    },\n    defaultVariants: {\n      variant: 'default',\n      size: 'default',\n    },\n  }\n)\n\nexport type SidebarMenuButtonVariants = VariantProps<typeof sidebarMenuButtonVariants>\n"
  },
  {
    "path": "web/src/components/ui/sidebar/utils.ts",
    "content": "import type { ComputedRef, Ref } from \"vue\"\nimport { createContext } from \"reka-ui\"\n\nexport const SIDEBAR_COOKIE_NAME = \"sidebar_state\"\nexport const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7\nexport const SIDEBAR_WIDTH = \"16rem\"\nexport const SIDEBAR_WIDTH_MOBILE = \"18rem\"\nexport const SIDEBAR_WIDTH_ICON = \"3rem\"\nexport const SIDEBAR_KEYBOARD_SHORTCUT = \"b\"\n\nexport const [useSidebar, provideSidebarContext] = createContext<{\n  state: ComputedRef<\"expanded\" | \"collapsed\">\n  open: Ref<boolean>\n  setOpen: (value: boolean) => void\n  isMobile: Ref<boolean>\n  openMobile: Ref<boolean>\n  setOpenMobile: (value: boolean) => void\n  toggleSidebar: () => void\n}>(\"Sidebar\")\n"
  },
  {
    "path": "web/src/components/ui/skeleton/Skeleton.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from \"vue\"\nimport { cn } from \"@/lib/utils\"\n\ninterface SkeletonProps {\n  class?: HTMLAttributes[\"class\"]\n}\n\nconst props = defineProps<SkeletonProps>()\n</script>\n\n<template>\n  <div\n    data-slot=\"skeleton\"\n    :class=\"cn('animate-pulse rounded-md bg-primary/10', props.class)\"\n  />\n</template>\n"
  },
  {
    "path": "web/src/components/ui/skeleton/index.ts",
    "content": "export { default as Skeleton } from \"./Skeleton.vue\"\n"
  },
  {
    "path": "web/src/components/ui/slider/Slider.vue",
    "content": "<script setup lang=\"ts\">\nimport type { SliderRootEmits, SliderRootProps } from 'reka-ui'\nimport type { HTMLAttributes } from 'vue'\nimport { reactiveOmit } from '@vueuse/core'\nimport { SliderRange, SliderRoot, SliderThumb, SliderTrack, useForwardPropsEmits } from 'reka-ui'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<SliderRootProps & { class?: HTMLAttributes['class'] }>()\nconst emits = defineEmits<SliderRootEmits>()\n\nconst delegatedProps = reactiveOmit(props, 'class')\n\nconst forwarded = useForwardPropsEmits(delegatedProps, emits)\n</script>\n\n<template>\n  <SliderRoot\n    v-slot=\"{ modelValue }\"\n    data-slot=\"slider\"\n    :class=\"\n      cn(\n        'relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col',\n        props.class\n      )\n    \"\n    v-bind=\"forwarded\"\n  >\n    <SliderTrack\n      data-slot=\"slider-track\"\n      class=\"relative grow overflow-hidden rounded-full bg-muted data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5\"\n    >\n      <SliderRange\n        data-slot=\"slider-range\"\n        class=\"absolute bg-primary data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full\"\n      />\n    </SliderTrack>\n\n    <SliderThumb\n      v-for=\"(_, key) in modelValue\"\n      :key=\"key\"\n      data-slot=\"slider-thumb\"\n      class=\"block size-4 shrink-0 rounded-full border-2 border-primary bg-background shadow-sm ring-ring/50 transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50\"\n    />\n  </SliderRoot>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/slider/index.ts",
    "content": "export { default as Slider } from \"./Slider.vue\"\n"
  },
  {
    "path": "web/src/components/ui/sonner/Sonner.vue",
    "content": "<script lang=\"ts\" setup>\nimport type { ToasterProps } from 'vue-sonner'\nimport {\n  CircleCheckIcon,\n  InfoIcon,\n  Loader2Icon,\n  OctagonXIcon,\n  TriangleAlertIcon,\n  XIcon,\n} from 'lucide-vue-next'\nimport { Toaster as Sonner } from 'vue-sonner'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<ToasterProps>()\n</script>\n\n<template>\n  <Sonner\n    :class=\"cn('toaster group', props.class)\"\n    :style=\"{\n      '--normal-bg': 'var(--popover)',\n      '--normal-text': 'var(--foreground)',\n      '--normal-border': 'var(--border)',\n      '--border-radius': 'var(--radius)',\n    }\"\n    v-bind=\"props\"\n  >\n    <template #success-icon>\n      <CircleCheckIcon class=\"size-4\" />\n    </template>\n    <template #info-icon>\n      <InfoIcon class=\"size-4\" />\n    </template>\n    <template #warning-icon>\n      <TriangleAlertIcon class=\"size-4\" />\n    </template>\n    <template #error-icon>\n      <OctagonXIcon class=\"size-4\" />\n    </template>\n    <template #loading-icon>\n      <div>\n        <Loader2Icon class=\"size-4 animate-spin\" />\n      </div>\n    </template>\n    <template #close-icon>\n      <XIcon class=\"size-4\" />\n    </template>\n  </Sonner>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/sonner/index.ts",
    "content": "export { default as Toaster } from \"./Sonner.vue\"\n"
  },
  {
    "path": "web/src/components/ui/split/Split.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed, ref, toRef, useSlots, watch } from 'vue'\nimport { useSplitResize } from './useSplitResize'\n\nexport interface SplitProps {\n  /**\n   * 分割方向\n   * @default 'horizontal'\n   */\n  direction?: 'horizontal' | 'vertical'\n\n  /**\n   * 默认大小（非受控）\n   * - 数字：百分比 0-1\n   * - 字符串：像素值 \"200px\"\n   * @default 0.5\n   */\n  defaultSize?: number | string\n\n  /**\n   * 当前大小（受控，配合 v-model:size）\n   */\n  size?: number | string\n\n  /**\n   * 最小尺寸\n   * - 数字：百分比 0-1\n   * - 字符串：像素值 \"100px\"\n   * @default 0\n   */\n  min?: number | string\n\n  /**\n   * 最大尺寸\n   * - 数字：百分比 0-1\n   * - 字符串：像素值 \"500px\"\n   * @default 1\n   */\n  max?: number | string\n\n  /**\n   * 禁用拖拽\n   * @default false\n   */\n  disabled?: boolean\n\n  /**\n   * 分隔条宽度（像素）\n   * - 仅影响布局占位与拖拽命中区域\n   * @default 4\n   */\n  dividerSize?: number\n\n  /**\n   * 分隔条视觉线宽度（像素）\n   * - 仅影响可见线条粗细，不影响命中区域\n   * @default 1\n   */\n  dividerVisualSize?: number\n\n  /**\n   * 分隔条拖拽区域自定义类名\n   */\n  dividerClass?: string\n\n  /**\n   * 分隔条视觉线自定义类名\n   */\n  dividerLineClass?: string\n\n  /**\n   * 面板 1 自定义类名\n   */\n  pane1Class?: string\n\n  /**\n   * 面板 2 自定义类名\n   */\n  pane2Class?: string\n\n  /**\n   * 反向模式：控制第二个面板的尺寸而非第一个\n   * - false（默认）：尺寸参数控制 template #1，template #2 自适应\n   * - true：尺寸参数控制 template #2，template #1 自适应\n   * @default false\n   */\n  reverse?: boolean\n}\n\nconst props = withDefaults(defineProps<SplitProps>(), {\n  direction: 'horizontal',\n  defaultSize: 0.5,\n  min: 0,\n  max: 1,\n  disabled: false,\n  dividerSize: 4,\n  dividerVisualSize: 1,\n  dividerClass: '',\n  dividerLineClass: '',\n  pane1Class: '',\n  pane2Class: '',\n  reverse: false,\n})\n\nconst emit = defineEmits<{\n  'update:size': [size: number | string]\n  drag: [e: MouseEvent]\n  'drag-start': [e: MouseEvent]\n  'drag-end': [e: MouseEvent]\n}>()\n\n// 非受控状态：存储组件内部的尺寸值\nconst uncontrolledSize = ref(props.defaultSize)\n\n// 内部状态管理（支持受控和非受控）\nconst internalSize = computed({\n  get: () => props.size ?? uncontrolledSize.value,\n  set: (value) => {\n    emit('update:size', value)\n    // 如果是非受控模式（没有外部 size prop），更新内部状态\n    if (props.size === undefined) {\n      uncontrolledSize.value = value\n    }\n  },\n})\n\n// 当 defaultSize 变化且处于非受控模式时，同步到内部状态\nwatch(\n  () => props.defaultSize,\n  (newDefault) => {\n    if (props.size === undefined) {\n      uncontrolledSize.value = newDefault\n    }\n  }\n)\n\n// 使用拖拽逻辑\nconst {\n  containerRef,\n  dividerRef,\n  isDragging,\n  dividerStyle,\n  dividerCursor,\n  handleMouseDown,\n  getFirstPaneStyle,\n  getSecondPaneStyle,\n} = useSplitResize({\n  direction: toRef(props, 'direction'),\n  dividerSize: toRef(props, 'dividerSize'),\n  min: toRef(props, 'min'),\n  max: toRef(props, 'max'),\n  reverse: toRef(props, 'reverse'),\n  onUpdate: (size) => {\n    internalSize.value = size\n  },\n  onDrag: (e) => emit('drag', e),\n  onDragStart: (e) => emit('drag-start', e),\n  onDragEnd: (e) => emit('drag-end', e),\n})\n\n// 计算样式\nconst containerClass = computed(() => [\n  'flex w-full h-full',\n  props.direction === 'horizontal' ? 'flex-row' : 'flex-col',\n])\n\nconst firstPaneStyle = computed(() => getFirstPaneStyle(internalSize.value))\nconst secondPaneStyle = computed(() => getSecondPaneStyle(internalSize.value))\nconst slots = useSlots()\n\nconst dividerClasses = computed(() => [\n  'group relative flex-shrink-0',\n  dividerCursor.value,\n  props.dividerClass || 'bg-transparent',\n  props.disabled && 'cursor-default opacity-50',\n])\n\nconst dividerLineClasses = computed(() => [\n  'pointer-events-none absolute transition-colors duration-200',\n  props.direction === 'horizontal'\n    ? 'top-0 left-1/2 h-full -translate-x-1/2'\n    : 'top-1/2 left-0 w-full -translate-y-1/2',\n  props.dividerLineClass ||\n    (isDragging.value ? 'bg-primary/70' : 'bg-border group-hover:bg-primary/50'),\n])\n\nconst dividerLineStyle = computed(() => {\n  const isHorizontal = props.direction === 'horizontal'\n  return isHorizontal\n    ? { width: `${props.dividerVisualSize}px` }\n    : { height: `${props.dividerVisualSize}px` }\n})\n\nconst hasDividerSlot = computed(() => Boolean(slots.divider))\n\n// 拖拽处理器\nconst onMouseDown = (e: MouseEvent) => {\n  if (props.disabled) return\n  handleMouseDown(e, internalSize.value)\n}\n</script>\n\n<template>\n  <div ref=\"containerRef\" :class=\"containerClass\">\n    <!-- 面板 1 -->\n    <div :class=\"['min-h-0 min-w-0 overflow-hidden', pane1Class]\" :style=\"firstPaneStyle\">\n      <slot name=\"1\" :panel=\"1\">\n        <slot :panel=\"1\" />\n      </slot>\n    </div>\n\n    <!-- 分隔条 -->\n    <div\n      v-if=\"!disabled\"\n      ref=\"dividerRef\"\n      :class=\"dividerClasses\"\n      :style=\"dividerStyle\"\n      @mousedown=\"onMouseDown\"\n    >\n      <div v-if=\"!hasDividerSlot\" :class=\"dividerLineClasses\" :style=\"dividerLineStyle\" />\n      <slot name=\"divider\" />\n    </div>\n\n    <!-- 面板 2 -->\n    <div :class=\"['min-h-0 min-w-0 overflow-hidden', pane2Class]\" :style=\"secondPaneStyle\">\n      <slot name=\"2\" :panel=\"2\">\n        <slot :panel=\"2\" />\n      </slot>\n    </div>\n  </div>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/split/index.ts",
    "content": "export { default as Split } from './Split.vue'\nexport type { SplitProps } from './Split.vue'\n\n"
  },
  {
    "path": "web/src/components/ui/split/useSplitResize.ts",
    "content": "import { computed, ref } from 'vue'\nimport type { Ref } from 'vue'\n\ninterface UseSplitResizeOptions {\n  direction: Ref<'horizontal' | 'vertical'>\n  dividerSize: Ref<number>\n  min: Ref<number | string>\n  max: Ref<number | string>\n  reverse: Ref<boolean>\n  onUpdate: (size: number | string) => void\n  onDrag?: (e: MouseEvent) => void\n  onDragStart?: (e: MouseEvent) => void\n  onDragEnd?: (e: MouseEvent) => void\n}\n\nexport function useSplitResize(options: UseSplitResizeOptions) {\n  const { direction, dividerSize, min, max, reverse, onUpdate, onDrag, onDragStart, onDragEnd } =\n    options\n\n  const containerRef = ref<HTMLElement>()\n  const dividerRef = ref<HTMLElement>()\n  const isDragging = ref(false)\n  let startOffset = 0\n\n  /**\n   * 将字符串尺寸转换为像素值\n   */\n  function parseSizeToPixels(size: number | string, containerSize: number): number {\n    if (typeof size === 'number') {\n      return size * containerSize\n    }\n    // 处理像素值 \"200px\"\n    const px = parseFloat(size)\n    return isNaN(px) ? 0 : px\n  }\n\n  /**\n   * 计算新的面板尺寸\n   */\n  function calculateNewSize(e: MouseEvent, currentSize: number | string): number | string {\n    const container = containerRef.value\n    if (!container) return currentSize\n\n    const containerRect = container.getBoundingClientRect()\n    const isHorizontal = direction.value === 'horizontal'\n\n    // 容器可用尺寸（减去分隔条宽度）\n    const containerUsableSize = isHorizontal\n      ? containerRect.width - dividerSize.value\n      : containerRect.height - dividerSize.value\n\n    // 计算鼠标相对位置\n    let mousePosition: number\n\n    if (reverse.value) {\n      // 反向模式：计算从右侧/底部到鼠标的距离（第二个面板的尺寸）\n      if (isHorizontal) {\n        mousePosition = containerRect.right - e.clientX + startOffset\n      } else {\n        mousePosition = containerRect.bottom - e.clientY - startOffset\n      }\n    } else {\n      // 正常模式：计算从左侧/顶部到鼠标的距离（第一个面板的尺寸）\n      if (isHorizontal) {\n        mousePosition = e.clientX - containerRect.left - startOffset\n      } else {\n        mousePosition = e.clientY - containerRect.top + startOffset\n      }\n    }\n\n    // 计算 min/max 的像素值\n    const minPx = parseSizeToPixels(min.value, containerUsableSize)\n    const maxPx = parseSizeToPixels(max.value, containerUsableSize)\n\n    // 应用限制\n    let newPx = Math.max(minPx, Math.min(mousePosition, maxPx, containerUsableSize))\n\n    // 根据当前尺寸类型返回对应格式\n    if (typeof currentSize === 'string' && currentSize.endsWith('px')) {\n      return `${newPx}px`\n    } else {\n      // 返回百分比（0-1）\n      return newPx / containerUsableSize\n    }\n  }\n\n  /**\n   * 鼠标按下处理\n   */\n  function handleMouseDown(e: MouseEvent, currentSize: number | string) {\n    e.preventDefault()\n\n    const divider = dividerRef.value\n    if (!divider) return\n\n    // 记录初始偏移量\n    const dividerRect = divider.getBoundingClientRect()\n    const isHorizontal = direction.value === 'horizontal'\n\n    if (reverse.value) {\n      // 反向模式：从分隔条右侧/底部计算偏移\n      if (isHorizontal) {\n        startOffset = dividerRect.right - e.clientX\n      } else {\n        startOffset = e.clientY - dividerRect.bottom\n      }\n    } else {\n      // 正常模式：从分隔条左侧/顶部计算偏移\n      if (isHorizontal) {\n        startOffset = e.clientX - dividerRect.left\n      } else {\n        startOffset = dividerRect.top - e.clientY\n      }\n    }\n\n    isDragging.value = true\n    onDragStart?.(e)\n\n    // 设置全局光标\n    const cursor = isHorizontal ? 'col-resize' : 'row-resize'\n    document.body.style.cursor = cursor\n    document.body.style.userSelect = 'none'\n\n    // 鼠标移动处理\n    const handleMouseMove = (e: MouseEvent) => {\n      const newSize = calculateNewSize(e, currentSize)\n      onUpdate(newSize)\n      onDrag?.(e)\n    }\n\n    // 鼠标释放处理\n    const handleMouseUp = (e: MouseEvent) => {\n      window.removeEventListener('mousemove', handleMouseMove)\n      window.removeEventListener('mouseup', handleMouseUp)\n\n      isDragging.value = false\n      document.body.style.cursor = ''\n      document.body.style.userSelect = ''\n\n      onDragEnd?.(e)\n    }\n\n    window.addEventListener('mousemove', handleMouseMove)\n    window.addEventListener('mouseup', handleMouseUp)\n\n    // 立即更新一次尺寸，确保初始响应\n    const newSize = calculateNewSize(e, currentSize)\n    onUpdate(newSize)\n  }\n\n  /**\n   * 计算第一个面板的样式\n   */\n  function getFirstPaneStyle(size: number | string) {\n    // 反向模式：第一个面板自适应\n    if (reverse.value) {\n      return { flex: '1' }\n    }\n\n    // 正常模式：第一个面板使用固定尺寸\n    if (typeof size === 'string' && size.endsWith('px')) {\n      return { flex: `0 0 ${size}` }\n    } else if (typeof size === 'number') {\n      const percentage = size * 100\n      const offset = dividerSize.value * size\n      return { flex: `0 0 calc(${percentage}% - ${offset}px)` }\n    }\n    return { flex: `0 0 50%` }\n  }\n\n  /**\n   * 计算第二个面板的样式\n   */\n  function getSecondPaneStyle(size: number | string) {\n    // 正常模式：第二个面板自适应\n    if (!reverse.value) {\n      return { flex: '1' }\n    }\n\n    // 反向模式：第二个面板使用固定尺寸\n    if (typeof size === 'string' && size.endsWith('px')) {\n      return { flex: `0 0 ${size}` }\n    } else if (typeof size === 'number') {\n      const percentage = size * 100\n      const offset = dividerSize.value * size\n      return { flex: `0 0 calc(${percentage}% - ${offset}px)` }\n    }\n    return { flex: `0 0 50%` }\n  }\n\n  /**\n   * 计算分隔条样式\n   */\n  const dividerStyle = computed(() => {\n    const isHorizontal = direction.value === 'horizontal'\n    return isHorizontal\n      ? { width: `${dividerSize.value}px`, height: '100%' }\n      : { width: '100%', height: `${dividerSize.value}px` }\n  })\n\n  /**\n   * 计算分隔条光标样式\n   */\n  const dividerCursor = computed(() => {\n    return direction.value === 'horizontal' ? 'cursor-col-resize' : 'cursor-row-resize'\n  })\n\n  return {\n    containerRef,\n    dividerRef,\n    isDragging,\n    dividerStyle,\n    dividerCursor,\n    handleMouseDown,\n    getFirstPaneStyle,\n    getSecondPaneStyle,\n  }\n}\n"
  },
  {
    "path": "web/src/components/ui/switch/Switch.vue",
    "content": "<script setup lang=\"ts\">\nimport type { SwitchRootEmits, SwitchRootProps } from 'reka-ui'\nimport type { HTMLAttributes } from 'vue'\nimport { reactiveOmit } from '@vueuse/core'\nimport { SwitchRoot, SwitchThumb, useForwardPropsEmits } from 'reka-ui'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<SwitchRootProps & { class?: HTMLAttributes['class'] }>()\n\nconst emits = defineEmits<SwitchRootEmits>()\n\nconst delegatedProps = reactiveOmit(props, 'class')\n\nconst forwarded = useForwardPropsEmits(delegatedProps, emits)\n</script>\n\n<template>\n  <SwitchRoot\n    v-slot=\"slotProps\"\n    data-slot=\"switch\"\n    v-bind=\"forwarded\"\n    :class=\"\n      cn(\n        'peer inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-input/80',\n        props.class\n      )\n    \"\n  >\n    <SwitchThumb\n      data-slot=\"switch-thumb\"\n      :class=\"\n        cn(\n          'pointer-events-none block size-4 rounded-full bg-surface-top ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0 dark:data-[state=checked]:bg-primary-foreground dark:data-[state=unchecked]:bg-foreground'\n        )\n      \"\n    >\n      <slot name=\"thumb\" v-bind=\"slotProps\" />\n    </SwitchThumb>\n  </SwitchRoot>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/switch/index.ts",
    "content": "export { default as Switch } from \"./Switch.vue\"\n"
  },
  {
    "path": "web/src/components/ui/tabs/Tabs.vue",
    "content": "<script setup lang=\"ts\">\nimport type { TabsRootEmits, TabsRootProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\"\nimport { reactiveOmit } from \"@vueuse/core\"\nimport { TabsRoot, useForwardPropsEmits } from \"reka-ui\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<TabsRootProps & { class?: HTMLAttributes[\"class\"] }>()\nconst emits = defineEmits<TabsRootEmits>()\n\nconst delegatedProps = reactiveOmit(props, \"class\")\nconst forwarded = useForwardPropsEmits(delegatedProps, emits)\n</script>\n\n<template>\n  <TabsRoot\n    data-slot=\"tabs\"\n    v-bind=\"forwarded\"\n    :class=\"cn('flex flex-col gap-2', props.class)\"\n  >\n    <slot />\n  </TabsRoot>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/tabs/TabsContent.vue",
    "content": "<script setup lang=\"ts\">\nimport type { TabsContentProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\"\nimport { reactiveOmit } from \"@vueuse/core\"\nimport { TabsContent } from \"reka-ui\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<TabsContentProps & { class?: HTMLAttributes[\"class\"] }>()\n\nconst delegatedProps = reactiveOmit(props, \"class\")\n</script>\n\n<template>\n  <TabsContent\n    data-slot=\"tabs-content\"\n    :class=\"cn('flex-1 outline-none', props.class)\"\n    v-bind=\"delegatedProps\"\n  >\n    <slot />\n  </TabsContent>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/tabs/TabsList.vue",
    "content": "<script setup lang=\"ts\">\nimport type { TabsListProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\"\nimport { reactiveOmit } from \"@vueuse/core\"\nimport { TabsList } from \"reka-ui\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<TabsListProps & { class?: HTMLAttributes[\"class\"] }>()\n\nconst delegatedProps = reactiveOmit(props, \"class\")\n</script>\n\n<template>\n  <TabsList\n    data-slot=\"tabs-list\"\n    v-bind=\"delegatedProps\"\n    :class=\"cn(\n      'bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]',\n      props.class,\n    )\"\n  >\n    <slot />\n  </TabsList>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/tabs/TabsTrigger.vue",
    "content": "<script setup lang=\"ts\">\nimport type { TabsTriggerProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\"\nimport { reactiveOmit } from \"@vueuse/core\"\nimport { TabsTrigger, useForwardProps } from \"reka-ui\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<TabsTriggerProps & { class?: HTMLAttributes[\"class\"] }>()\n\nconst delegatedProps = reactiveOmit(props, \"class\")\n\nconst forwardedProps = useForwardProps(delegatedProps)\n</script>\n\n<template>\n  <TabsTrigger\n    data-slot=\"tabs-trigger\"\n    v-bind=\"forwardedProps\"\n    :class=\"cn(\n      'data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\\'size-\\'])]:size-4',\n      props.class,\n    )\"\n  >\n    <slot />\n  </TabsTrigger>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/tabs/index.ts",
    "content": "export { default as Tabs } from \"./Tabs.vue\"\nexport { default as TabsContent } from \"./TabsContent.vue\"\nexport { default as TabsList } from \"./TabsList.vue\"\nexport { default as TabsTrigger } from \"./TabsTrigger.vue\"\n"
  },
  {
    "path": "web/src/components/ui/textarea/Textarea.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from \"vue\"\nimport { useVModel } from \"@vueuse/core\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<{\n  class?: HTMLAttributes[\"class\"]\n  defaultValue?: string | number\n  modelValue?: string | number\n}>()\n\nconst emits = defineEmits<{\n  (e: \"update:modelValue\", payload: string | number): void\n}>()\n\nconst modelValue = useVModel(props, \"modelValue\", emits, {\n  passive: true,\n  defaultValue: props.defaultValue,\n})\n</script>\n\n<template>\n  <textarea\n    v-model=\"modelValue\"\n    data-slot=\"textarea\"\n    :class=\"cn('border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm', props.class)\"\n  />\n</template>\n"
  },
  {
    "path": "web/src/components/ui/textarea/index.ts",
    "content": "export { default as Textarea } from \"./Textarea.vue\"\n"
  },
  {
    "path": "web/src/components/ui/toggle/Toggle.vue",
    "content": "<script setup lang=\"ts\">\nimport type { ToggleEmits, ToggleProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\"\nimport type { ToggleVariants } from \".\"\nimport { reactiveOmit } from \"@vueuse/core\"\nimport { Toggle, useForwardPropsEmits } from \"reka-ui\"\nimport { cn } from \"@/lib/utils\"\nimport { toggleVariants } from \".\"\n\nconst props = withDefaults(defineProps<ToggleProps & {\n  class?: HTMLAttributes[\"class\"]\n  variant?: ToggleVariants[\"variant\"]\n  size?: ToggleVariants[\"size\"]\n}>(), {\n  variant: \"default\",\n  size: \"default\",\n  disabled: false,\n})\n\nconst emits = defineEmits<ToggleEmits>()\n\nconst delegatedProps = reactiveOmit(props, \"class\", \"size\", \"variant\")\nconst forwarded = useForwardPropsEmits(delegatedProps, emits)\n</script>\n\n<template>\n  <Toggle\n    v-slot=\"slotProps\"\n    data-slot=\"toggle\"\n    v-bind=\"forwarded\"\n    :class=\"cn(toggleVariants({ variant, size }), props.class)\"\n  >\n    <slot v-bind=\"slotProps\" />\n  </Toggle>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/toggle/index.ts",
    "content": "import type { VariantProps } from \"class-variance-authority\"\nimport { cva } from \"class-variance-authority\"\n\nexport { default as Toggle } from \"./Toggle.vue\"\n\nexport const toggleVariants = cva(\n  \"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap\",\n  {\n    variants: {\n      variant: {\n        default: \"bg-transparent\",\n        outline:\n          \"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground\",\n      },\n      size: {\n        default: \"h-9 px-2 min-w-9\",\n        sm: \"h-8 px-1.5 min-w-8\",\n        lg: \"h-10 px-2.5 min-w-10\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n      size: \"default\",\n    },\n  },\n)\n\nexport type ToggleVariants = VariantProps<typeof toggleVariants>\n"
  },
  {
    "path": "web/src/components/ui/toggle-group/ToggleGroup.vue",
    "content": "<script setup lang=\"ts\">\nimport type { VariantProps } from \"class-variance-authority\"\nimport type { ToggleGroupRootEmits, ToggleGroupRootProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\"\nimport type { toggleVariants } from '@/components/ui/toggle'\nimport { reactiveOmit } from \"@vueuse/core\"\nimport { ToggleGroupRoot, useForwardPropsEmits } from \"reka-ui\"\nimport { provide } from \"vue\"\nimport { cn } from \"@/lib/utils\"\n\ntype ToggleGroupVariants = VariantProps<typeof toggleVariants>\n\nconst props = defineProps<ToggleGroupRootProps & {\n  class?: HTMLAttributes[\"class\"]\n  variant?: ToggleGroupVariants[\"variant\"]\n  size?: ToggleGroupVariants[\"size\"]\n}>()\nconst emits = defineEmits<ToggleGroupRootEmits>()\n\nprovide(\"toggleGroup\", {\n  variant: props.variant,\n  size: props.size,\n})\n\nconst delegatedProps = reactiveOmit(props, \"class\", \"size\", \"variant\")\nconst forwarded = useForwardPropsEmits(delegatedProps, emits)\n</script>\n\n<template>\n  <ToggleGroupRoot\n    v-slot=\"slotProps\"\n    data-slot=\"toggle-group\"\n    :data-size=\"size\"\n    :data-variant=\"variant\"\n    v-bind=\"forwarded\"\n    :class=\"cn('group/toggle-group flex w-fit items-center rounded-md data-[variant=outline]:shadow-xs', props.class)\"\n  >\n    <slot v-bind=\"slotProps\" />\n  </ToggleGroupRoot>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/toggle-group/ToggleGroupItem.vue",
    "content": "<script setup lang=\"ts\">\nimport type { VariantProps } from \"class-variance-authority\"\nimport type { ToggleGroupItemProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\"\nimport { reactiveOmit } from \"@vueuse/core\"\nimport { ToggleGroupItem, useForwardProps } from \"reka-ui\"\nimport { inject } from \"vue\"\nimport { cn } from \"@/lib/utils\"\nimport { toggleVariants } from '@/components/ui/toggle'\n\ntype ToggleGroupVariants = VariantProps<typeof toggleVariants>\n\nconst props = defineProps<ToggleGroupItemProps & {\n  class?: HTMLAttributes[\"class\"]\n  variant?: ToggleGroupVariants[\"variant\"]\n  size?: ToggleGroupVariants[\"size\"]\n}>()\n\nconst context = inject<ToggleGroupVariants>(\"toggleGroup\")\n\nconst delegatedProps = reactiveOmit(props, \"class\", \"size\", \"variant\")\nconst forwardedProps = useForwardProps(delegatedProps)\n</script>\n\n<template>\n  <ToggleGroupItem\n    v-slot=\"slotProps\"\n    data-slot=\"toggle-group-item\"\n    :data-variant=\"context?.variant || variant\"\n    :data-size=\"context?.size || size\"\n    v-bind=\"forwardedProps\"\n    :class=\"cn(\n      toggleVariants({\n        variant: context?.variant || variant,\n        size: context?.size || size,\n      }),\n      'min-w-0 flex-1 shrink-0 rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l',\n      props.class)\"\n  >\n    <slot v-bind=\"slotProps\" />\n  </ToggleGroupItem>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/toggle-group/index.ts",
    "content": "export { default as ToggleGroup } from \"./ToggleGroup.vue\"\nexport { default as ToggleGroupItem } from \"./ToggleGroupItem.vue\"\n"
  },
  {
    "path": "web/src/components/ui/tooltip/Tooltip.vue",
    "content": "<script setup lang=\"ts\">\nimport type { TooltipRootEmits, TooltipRootProps } from \"reka-ui\"\nimport { TooltipRoot, useForwardPropsEmits } from \"reka-ui\"\n\nconst props = defineProps<TooltipRootProps>()\nconst emits = defineEmits<TooltipRootEmits>()\n\nconst forwarded = useForwardPropsEmits(props, emits)\n</script>\n\n<template>\n  <TooltipRoot\n    data-slot=\"tooltip\"\n    v-bind=\"forwarded\"\n  >\n    <slot />\n  </TooltipRoot>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/tooltip/TooltipContent.vue",
    "content": "<script setup lang=\"ts\">\nimport type { TooltipContentEmits, TooltipContentProps } from 'reka-ui'\nimport type { HTMLAttributes } from 'vue'\nimport { reactiveOmit } from '@vueuse/core'\nimport { TooltipArrow, TooltipContent, TooltipPortal, useForwardPropsEmits } from 'reka-ui'\nimport { cn } from '@/lib/utils'\n\ndefineOptions({\n  inheritAttrs: false,\n})\n\nconst props = withDefaults(\n  defineProps<\n    TooltipContentProps & {\n      class?: HTMLAttributes['class']\n      variant?: 'default' | 'sidebar'\n    }\n  >(),\n  {\n    sideOffset: 4,\n    variant: 'default',\n  }\n)\n\nconst emits = defineEmits<TooltipContentEmits>()\n\nconst delegatedProps = reactiveOmit(props, 'class', 'variant')\nconst forwarded = useForwardPropsEmits(delegatedProps, emits)\n</script>\n\n<template>\n  <TooltipPortal>\n    <TooltipContent\n      data-slot=\"tooltip-content\"\n      v-bind=\"{ ...forwarded, ...$attrs }\"\n      :class=\"\n        cn(\n          'z-50 w-fit animate-in rounded-md px-3 py-1.5 text-xs text-balance fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',\n          props.variant === 'sidebar'\n            ? 'bg-background text-foreground shadow-sm'\n            : 'bg-primary text-primary-foreground',\n          props.class\n        )\n      \"\n    >\n      <slot />\n\n      <TooltipArrow\n        class=\"z-50\"\n        :class=\"props.variant === 'sidebar' ? 'fill-background' : 'bg-primary fill-primary'\"\n      />\n    </TooltipContent>\n  </TooltipPortal>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/tooltip/TooltipProvider.vue",
    "content": "<script setup lang=\"ts\">\nimport type { TooltipProviderProps } from \"reka-ui\"\nimport { TooltipProvider } from \"reka-ui\"\n\nconst props = withDefaults(defineProps<TooltipProviderProps>(), {\n  delayDuration: 0,\n})\n</script>\n\n<template>\n  <TooltipProvider v-bind=\"props\">\n    <slot />\n  </TooltipProvider>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/tooltip/TooltipTrigger.vue",
    "content": "<script setup lang=\"ts\">\nimport type { TooltipTriggerProps } from \"reka-ui\"\nimport { TooltipTrigger } from \"reka-ui\"\n\nconst props = defineProps<TooltipTriggerProps>()\n</script>\n\n<template>\n  <TooltipTrigger\n    data-slot=\"tooltip-trigger\"\n    v-bind=\"props\"\n  >\n    <slot />\n  </TooltipTrigger>\n</template>\n"
  },
  {
    "path": "web/src/components/ui/tooltip/index.ts",
    "content": "export { default as Tooltip } from \"./Tooltip.vue\"\nexport { default as TooltipContent } from \"./TooltipContent.vue\"\nexport { default as TooltipProvider } from \"./TooltipProvider.vue\"\nexport { default as TooltipTrigger } from \"./TooltipTrigger.vue\"\n"
  },
  {
    "path": "web/src/composables/useI18n.ts",
    "content": "// Re-export from core i18n module\nexport { useI18n } from '@/core/i18n'\n"
  },
  {
    "path": "web/src/composables/useRpc.ts",
    "content": "import { ref, onMounted, onUnmounted } from 'vue'\nimport {\n  getStats,\n  getTransportType,\n  isConnected,\n  on,\n  off,\n  type TransportStats,\n  type TransportType,\n} from '@/core/rpc'\n\nexport interface RpcStatus {\n  transportType: TransportType | null\n  isConnected: boolean\n  stats: TransportStats | null\n}\n\n/**\n * Vue Composable for monitoring RPC status\n */\nexport function useRpcStatus(refreshInterval = 1000) {\n  const transportType = ref<TransportType | null>(null)\n  const isConnectedRef = ref(false)\n  const stats = ref<TransportStats | null>(null)\n\n  let intervalId: ReturnType<typeof setInterval> | null = null\n\n  const updateStatus = () => {\n    try {\n      transportType.value = getTransportType()\n      isConnectedRef.value = isConnected()\n      stats.value = getStats()\n    } catch (error) {\n      console.error('Failed to get RPC status:', error)\n      transportType.value = null\n      isConnectedRef.value = false\n      stats.value = null\n    }\n  }\n\n  onMounted(() => {\n    // 立即更新一次\n    updateStatus()\n\n    // 定期更新\n    intervalId = setInterval(updateStatus, refreshInterval)\n  })\n\n  onUnmounted(() => {\n    if (intervalId) {\n      clearInterval(intervalId)\n    }\n  })\n\n  return {\n    transportType,\n    isConnected: isConnectedRef,\n    stats,\n  }\n}\n\n/**\n * Vue Composable for listening to RPC events\n */\nexport function useRpcEvent<T = unknown>(method: string, handler: (params: T) => void): void {\n  const eventHandler = (params: unknown) => {\n    handler(params as T)\n  }\n\n  onMounted(() => {\n    on(method, eventHandler)\n  })\n\n  onUnmounted(() => {\n    off(method, eventHandler)\n  })\n}\n"
  },
  {
    "path": "web/src/composables/useToast.ts",
    "content": "import { toast as sonnerToast } from 'vue-sonner'\nimport type { ExternalToast } from 'vue-sonner'\n\nexport interface ToastOptions extends ExternalToast {\n  title?: string\n  description?: string\n}\n\n/**\n * Toast notification composable\n * \n * @example\n * ```ts\n * const { toast } = useToast()\n * \n * // Basic usage\n * toast.success('Settings saved')\n * \n * // With description\n * toast.error('Failed to save', { description: 'Network error' })\n * \n * // Custom duration\n * toast.info('Processing...', { duration: 5000 })\n * \n * // With action\n * toast('Delete item?', {\n *   action: {\n *     label: 'Undo',\n *     onClick: () => console.log('Undo clicked')\n *   }\n * })\n * ```\n */\nexport function useToast() {\n  /**\n   * Show a default toast\n   */\n  const toast = (message: string, options?: ToastOptions) => {\n    return sonnerToast(message, options)\n  }\n\n  /**\n   * Show a success toast\n   */\n  toast.success = (message: string, options?: ToastOptions) => {\n    return sonnerToast.success(message, options)\n  }\n\n  /**\n   * Show an error toast\n   */\n  toast.error = (message: string, options?: ToastOptions) => {\n    return sonnerToast.error(message, options)\n  }\n\n  /**\n   * Show an info toast\n   */\n  toast.info = (message: string, options?: ToastOptions) => {\n    return sonnerToast.info(message, options)\n  }\n\n  /**\n   * Show a warning toast\n   */\n  toast.warning = (message: string, options?: ToastOptions) => {\n    return sonnerToast.warning(message, options)\n  }\n\n  /**\n   * Show a loading toast\n   */\n  toast.loading = (message: string, options?: ToastOptions) => {\n    return sonnerToast.loading(message, options)\n  }\n\n  /**\n   * Show a promise toast (automatically updates based on promise state)\n   */\n  toast.promise = sonnerToast.promise\n\n  /**\n   * Dismiss a toast by ID\n   */\n  toast.dismiss = sonnerToast.dismiss\n\n  /**\n   * Create a custom toast with full control\n   */\n  toast.custom = sonnerToast.custom\n\n  return { toast }\n}\n\n// Re-export for direct usage if needed\nexport { toast } from 'vue-sonner'\n"
  },
  {
    "path": "web/src/core/clipboard.ts",
    "content": "import { call } from '@/core/rpc'\n\nexport async function readClipboardText(): Promise<string | null> {\n  try {\n    const result = await call<string | null>('clipboard.readText', {})\n    return result\n  } catch (error) {\n    console.error('Failed to read clipboard text:', error)\n    throw new Error('读取剪贴板失败')\n  }\n}\n"
  },
  {
    "path": "web/src/core/env/index.ts",
    "content": "/**\n * 运行环境类型\n * - `webview`: 在 WebView2 中运行\n * - `web`: 在标准 Web 浏览器中运行\n */\nexport type EnvironmentType = 'webview' | 'web'\n\n// 模块级缓存，避免重复检测\nlet currentEnvironment: EnvironmentType | null = null\n\n/**\n * 检测当前运行环境\n * - `webview`: 通过 `window.chrome.webview` 特征检测\n * - `web`: 默认环境\n */\nfunction detectEnvironment(): EnvironmentType {\n  if (typeof window !== 'undefined' && window.chrome?.webview) {\n    return 'webview'\n  }\n  return 'web'\n}\n\n/**\n * 获取当前运行环境（带缓存）\n */\nexport function getCurrentEnvironment(): EnvironmentType {\n  if (!currentEnvironment) {\n    currentEnvironment = detectEnvironment()\n  }\n  return currentEnvironment\n}\n\n/**\n * 检查是否在 WebView 环境中运行\n */\nexport function isWebView(): boolean {\n  return getCurrentEnvironment() === 'webview'\n}\n\n/**\n * 检查是否在标准 Web 浏览器环境中运行\n */\nexport function isWebBrowser(): boolean {\n  return getCurrentEnvironment() === 'web'\n}\n\n/**\n * 根据环境获取静态资源的完整 URL\n * - 在 WebView 的生产环境中，使用 `https://static.test` 协议头\n * - 在其他环境中，使用相对路径\n */\nexport function getStaticUrl(path: string): string {\n  const normalizedPath = path.startsWith('/') ? path : `/${path}`\n\n  return isWebView() && !import.meta.env.DEV\n    ? `https://static.test${normalizedPath}`\n    : normalizedPath\n}\n"
  },
  {
    "path": "web/src/core/i18n/index.ts",
    "content": "import { ref, shallowRef } from 'vue'\nimport type { Locale, Messages, I18nInstance } from './types'\n\n// 当前语言\nconst locale = ref<Locale>('zh-CN')\n\n// 翻译字典（使用 shallowRef 避免深度响应式，提升性能）\nconst messages = shallowRef<Messages>({})\nconst LOCALE_DOMAINS = [\n  'common',\n  'app',\n  'settings',\n  'gallery',\n  'about',\n  'onboarding',\n  'menu',\n  'extensions',\n  'home',\n  'map',\n] as const\n\n/**\n * 参数插值：替换文本中的 {key} 占位符\n */\nfunction interpolate(text: string, params: Record<string, any>): string {\n  return text.replace(/\\{(\\w+)\\}/g, (_, key) => {\n    return params[key] !== undefined ? String(params[key]) : `{${key}}`\n  })\n}\n\n/**\n * 翻译函数\n */\nfunction t(key: string, params?: Record<string, any>): string {\n  const text = messages.value[key] ?? key\n  return params ? interpolate(text, params) : text\n}\n\n/**\n * 切换语言\n */\nasync function setLocale(newLocale: Locale): Promise<void> {\n  try {\n    const merged: Messages = {}\n\n    for (const domain of LOCALE_DOMAINS) {\n      const module = await import(`./locales/${newLocale}/${domain}.json`)\n      const dict = (module.default || module) as Messages\n\n      for (const [key, value] of Object.entries(dict)) {\n        if (key in merged) {\n          console.warn(\n            `[i18n] Duplicate translation key detected while loading ${newLocale}/${domain}.json: ${key}`\n          )\n        }\n        merged[key] = value\n      }\n    }\n\n    messages.value = merged\n    locale.value = newLocale\n  } catch (error) {\n    console.error(`Failed to load locale: ${newLocale}`, error)\n  }\n}\n\n/**\n * 初始化 i18n\n */\nexport async function initI18n(initialLocale: Locale = 'zh-CN'): Promise<void> {\n  await setLocale(initialLocale)\n}\n\n/**\n * useI18n composable\n */\nexport function useI18n(): I18nInstance {\n  return {\n    locale,\n    t,\n    setLocale,\n  }\n}\n"
  },
  {
    "path": "web/src/core/i18n/locales/en-US/about.json",
    "content": "{\n  \"about.actions.checkingUpdate\": \"Checking for updates...\",\n  \"about.actions.copyDiagnostics\": \"Copy Diagnostics\",\n  \"about.actions.downloadUpdate\": \"Download update {version}\",\n  \"about.actions.installDownloadedUpdate\": \"Install now {version}\",\n  \"about.actions.installingUpdate\": \"Installing...\",\n  \"about.actions.openDataDirectory\": \"App Data\",\n  \"about.actions.openLogDirectory\": \"Log Folder\",\n  \"about.diagnostics.title\": \"SpinningMomo Diagnostics\",\n  \"about.issuesDialog.description\": \"If something goes wrong, copy the environment details below, then open an issue on GitHub so we can help.\",\n  \"about.issuesDialog.openOnGithub\": \"Open issue on GitHub\",\n  \"about.footer.creditLink\": \"NUAN5.PRO\",\n  \"about.footer.creditSuffix\": \" support.\",\n  \"about.footer.openSourceLink\": \"open source projects\",\n  \"about.footer.openSourcePrefix\": \"SpinningMomo wouldn't be possible without many wonderful \",\n  \"about.footer.openSourceSuffix\": \" and \",\n  \"about.footer.rightsReserved\": \"All rights reserved.\",\n  \"about.links.issues\": \"Report Issue\",\n  \"about.links.legalNotice\": \"Legal & Privacy Notice\",\n  \"about.links.license\": \"License\",\n  \"about.links.officialWebsite\": \"Official Website\",\n  \"about.runtime.available\": \"Available\",\n  \"about.runtime.capture\": \"Graphics Capture Support\",\n  \"about.runtime.environment\": \"Environment\",\n  \"about.runtime.environmentWeb\": \"Browser (Web)\",\n  \"about.runtime.environmentWebview\": \"Desktop WebView2\",\n  \"about.runtime.loopback\": \"Game Audio Loopback Support\",\n  \"about.runtime.os\": \"Windows Version\",\n  \"about.runtime.supported\": \"Supported\",\n  \"about.runtime.unavailable\": \"Unavailable\",\n  \"about.runtime.unsupported\": \"Unsupported\",\n  \"about.runtime.version\": \"App Version\",\n  \"about.runtime.webview2\": \"WebView2\",\n  \"about.status.copied\": \"Copied\",\n  \"about.toast.openDataDirectoryFailed\": \"Failed to open data directory\",\n  \"about.toast.openLogDirectoryFailed\": \"Failed to open log directory\",\n  \"about.toast.updateAvailable\": \"Update is available\",\n  \"about.toast.updateCheckFailed\": \"Failed to check for updates\",\n  \"about.toast.updateDownloadFailed\": \"Failed to start update download\",\n  \"about.toast.updateInstallFailed\": \"Failed to install update\",\n  \"about.toast.upToDate\": \"You are up to date\"\n}\n"
  },
  {
    "path": "web/src/core/i18n/locales/en-US/app.json",
    "content": "{\n  \"app.header.gallery.extension.infinityNikki.gameDirMissingDescription\": \"Please configure the Infinity Nikki game directory in onboarding or settings first.\",\n  \"app.header.gallery.extension.infinityNikki.gameDirMissingTitle\": \"Infinity Nikki game directory is not configured\",\n  \"app.header.gallery.extension.infinityNikki.importAlbum\": \"Import Infinity Nikki Album\",\n  \"app.header.gallery.infinityNikki.menuTitle\": \"Infinity Nikki\",\n  \"app.header.gallery.toggleDetails.hide\": \"Hide right panel\",\n  \"app.header.gallery.toggleDetails.show\": \"Show right panel\",\n  \"app.header.gallery.toggleSidebar.hide\": \"Hide left panel\",\n  \"app.header.gallery.toggleSidebar.show\": \"Show left panel\",\n  \"app.header.tasks.activeCount\": \"Tasks ({count})\",\n  \"app.header.tasks.button\": \"Tasks\",\n  \"app.header.tasks.clearFailedTitle\": \"Failed to clear background tasks\",\n  \"app.header.tasks.clearFinished\": \"Clear Finished\",\n  \"app.header.tasks.collapse\": \"Collapse\",\n  \"app.header.tasks.expand\": \"Expand\",\n  \"app.header.tasks.none\": \"No background tasks yet.\",\n  \"app.header.tasks.recordCount\": \"{count} records\",\n  \"app.header.tasks.status.cancelled\": \"Cancelled\",\n  \"app.header.tasks.status.failed\": \"Failed\",\n  \"app.header.tasks.status.queued\": \"Queued\",\n  \"app.header.tasks.status.running\": \"Running\",\n  \"app.header.tasks.status.succeeded\": \"Completed\",\n  \"app.header.tasks.type.galleryScan\": \"Gallery Scan\",\n  \"app.header.tasks.type.infinityNikkiExtractPhotoParams\": \"Infinity Nikki Metadata Extract\",\n  \"app.header.tasks.type.infinityNikkiInitialScan\": \"Infinity Nikki Initial Scan\",\n  \"app.header.tasks.type.infinityNikkiInitScreenshotHardlinks\": \"ScreenShot Hardlink Init\",\n  \"app.header.tasks.type.updateDownload\": \"App Update Download\",\n  \"app.name\": \"SpinningMomo\"\n}\n"
  },
  {
    "path": "web/src/core/i18n/locales/en-US/common.json",
    "content": "{\n  \"common.languageEnUs\": \"English\",\n  \"common.languageLabelBilingual\": \"语言 / Language\",\n  \"common.languageZhCn\": \"简体中文\",\n  \"common.no\": \"No\",\n  \"common.windowTitlePicker.empty\": \"No visible windows found\",\n  \"common.windowTitlePicker.loadFailed\": \"Failed to load the window list\",\n  \"common.windowTitlePicker.loading\": \"Loading window list...\",\n  \"common.windowTitlePicker.retry\": \"Retry\",\n  \"common.windowTitlePicker.trigger\": \"Choose from visible windows\",\n  \"common.yes\": \"Yes\"\n}\n"
  },
  {
    "path": "web/src/core/i18n/locales/en-US/extensions.json",
    "content": "{\n  \"extensions.infinityNikki.album.rootDisplayName\": \"Infinity Nikki\",\n  \"extensions.infinityNikki.scanRules.excludeAll\": \"Exclude all files by default\",\n  \"extensions.infinityNikki.scanRules.includeHighQualityPhotos\": \"Include high-quality photo directory\"\n}\n"
  },
  {
    "path": "web/src/core/i18n/locales/en-US/gallery.json",
    "content": "{\n  \"gallery.contextMenu.copyFiles.failedTitle\": \"Failed to copy files\",\n  \"gallery.contextMenu.copyFiles.label\": \"Copy Files\",\n  \"gallery.contextMenu.copyFiles.partialDescription\": \"{copied} copied, {failed} failed, {notFound} not found\",\n  \"gallery.contextMenu.copyFiles.partialTitle\": \"Partially copied to clipboard\",\n  \"gallery.contextMenu.copyFiles.successDescription\": \"Copied {count} file(s)\",\n  \"gallery.contextMenu.copyFiles.successTitle\": \"Copied to Clipboard\",\n  \"gallery.contextMenu.moveToTrash.failedDescription\": \"Move failed: {failed} failed, {notFound} not found\",\n  \"gallery.contextMenu.moveToTrash.failedTitle\": \"Failed to move to recycle bin\",\n  \"gallery.contextMenu.moveToTrash.label\": \"Move to Recycle Bin\",\n  \"gallery.contextMenu.moveToTrash.partialDescription\": \"{moved} moved, {failed} failed, {notFound} not found\",\n  \"gallery.contextMenu.moveToTrash.partialTitle\": \"Partially moved to recycle bin\",\n  \"gallery.contextMenu.moveToTrash.successDescription\": \"{count} item(s) processed\",\n  \"gallery.contextMenu.moveToTrash.successTitle\": \"Moved to Recycle Bin\",\n  \"gallery.contextMenu.openDefaultApp.failedTitle\": \"Failed to open file\",\n  \"gallery.contextMenu.openDefaultApp.label\": \"Open With Default App\",\n  \"gallery.contextMenu.revealInExplorer.failedTitle\": \"Failed to reveal file\",\n  \"gallery.contextMenu.revealInExplorer.label\": \"Show in Explorer\",\n  \"gallery.contextMenu.review.flag.clear\": \"Clear Rejected\",\n  \"gallery.contextMenu.review.flag.label\": \"Rejected\",\n  \"gallery.contextMenu.review.rating.clear\": \"Clear Rating\",\n  \"gallery.contextMenu.review.rating.label\": \"Rating\",\n  \"gallery.details.asset.basicInfo\": \"Basic Info\",\n  \"gallery.details.asset.copyFileNameFailed\": \"Failed to copy file name\",\n  \"gallery.details.asset.copyFileNameSuccess\": \"File name copied\",\n  \"gallery.details.asset.description\": \"Description\",\n  \"gallery.details.asset.descriptionPlaceholder\": \"Add description\",\n  \"gallery.details.asset.fileName\": \"File Name\",\n  \"gallery.details.asset.fileSize\": \"File Size\",\n  \"gallery.details.asset.preview\": \"Preview\",\n  \"gallery.details.asset.resolution\": \"Resolution\",\n  \"gallery.details.asset.sizeInfo\": \"Size Info\",\n  \"gallery.details.asset.storagePath\": \"Storage Path\",\n  \"gallery.details.asset.type\": \"Type\",\n  \"gallery.details.asset.updateDescriptionFailed\": \"Failed to update description\",\n  \"gallery.details.assetCount\": \"File Count\",\n  \"gallery.details.assetType.unknown\": \"Unknown\",\n  \"gallery.details.batch.currentFocus\": \"Current Focus Item\",\n  \"gallery.details.batch.focusUnavailable\": \"Current focus item is unavailable (it may not be loaded yet or was filtered out).\",\n  \"gallery.details.batch.helpTitle\": \"Batch Review\",\n  \"gallery.details.batch.placeholder\": \"Coming soon...\",\n  \"gallery.details.batch.reviewHint\": \"Use 0~5, P, X and U to update the current selection quickly.\",\n  \"gallery.details.batch.selectedCount\": \"{count} item(s) selected\",\n  \"gallery.details.batch.title\": \"Batch Actions\",\n  \"gallery.details.colors.copyFailed\": \"Failed to copy color\",\n  \"gallery.details.colors.copySuccess\": \"Copied {hex}\",\n  \"gallery.details.colors.title\": \"Main Colors\",\n  \"gallery.details.empty\": \"Select a file or folder to view details\",\n  \"gallery.details.folderDisplayName\": \"Display Name\",\n  \"gallery.details.folderInfo\": \"Folder Info\",\n  \"gallery.details.folderName\": \"Folder Name\",\n  \"gallery.details.fullPath\": \"Full Path\",\n  \"gallery.details.histogram.empty\": \"No histogram available\",\n  \"gallery.details.histogram.loading\": \"Computing histogram...\",\n  \"gallery.details.histogram.title\": \"Histogram\",\n  \"gallery.details.histogram.unavailable\": \"Histogram unavailable\",\n  \"gallery.details.infinityNikki.apertureValue\": \"Aperture\",\n  \"gallery.details.infinityNikki.cameraFocalLength\": \"Focal Length\",\n  \"gallery.details.infinityNikki.cameraParams\": \"Camera Params\",\n  \"gallery.details.infinityNikki.contrast\": \"Contrast\",\n  \"gallery.details.infinityNikki.codeType.dye\": \"Dye Code\",\n  \"gallery.details.infinityNikki.codeType.homeBuilding\": \"Home Building Code\",\n  \"gallery.details.infinityNikki.copyCameraParams\": \"Copy\",\n  \"gallery.details.infinityNikki.copyCameraParamsFailed\": \"Failed to copy camera params\",\n  \"gallery.details.infinityNikki.copyCameraParamsSuccess\": \"Camera params copied\",\n  \"gallery.details.infinityNikki.copyCodeValue\": \"Copy\",\n  \"gallery.details.infinityNikki.copyCodeValueFailed\": \"Failed to copy record value\",\n  \"gallery.details.infinityNikki.copyCodeValueSuccess\": \"Record value copied\",\n  \"gallery.details.infinityNikki.filterId\": \"Filter\",\n  \"gallery.details.infinityNikki.filterStrength\": \"Filter Strength\",\n  \"gallery.details.infinityNikki.gameTime\": \"In-Game Time\",\n  \"gallery.details.infinityNikki.highlights\": \"Highlights\",\n  \"gallery.details.infinityNikki.lightId\": \"Light Preset\",\n  \"gallery.details.infinityNikki.lightStrength\": \"Light Strength\",\n  \"gallery.details.infinityNikki.nikkiHidden\": \"Hide Nikki\",\n  \"gallery.details.infinityNikki.nikkiLocation\": \"Location\",\n  \"gallery.details.infinityNikki.pasteCodeValue\": \"Paste\",\n  \"gallery.details.infinityNikki.pasteCodeValueFailed\": \"Failed to paste record value\",\n  \"gallery.details.infinityNikki.poseId\": \"Pose\",\n  \"gallery.details.infinityNikki.saveUserRecordFailed\": \"Failed to save player record\",\n  \"gallery.details.infinityNikki.title\": \"Infinity Nikki\",\n  \"gallery.details.infinityNikki.vignetteIntensity\": \"Vignette Intensity\",\n  \"gallery.details.infinityNikki.vertical\": \"Vertical Shot\",\n  \"gallery.details.infinityNikki.vibrance\": \"Vibrance\",\n  \"gallery.details.itemCount\": \"{count} item(s)\",\n  \"gallery.details.parentTagId\": \"Parent Tag ID\",\n  \"gallery.details.review.clearFlag\": \"Clear Rejected\",\n  \"gallery.details.review.clearRating\": \"Clear Rating\",\n  \"gallery.details.review.starLabel\": \"star\",\n  \"gallery.details.review.summary\": \"{rating} star(s) · {flag}\",\n  \"gallery.details.review.title\": \"Review\",\n  \"gallery.details.rootFolderSummary.assetCount\": \"Total Files\",\n  \"gallery.details.rootFolderSummary.folderCount\": \"Root Folder Count\",\n  \"gallery.details.rootFolderSummary.title\": \"Root Folder Overview\",\n  \"gallery.details.rootTagSummary.assetCount\": \"Total Files\",\n  \"gallery.details.rootTagSummary.tagCount\": \"Root Tag Count\",\n  \"gallery.details.rootTagSummary.title\": \"Root Tag Overview\",\n  \"gallery.details.sortOrder\": \"Sort Order\",\n  \"gallery.details.tagInfo\": \"Tag Info\",\n  \"gallery.details.tagName\": \"Tag Name\",\n  \"gallery.details.tags.add\": \"Add Tag\",\n  \"gallery.details.tags.empty\": \"No tags yet\",\n  \"gallery.details.tags.title\": \"Tags\",\n  \"gallery.details.title\": \"Details\",\n  \"gallery.guide.infinityNikki.actionFailedTitle\": \"Failed to apply gallery setup\",\n  \"gallery.guide.infinityNikki.actions.applyRecommended\": \"Apply recommended setup\",\n  \"gallery.guide.infinityNikki.actions.confirmAndApply\": \"Start applying\",\n  \"gallery.guide.infinityNikki.actions.enable\": \"Enable\",\n  \"gallery.guide.infinityNikki.actions.enableMetadataOnly\": \"Enable metadata only\",\n  \"gallery.guide.infinityNikki.actions.skip\": \"Skip\",\n  \"gallery.guide.infinityNikki.actions.skipForNow\": \"Not now\",\n  \"gallery.guide.infinityNikki.credit\": \"Photo data parsing service by \",\n  \"gallery.guide.infinityNikki.creditLink\": \"NUAN5.PRO\",\n  \"gallery.guide.infinityNikki.creditPowered\": \"\",\n  \"gallery.guide.infinityNikki.description\": \"It is recommended to enable the following features for a complete gallery experience and to save disk space.\",\n  \"gallery.guide.infinityNikki.hardlinksDescription\": \"The game saves two identical copies of each photo when taking pictures. This feature will replace the files in ScreenShot with hardlinks to Momo's Album so you keep a single physical file copy.\",\n  \"gallery.guide.infinityNikki.hardlinksDetailsContent\": \"• This feature creates hard link mirrors in ScreenShot based on the NikkiPhotos_HighQuality folder, so both locations refer to the same file data.\\n• New photos stay in sync automatically, and everyday copying or sending still works as usual.\",\n  \"gallery.guide.infinityNikki.hardlinksDetailsTrigger\": \"View directory change details\",\n  \"gallery.guide.infinityNikki.hardlinksTaskStartedDescription\": \"You can track progress from the task menu in the top-right corner.\",\n  \"gallery.guide.infinityNikki.hardlinksTaskStartedTitle\": \"ScreenShot hardlink initialization started\",\n  \"gallery.guide.infinityNikki.hardlinksTitle\": \"Optimize photos in the ScreenShot folder as hardlinks\",\n  \"gallery.guide.infinityNikki.header.subtitle\": \"First-time setup\",\n  \"gallery.guide.infinityNikki.header.title\": \"Infinity Nikki Gallery\",\n  \"gallery.guide.infinityNikki.metadataDescription\": \"Automatically extract hidden shooting parameters from photos (including focal length, filters, lighting, and poses) to display richer details in the gallery. (Requires internet connection)\",\n  \"gallery.guide.infinityNikki.metadataTaskStartedDescription\": \"You can track progress from the task menu in the top-right corner.\",\n  \"gallery.guide.infinityNikki.metadataTaskStartedTitle\": \"Metadata extraction started\",\n  \"gallery.guide.infinityNikki.metadataTitle\": \"Enable photo metadata extraction\",\n  \"gallery.guide.infinityNikki.recommendedTaskStartedDescription\": \"Metadata extraction and ScreenShot hardlink initialization are now running as background tasks.\",\n  \"gallery.guide.infinityNikki.recommendedTaskStartedTitle\": \"Recommended setup started\",\n  \"gallery.guide.infinityNikki.saveFailedTitle\": \"Failed to save gallery guide state\",\n  \"gallery.guide.infinityNikki.step1.title\": \"Photo Metadata Extraction\",\n  \"gallery.guide.infinityNikki.step2.title\": \"Storage Space Optimization\",\n  \"gallery.guide.infinityNikki.step3.description\": \"Next, the app will scan your game photo directories and initialize related tasks. You can monitor progress from the task menu in the top-right corner while continuing to use the app.\",\n  \"gallery.guide.infinityNikki.step3.progressHint\": \"You can monitor progress from the task menu in the top-right corner while continuing to use the app.\",\n  \"gallery.guide.infinityNikki.step3.timeCostNotice\": \"If you have a large number of photos, the first run may take some time.\",\n  \"gallery.guide.infinityNikki.step3.title\": \"Start Applying Configuration\",\n  \"gallery.guide.infinityNikki.title\": \"Welcome to the Infinity Nikki Gallery\",\n  \"gallery.infinityNikki.extractDialog.cancel\": \"Cancel\",\n  \"gallery.infinityNikki.extractDialog.confirm\": \"Start Extraction\",\n  \"gallery.infinityNikki.extractDialog.description\": \"Photos under \\\"{folderName}\\\" and its subfolders will be parsed using the UID you enter.\",\n  \"gallery.infinityNikki.extractDialog.failedTitle\": \"Failed to start extraction\",\n  \"gallery.infinityNikki.extractDialog.invalidUidDescription\": \"Please enter a numeric UID.\",\n  \"gallery.infinityNikki.extractDialog.invalidUidTitle\": \"Invalid UID\",\n  \"gallery.infinityNikki.extractDialog.onlyMissingHint\": \"Turn this off to re-parse existing records within the current folder scope.\",\n  \"gallery.infinityNikki.extractDialog.onlyMissingLabel\": \"Only parse missing items\",\n  \"gallery.infinityNikki.extractDialog.submitting\": \"Submitting\",\n  \"gallery.infinityNikki.extractDialog.successDescription\": \"Background task created: {taskId}\",\n  \"gallery.infinityNikki.extractDialog.successTitle\": \"Metadata extraction started\",\n  \"gallery.infinityNikki.extractDialog.title\": \"Extract Infinity Nikki Metadata\",\n  \"gallery.infinityNikki.extractDialog.uidHint\": \"Use this only when all photos in the current folder scope belong to the same UID.\",\n  \"gallery.infinityNikki.extractDialog.uidLabel\": \"UID\",\n  \"gallery.infinityNikki.extractDialog.uidPlaceholder\": \"Enter the UID for this album\",\n  \"gallery.lightbox.contextMenu.actual\": \"Actual Size\",\n  \"gallery.lightbox.contextMenu.exitImmersive\": \"Exit immersive mode\",\n  \"gallery.lightbox.contextMenu.fit\": \"Fit to View\",\n  \"gallery.lightbox.contextMenu.hideFilmstrip\": \"Hide Filmstrip\",\n  \"gallery.lightbox.contextMenu.immersive\": \"Enter immersive mode\",\n  \"gallery.lightbox.contextMenu.showFilmstrip\": \"Show Filmstrip\",\n  \"gallery.lightbox.contextMenu.zoomIn\": \"Zoom In\",\n  \"gallery.lightbox.contextMenu.zoomOut\": \"Zoom Out\",\n  \"gallery.lightbox.image.fitIndicator\": \"Fit ({percent}%)\",\n  \"gallery.lightbox.image.loadFailed\": \"Failed to load image\",\n  \"gallery.lightbox.image.nextTitle\": \"Next (→)\",\n  \"gallery.lightbox.image.previousTitle\": \"Previous (←)\",\n  \"gallery.lightbox.toolbar.actualTitle\": \"100% actual pixels (Z)\",\n  \"gallery.lightbox.toolbar.backTitle\": \"Back to gallery (ESC)\",\n  \"gallery.lightbox.toolbar.closeTitle\": \"Close (ESC)\",\n  \"gallery.lightbox.toolbar.exitImmersiveTitle\": \"Exit immersive mode (F)\",\n  \"gallery.lightbox.toolbar.filmstripHideTitle\": \"Hide filmstrip (Tab)\",\n  \"gallery.lightbox.toolbar.filmstripShowTitle\": \"Show filmstrip (Tab)\",\n  \"gallery.lightbox.toolbar.fit\": \"Fit\",\n  \"gallery.lightbox.toolbar.fitTitle\": \"Fit to viewport (Z)\",\n  \"gallery.lightbox.toolbar.immersiveTitle\": \"Immersive mode (F)\",\n  \"gallery.lightbox.toolbar.selected\": \"Selected\",\n  \"gallery.lightbox.toolbar.zoomInTitle\": \"Zoom in (= / +)\",\n  \"gallery.lightbox.toolbar.zoomOutTitle\": \"Zoom out (-)\",\n  \"gallery.review.flag.none\": \"Not Rejected\",\n  \"gallery.review.flag.picked\": \"Picked\",\n  \"gallery.review.flag.rejected\": \"Rejected\",\n  \"gallery.review.update.failedTitle\": \"Failed to update review state\",\n  \"gallery.sidebar.common.loading\": \"Loading...\",\n  \"gallery.sidebar.folders.menu.extractInfinityNikkiMetadata\": \"Extract Infinity Nikki Metadata\",\n  \"gallery.sidebar.folders.menu.openInExplorer\": \"Open in Explorer\",\n  \"gallery.sidebar.folders.menu.rescan\": \"Reanalyze This Folder\",\n  \"gallery.sidebar.folders.menu.removeWatch\": \"Remove Watch and Clean Index\",\n  \"gallery.sidebar.folders.menu.renameDisplayName\": \"Rename Display Name\",\n  \"gallery.sidebar.folders.moveAssets.failedDescription\": \"{failed} failed, {notFound} not found, {unchanged} unchanged\",\n  \"gallery.sidebar.folders.moveAssets.failedTitle\": \"Move failed\",\n  \"gallery.sidebar.folders.moveAssets.partialDescription\": \"{moved} moved, {failed} failed, {notFound} not found, {unchanged} unchanged\",\n  \"gallery.sidebar.folders.moveAssets.partialTitle\": \"Partially moved\",\n  \"gallery.sidebar.folders.moveAssets.successDescription\": \"{count} item(s) moved\",\n  \"gallery.sidebar.folders.moveAssets.successTitle\": \"Move completed\",\n  \"gallery.sidebar.folders.openInExplorer.failedTitle\": \"Failed to open folder\",\n  \"gallery.sidebar.folders.removeWatch.cancel\": \"Cancel\",\n  \"gallery.sidebar.folders.removeWatch.confirm\": \"Remove\",\n  \"gallery.sidebar.folders.removeWatch.confirmDescription\": \"This will stop watching this folder and clean its indexed records in gallery (including subfolders). Original files on disk will not be deleted.\",\n  \"gallery.sidebar.folders.removeWatch.confirmTitle\": \"Remove \\\"{name}\\\"?\",\n  \"gallery.sidebar.folders.removeWatch.failedTitle\": \"Failed to remove watch\",\n  \"gallery.sidebar.folders.removeWatch.successDescription\": \"Watching has stopped and index has been cleaned. You can add and scan again anytime.\",\n  \"gallery.sidebar.folders.removeWatch.successTitle\": \"Folder removed\",\n  \"gallery.sidebar.folders.rescan.cancel\": \"Cancel\",\n  \"gallery.sidebar.folders.rescan.confirm\": \"Reanalyze\",\n  \"gallery.sidebar.folders.rescan.confirmDescription\": \"This will reanalyze metadata for all files in this folder and subfolders, and overwrite thumbnails. It may take some time.\",\n  \"gallery.sidebar.folders.rescan.confirmTitle\": \"Reanalyze \\\"{name}\\\"?\",\n  \"gallery.sidebar.folders.rescan.failedTitle\": \"Failed to start reanalysis\",\n  \"gallery.sidebar.folders.rescan.folderNotFoundDescription\": \"Target folder not found. Please refresh and try again.\",\n  \"gallery.sidebar.folders.rescan.queuedDescription\": \"Background task created: {taskId}\",\n  \"gallery.sidebar.folders.rescan.queuedTitle\": \"Reanalysis started\",\n  \"gallery.sidebar.folders.rename.failedTitle\": \"Failed to update display name\",\n  \"gallery.sidebar.folders.rename.placeholder\": \"Enter display name...\",\n  \"gallery.sidebar.folders.title\": \"Folders\",\n  \"gallery.sidebar.scan.addRule\": \"Add Rule\",\n  \"gallery.sidebar.scan.advancedOptions\": \"Advanced Options (Optional)\",\n  \"gallery.sidebar.scan.cancel\": \"Cancel\",\n  \"gallery.sidebar.scan.dialogDescription\": \"Select a directory to add to gallery and run an immediate scan.\",\n  \"gallery.sidebar.scan.dialogTitle\": \"Add and Scan Folder\",\n  \"gallery.sidebar.scan.directoryLabel\": \"Folder Path\",\n  \"gallery.sidebar.scan.directoryPlaceholder\": \"Please select a folder to add\",\n  \"gallery.sidebar.scan.failedTitle\": \"Failed to add folder\",\n  \"gallery.sidebar.scan.generateThumbnails\": \"Generate Thumbnails\",\n  \"gallery.sidebar.scan.generateThumbnailsHint\": \"When disabled, files are indexed without thumbnail generation.\",\n  \"gallery.sidebar.scan.ignoreRules\": \"Ignore Rules (Regex)\",\n  \"gallery.sidebar.scan.loading\": \"Adding and scanning folder...\",\n  \"gallery.sidebar.scan.noRules\": \"No ignore rules yet. Add regex rules to include or exclude files.\",\n  \"gallery.sidebar.scan.partialErrorsTitle\": \"Scan completed with partial errors\",\n  \"gallery.sidebar.scan.patternType\": \"Pattern Type\",\n  \"gallery.sidebar.scan.patternTypeGlob\": \"glob\",\n  \"gallery.sidebar.scan.patternTypeRegex\": \"regex\",\n  \"gallery.sidebar.scan.queuedDescription\": \"Task {taskId} has been queued in background.\",\n  \"gallery.sidebar.scan.queuedTitle\": \"Scan task created\",\n  \"gallery.sidebar.scan.ruleDescription\": \"Description\",\n  \"gallery.sidebar.scan.ruleDescriptionPlaceholder\": \"Optional note\",\n  \"gallery.sidebar.scan.rulePattern\": \"Rule Pattern\",\n  \"gallery.sidebar.scan.rulePatternPlaceholder\": \"Example: ^X6Game\\\\\\\\ScreenShot\\\\\\\\.*\",\n  \"gallery.sidebar.scan.ruleType\": \"Rule Type\",\n  \"gallery.sidebar.scan.ruleTypeExclude\": \"exclude\",\n  \"gallery.sidebar.scan.ruleTypeInclude\": \"include\",\n  \"gallery.sidebar.scan.scanning\": \"Scanning...\",\n  \"gallery.sidebar.scan.selectDialogTitle\": \"Select a folder to add and scan\",\n  \"gallery.sidebar.scan.selectDirectory\": \"Select Folder\",\n  \"gallery.sidebar.scan.selectDirectoryFailed\": \"Failed to select folder\",\n  \"gallery.sidebar.scan.selectDirectoryRequired\": \"Please select a folder first\",\n  \"gallery.sidebar.scan.selectingDirectory\": \"Selecting...\",\n  \"gallery.sidebar.scan.submit\": \"Add and Scan\",\n  \"gallery.sidebar.scan.submitting\": \"Submitting...\",\n  \"gallery.sidebar.scan.successDescription\": \"Scanned {totalFiles} files, added {newItems}, updated {updatedItems}\",\n  \"gallery.sidebar.scan.successTitle\": \"Folder added and scanned\",\n  \"gallery.sidebar.scan.supportedExtensions\": \"Supported Extensions\",\n  \"gallery.sidebar.scan.supportedExtensionsHint\": \"Use comma, semicolon, whitespace, or newline as separators.\",\n  \"gallery.sidebar.scan.thumbnailShortEdge\": \"Thumbnail Short Edge\",\n  \"gallery.sidebar.tags.addAssets.failedDescription\": \"{failed} failed, {unchanged} unchanged\",\n  \"gallery.sidebar.tags.addAssets.failedTitle\": \"Tagging failed\",\n  \"gallery.sidebar.tags.addAssets.partialDescription\": \"{affected} tagged, {failed} failed, {unchanged} unchanged\",\n  \"gallery.sidebar.tags.addAssets.partialTitle\": \"Partially tagged\",\n  \"gallery.sidebar.tags.addAssets.successDescription\": \"{count} item(s) tagged\",\n  \"gallery.sidebar.tags.addAssets.successTitle\": \"Tagging completed\",\n  \"gallery.sidebar.tags.createPlaceholder\": \"Enter tag name...\",\n  \"gallery.sidebar.tags.title\": \"Tags\",\n  \"gallery.toolbar.colorFilter.apply\": \"Apply\",\n  \"gallery.toolbar.colorFilter.clear\": \"Clear\",\n  \"gallery.toolbar.colorFilter.none\": \"No color selected\",\n  \"gallery.toolbar.colorFilter.title\": \"Color Filter\",\n  \"gallery.toolbar.colorFilter.tooltip\": \"Color Filter\",\n  \"gallery.toolbar.deleteSelected.button\": \"Delete ({count})\",\n  \"gallery.toolbar.deleteSelected.tooltip\": \"Delete {count} selected item(s)\",\n  \"gallery.toolbar.filter.flag.all\": \"All\",\n  \"gallery.toolbar.filter.flag.label\": \"Rejected\",\n  \"gallery.toolbar.filter.flag.none\": \"Not Rejected\",\n  \"gallery.toolbar.filter.flag.picked\": \"Picked\",\n  \"gallery.toolbar.filter.flag.rejected\": \"Rejected\",\n  \"gallery.toolbar.filter.rating.all\": \"All Ratings\",\n  \"gallery.toolbar.filter.rating.five\": \"5 Stars\",\n  \"gallery.toolbar.filter.rating.four\": \"4 Stars\",\n  \"gallery.toolbar.filter.rating.label\": \"Rating\",\n  \"gallery.toolbar.filter.rating.one\": \"1 Star\",\n  \"gallery.toolbar.filter.rating.three\": \"3 Stars\",\n  \"gallery.toolbar.filter.rating.two\": \"2 Stars\",\n  \"gallery.toolbar.filter.rating.unrated\": \"Unrated\",\n  \"gallery.toolbar.filter.review.tooltip\": \"Rating & Rejected Filter\",\n  \"gallery.toolbar.filter.type.all\": \"All Types\",\n  \"gallery.toolbar.filter.type.label\": \"File Type\",\n  \"gallery.toolbar.filter.type.livePhoto\": \"Live Photo\",\n  \"gallery.toolbar.filter.type.photo\": \"Photo\",\n  \"gallery.toolbar.filter.type.video\": \"Video\",\n  \"gallery.toolbar.filterAndSort.tooltip\": \"Filter & Sort\",\n  \"gallery.toolbar.folderOptions.includeSubfolders\": \"Include Subfolders\",\n  \"gallery.toolbar.folderOptions.label\": \"Folder Options\",\n  \"gallery.toolbar.search.placeholder\": \"Search file name...\",\n  \"gallery.toolbar.sort.createdAt\": \"Created At\",\n  \"gallery.toolbar.sort.label\": \"Sort By\",\n  \"gallery.toolbar.sort.name\": \"Name\",\n  \"gallery.toolbar.sort.resolution\": \"Resolution\",\n  \"gallery.toolbar.sort.size\": \"Size\",\n  \"gallery.toolbar.sortOrder.asc\": \"Ascending\",\n  \"gallery.toolbar.sortOrder.desc\": \"Descending\",\n  \"gallery.toolbar.thumbnailSize.fine\": \"Compact\",\n  \"gallery.toolbar.thumbnailSize.label\": \"Thumbnail Size\",\n  \"gallery.toolbar.thumbnailSize.showcase\": \"Showcase\",\n  \"gallery.toolbar.viewMode.adaptive\": \"Adaptive\",\n  \"gallery.toolbar.viewMode.grid\": \"Grid\",\n  \"gallery.toolbar.viewMode.label\": \"View Mode\",\n  \"gallery.toolbar.viewMode.list\": \"List\",\n  \"gallery.toolbar.viewMode.masonry\": \"Masonry\",\n  \"gallery.toolbar.viewSettings.tooltip\": \"View Settings\"\n}\n"
  },
  {
    "path": "web/src/core/i18n/locales/en-US/home.json",
    "content": "{\n  \"home.outputDir.openButton\": \"Open Output Folder\",\n  \"home.outputDir.openFailed\": \"Failed to open output folder\"\n}\n"
  },
  {
    "path": "web/src/core/i18n/locales/en-US/map.json",
    "content": "{\n  \"map.popup.fallbackTitle\": \"Photo\",\n  \"map.cluster.title\": \"{count} photos\"\n}\n"
  },
  {
    "path": "web/src/core/i18n/locales/en-US/menu.json",
    "content": "{\n  \"menu.app_exit\": \"Exit\",\n  \"menu.app_float\": \"Floating Window\",\n  \"menu.app_main\": \"Main\",\n  \"menu.external_album_open_folder\": \"Game Album\",\n  \"menu.letterbox_toggle\": \"Letterbox\",\n  \"menu.motion_photo_toggle\": \"Motion Photo\",\n  \"menu.output_open_folder\": \"Output Folder\",\n  \"menu.overlay_toggle\": \"Overlay\",\n  \"menu.preview_toggle\": \"Preview\",\n  \"menu.recording_toggle\": \"Record\",\n  \"menu.replay_buffer_save\": \"Save Replay\",\n  \"menu.replay_buffer_toggle\": \"Instant Replay\",\n  \"menu.screenshot_capture\": \"Capture\",\n  \"menu.window_reset\": \"Reset\"\n}\n"
  },
  {
    "path": "web/src/core/i18n/locales/en-US/onboarding.json",
    "content": "{\n  \"onboarding.actions.complete\": \"Finish\",\n  \"onboarding.actions.completing\": \"Saving...\",\n  \"onboarding.actions.next\": \"Next\",\n  \"onboarding.actions.previous\": \"Previous\",\n  \"onboarding.common.saveFailed\": \"Failed to save onboarding settings. Please try again.\",\n  \"onboarding.completed.description\": \"You can now close this page.\",\n  \"onboarding.completed.title\": \"Setup Complete!\",\n  \"onboarding.description\": \"Please complete this setup on first launch. You can change these later in Settings.\",\n  \"onboarding.step1.description\": \"Choose your preferred interface language and theme first.\",\n  \"onboarding.step1.themeLabel\": \"Interface Theme\",\n  \"onboarding.step1.title\": \"Step 1: Language and Theme\",\n  \"onboarding.step2.description\": \"Optionally enter the window title you want to control. If the game is not running yet, you can leave this blank for now.\",\n  \"onboarding.step2.targetTitleHint\": \"You can set this later through \\\"Select Window\\\" in the floating window's right-click menu.\",\n  \"onboarding.step2.targetTitlePlaceholder\": \"Enter window title\",\n  \"onboarding.step2.title\": \"Step 2: Target Window Title\",\n  \"onboarding.title\": \"Welcome to SpinningMomo\"\n}\n"
  },
  {
    "path": "web/src/core/i18n/locales/en-US/settings.json",
    "content": "{\n  \"settings.appearance.background.backgroundBlurAmount.description\": \"Adjust blur intensity of the global background layer.\",\n  \"settings.appearance.background.backgroundBlurAmount.label\": \"Background Blur\",\n  \"settings.appearance.background.backgroundOpacity.description\": \"Adjust opacity of the global background layer.\",\n  \"settings.appearance.background.backgroundOpacity.label\": \"Background Opacity\",\n  \"settings.appearance.background.description\": \"Customize app background style.\",\n  \"settings.appearance.background.image.autoThemeDescription\": \"Automatically applies theme mode, primary color, and overlay colors from the background image.\",\n  \"settings.appearance.background.image.label\": \"Background Image\",\n  \"settings.appearance.background.image.removeButton\": \"Remove Image\",\n  \"settings.appearance.background.image.selectButton\": \"Select Image\",\n  \"settings.appearance.background.overlayOpacity.description\": \"Adjust opacity of the background gradient overlay color.\",\n  \"settings.appearance.background.overlayOpacity.label\": \"Overlay Color Opacity\",\n  \"settings.appearance.background.overlayPalette.description\": \"Supports symmetric gradient layouts from 1 to 4 colors with quick presets.\",\n  \"settings.appearance.background.overlayPalette.label\": \"Overlay Gradient Palette\",\n  \"settings.appearance.background.overlayPalette.mode.double\": \"Double\",\n  \"settings.appearance.background.overlayPalette.mode.quad\": \"Quad\",\n  \"settings.appearance.background.overlayPalette.mode.single\": \"Single\",\n  \"settings.appearance.background.overlayPalette.mode.title\": \"Color Count\",\n  \"settings.appearance.background.overlayPalette.mode.triple\": \"Triple\",\n  \"settings.appearance.background.overlayPalette.presets.title\": \"Presets\",\n  \"settings.appearance.background.overlayPalette.sampleFromWallpaper\": \"Sample from Wallpaper\",\n  \"settings.appearance.background.surfaceOpacity.description\": \"Only affects panel layer opacity, not the background image layer.\",\n  \"settings.appearance.background.surfaceOpacity.label\": \"Panel Opacity\",\n  \"settings.appearance.background.title\": \"Background Settings\",\n  \"settings.appearance.quickSetup.title\": \"Quick Setup\",\n  \"settings.appearance.description\": \"Customize app theme and layout.\",\n  \"settings.appearance.error.retry\": \"Retry\",\n  \"settings.appearance.error.title\": \"Failed to Load Appearance Settings\",\n  \"settings.appearance.floatingWindowTheme.dark\": \"Dark\",\n  \"settings.appearance.floatingWindowTheme.description\": \"Floating window (UI border) theme mode.\",\n  \"settings.appearance.floatingWindowTheme.label\": \"Floating Window Theme\",\n  \"settings.appearance.floatingWindowTheme.light\": \"Light\",\n  \"settings.appearance.layout.baseFontSize.description\": \"Base font size.\",\n  \"settings.appearance.layout.baseFontSize.label\": \"Font Size\",\n  \"settings.appearance.layout.baseIndicatorWidth.description\": \"Base indicator width.\",\n  \"settings.appearance.layout.baseIndicatorWidth.label\": \"Indicator Width\",\n  \"settings.appearance.layout.baseItemHeight.description\": \"Base item height.\",\n  \"settings.appearance.layout.baseItemHeight.label\": \"Item Height\",\n  \"settings.appearance.layout.baseRatioColumnWidth.description\": \"Base ratio column width.\",\n  \"settings.appearance.layout.baseRatioColumnWidth.label\": \"Ratio Column Width\",\n  \"settings.appearance.layout.baseRatioIndicatorWidth.description\": \"Base ratio indicator width.\",\n  \"settings.appearance.layout.baseRatioIndicatorWidth.label\": \"Ratio Indicator Width\",\n  \"settings.appearance.layout.baseResolutionColumnWidth.description\": \"Base resolution column width.\",\n  \"settings.appearance.layout.baseResolutionColumnWidth.label\": \"Resolution Column Width\",\n  \"settings.appearance.layout.baseSeparatorHeight.description\": \"Base separator height.\",\n  \"settings.appearance.layout.baseSeparatorHeight.label\": \"Separator Height\",\n  \"settings.appearance.layout.baseSettingsColumnWidth.description\": \"Base settings column width.\",\n  \"settings.appearance.layout.baseSettingsColumnWidth.label\": \"Settings Column Width\",\n  \"settings.appearance.layout.baseTextPadding.description\": \"Base text padding.\",\n  \"settings.appearance.layout.baseTextPadding.label\": \"Text Padding\",\n  \"settings.appearance.layout.baseTitleHeight.description\": \"Base title height.\",\n  \"settings.appearance.layout.baseTitleHeight.label\": \"Title Height\",\n  \"settings.appearance.layout.description\": \"Fine-tune window layout dimensions (Advanced).\",\n  \"settings.appearance.layout.maxVisibleRows.description\": \"Rows shown at once per column in paged mode. Minimum is 1.\",\n  \"settings.appearance.layout.maxVisibleRows.label\": \"Visible Rows Per Column\",\n  \"settings.appearance.layout.rowsUnit\": \"rows\",\n  \"settings.appearance.layout.title\": \"Layout Parameters\",\n  \"settings.appearance.layout.unit\": \"px\",\n  \"settings.appearance.loading\": \"Loading appearance settings...\",\n  \"settings.appearance.reset.description\": \"Are you sure you want to reset all appearance settings?\",\n  \"settings.appearance.reset.title\": \"Reset Appearance Settings\",\n  \"settings.appearance.theme.customCss.description\": \"Add CSS to fine-tune fonts, colors, and other interface styles.\",\n  \"settings.appearance.theme.customCss.label\": \"Custom CSS\",\n  \"settings.appearance.theme.customCss.placeholder\": \"e.g. :root { --app-font-cjk: 'Your Font', 'Microsoft YaHei', sans-serif; }\",\n  \"settings.appearance.theme.dark\": \"Dark\",\n  \"settings.appearance.theme.description\": \"Advanced customization options for the web interface.\",\n  \"settings.appearance.theme.light\": \"Light\",\n  \"settings.appearance.theme.mode.description\": \"Controls text and UI element color scheme - does not affect background overlay colors.\",\n  \"settings.appearance.theme.mode.label\": \"UI Color Mode\",\n  \"settings.appearance.theme.primaryColor.description\": \"Used for buttons, selected states, and focus highlights.\",\n  \"settings.appearance.theme.primaryColor.label\": \"Primary Color\",\n  \"settings.appearance.theme.system\": \"Follow System\",\n  \"settings.appearance.theme.title\": \"Interface Settings\",\n  \"settings.appearance.title\": \"Appearance Settings\",\n  \"settings.appearance.webview.transparentBackground.description\": \"Enable transparent background effects. Disable to use an opaque theme-based background for better performance.\",\n  \"settings.appearance.webview.transparentBackground.label\": \"WebView Transparent Background\",\n  \"settings.capture.description\": \"Configure screenshot, recording, motion photo, and replay output.\",\n  \"settings.capture.error.retry\": \"Retry\",\n  \"settings.capture.error.title\": \"Failed to Load Capture and Output Settings\",\n  \"settings.capture.loading\": \"Loading capture and output settings...\",\n  \"settings.capture.reset.description\": \"Are you sure you want to reset all capture and output settings?\",\n  \"settings.capture.reset.title\": \"Reset Capture & Output Settings\",\n  \"settings.capture.title\": \"Capture & Output\",\n  \"settings.error.retry\": \"Retry\",\n  \"settings.error.title\": \"Failed to Load Settings\",\n  \"settings.extensions.description\": \"Configure built-in game extensions and related behaviors.\",\n  \"settings.extensions.error.retry\": \"Retry\",\n  \"settings.extensions.error.title\": \"Failed to Load Extensions Settings\",\n  \"settings.extensions.infinityNikki.description\": \"Configure gallery watching, metadata extraction, and ScreenShot hardlink behavior for Infinity Nikki.\",\n  \"settings.extensions.infinityNikki.enable.description\": \"Enable Infinity Nikki import and automation features inside the gallery.\",\n  \"settings.extensions.infinityNikki.enable.label\": \"Enable Infinity Nikki extension\",\n  \"settings.extensions.infinityNikki.gameDir.detectButton\": \"Auto Detect\",\n  \"settings.extensions.infinityNikki.gameDir.detectFailedDescription\": \"Please make sure the game is installed, or choose the directory manually.\",\n  \"settings.extensions.infinityNikki.gameDir.detectFailedTitle\": \"Failed to detect Infinity Nikki game directory\",\n  \"settings.extensions.infinityNikki.gameDir.detecting\": \"Detecting...\",\n  \"settings.extensions.infinityNikki.gameDir.detectSuccessTitle\": \"Infinity Nikki game directory detected\",\n  \"settings.extensions.infinityNikki.gameDir.dialogTitle\": \"Select Infinity Nikki game directory\",\n  \"settings.extensions.infinityNikki.gameDir.empty\": \"Infinity Nikki game directory is not configured\",\n  \"settings.extensions.infinityNikki.gameDir.invalidDescription\": \"Please choose the game root directory that contains InfinityNikki.exe.\",\n  \"settings.extensions.infinityNikki.gameDir.invalidTitle\": \"Invalid directory\",\n  \"settings.extensions.infinityNikki.gameDir.label\": \"Game Directory\",\n  \"settings.extensions.infinityNikki.gameDir.selectButton\": \"Choose Folder\",\n  \"settings.extensions.infinityNikki.gameDir.selectFailedTitle\": \"Failed to select Infinity Nikki game directory\",\n  \"settings.extensions.infinityNikki.gameDir.selecting\": \"Selecting...\",\n  \"settings.extensions.infinityNikki.gameDir.selectSuccessTitle\": \"Infinity Nikki game directory updated\",\n  \"settings.extensions.infinityNikki.hardlinks.description\": \"Replace duplicate photos in the ScreenShot directory with hardlinks pointing to Momo's Album to save disk space.\",\n  \"settings.extensions.infinityNikki.hardlinks.label\": \"Manage ScreenShot hardlinks\",\n  \"settings.extensions.infinityNikki.initialization.completeButton\": \"Complete Initialization\",\n  \"settings.extensions.infinityNikki.initialization.completed\": \"Completed\",\n  \"settings.extensions.infinityNikki.initialization.completedDescription\": \"Infinity Nikki gallery watching has been initialized and new photos will be processed according to the current settings.\",\n  \"settings.extensions.infinityNikki.initialization.completeFailedTitle\": \"Failed to initialize Infinity Nikki gallery\",\n  \"settings.extensions.infinityNikki.initialization.completeSuccessDescription\": \"You can track the initialization progress from the background tasks menu in the top-right corner.\",\n  \"settings.extensions.infinityNikki.initialization.completeSuccessTitle\": \"Infinity Nikki gallery initialization started\",\n  \"settings.extensions.infinityNikki.initialization.completing\": \"Initializing...\",\n  \"settings.extensions.infinityNikki.initialization.label\": \"Gallery Initialization\",\n  \"settings.extensions.infinityNikki.initialization.notReady\": \"Not Ready\",\n  \"settings.extensions.infinityNikki.initialization.notReadyDescription\": \"Enable the extension and configure a valid game directory before completing initialization.\",\n  \"settings.extensions.infinityNikki.initialization.pending\": \"Pending\",\n  \"settings.extensions.infinityNikki.initialization.pendingDescription\": \"Completing initialization will register the gallery watcher and start the initial scan or background tasks based on the current switches.\",\n  \"settings.extensions.infinityNikki.initialization.requirementsDescription\": \"Enable the Infinity Nikki extension and configure a valid game directory first.\",\n  \"settings.extensions.infinityNikki.initialization.requirementsTitle\": \"Initialization unavailable\",\n  \"settings.extensions.infinityNikki.metadata.description\": \"Automatically fetch and parse hidden shooting parameters so the gallery can show richer details.\",\n  \"settings.extensions.infinityNikki.metadata.label\": \"Auto extract photo metadata\",\n  \"settings.extensions.infinityNikki.saveFailedTitle\": \"Failed to save Infinity Nikki settings\",\n  \"settings.extensions.infinityNikki.title\": \"Infinity Nikki\",\n  \"settings.extensions.loading\": \"Loading extensions settings...\",\n  \"settings.extensions.reset.description\": \"Are you sure you want to reset all extensions settings?\",\n  \"settings.extensions.reset.title\": \"Reset Extensions Settings\",\n  \"settings.extensions.title\": \"Extensions\",\n  \"settings.floatingWindow.description\": \"Configure floating window menus, presets, theme, and layout.\",\n  \"settings.floatingWindow.error.retry\": \"Retry\",\n  \"settings.floatingWindow.error.title\": \"Failed to Load Floating Window Settings\",\n  \"settings.floatingWindow.loading\": \"Loading floating window settings...\",\n  \"settings.floatingWindow.reset.description\": \"Are you sure you want to reset all floating window settings?\",\n  \"settings.floatingWindow.reset.title\": \"Reset Floating Window Settings\",\n  \"settings.floatingWindow.theme.description\": \"Adjust floating window color mode.\",\n  \"settings.floatingWindow.theme.title\": \"Floating Window Theme\",\n  \"settings.floatingWindow.title\": \"Floating Window Settings\",\n  \"settings.function.description\": \"Configure core application functionality behavior.\",\n  \"settings.function.error.title\": \"Failed to Load Function Settings\",\n  \"settings.function.loading\": \"Loading function settings...\",\n  \"settings.function.motionPhoto.audioBitrate.description\": \"Motion Photo video audio bitrate (kbps).\",\n  \"settings.function.motionPhoto.audioBitrate.label\": \"Audio Bitrate\",\n  \"settings.function.motionPhoto.audioSource.description\": \"Motion Photo video audio source.\",\n  \"settings.function.motionPhoto.audioSource.label\": \"Audio Source\",\n  \"settings.function.motionPhoto.bitrate.description\": \"Fixed bitrate for CBR mode (Mbps).\",\n  \"settings.function.motionPhoto.bitrate.label\": \"Bitrate\",\n  \"settings.function.motionPhoto.codec.description\": \"Motion Photo video encoding format.\",\n  \"settings.function.motionPhoto.codec.label\": \"Codec\",\n  \"settings.function.motionPhoto.description\": \"Configure Motion Photo feature parameters.\",\n  \"settings.function.motionPhoto.duration.description\": \"Duration of video in Motion Photo (seconds).\",\n  \"settings.function.motionPhoto.duration.label\": \"Video Duration\",\n  \"settings.function.motionPhoto.enabled.description\": \"Automatically generate Motion Photo with video when taking screenshots.\",\n  \"settings.function.motionPhoto.enabled.label\": \"Enable Motion Photo\",\n  \"settings.function.motionPhoto.fps.description\": \"Motion Photo video frame rate (FPS).\",\n  \"settings.function.motionPhoto.fps.label\": \"Frame Rate\",\n  \"settings.function.motionPhoto.quality.description\": \"Quality level for VBR mode (0-100, higher is better).\",\n  \"settings.function.motionPhoto.quality.label\": \"Quality\",\n  \"settings.function.motionPhoto.rateControl.description\": \"Choose between constant bitrate or quality-based mode.\",\n  \"settings.function.motionPhoto.rateControl.label\": \"Rate Control Mode\",\n  \"settings.function.motionPhoto.resolution.description\": \"Short edge resolution for Motion Photo video. 0 = use capture resolution (no scaling).\",\n  \"settings.function.motionPhoto.resolution.label\": \"Video Resolution\",\n  \"settings.function.motionPhoto.resolution.original\": \"Original\",\n  \"settings.function.motionPhoto.title\": \"Motion Photo\",\n  \"settings.function.outputDir.default\": \"Using user Videos folder (Videos/SpinningMomo)\",\n  \"settings.function.outputDir.description\": \"Configure where screenshots and recordings are saved.\",\n  \"settings.function.outputDir.dialogTitle\": \"Select Output Directory\",\n  \"settings.function.outputDir.driveRootNotAllowedDescription\": \"Please pick a separate folder (for example D:\\\\Videos\\\\SpinningMomo), not just the drive letter root.\",\n  \"settings.function.outputDir.driveRootNotAllowedTitle\": \"Drive root is not allowed\",\n  \"settings.function.outputDir.label\": \"Save Directory\",\n  \"settings.function.outputDir.selectButton\": \"Select\",\n  \"settings.function.outputDir.selecting\": \"Selecting...\",\n  \"settings.function.outputDir.title\": \"Output Directory\",\n  \"settings.function.recording.audioBitrate.description\": \"Audio encoding bitrate (kbps).\",\n  \"settings.function.recording.audioBitrate.label\": \"Audio Bitrate\",\n  \"settings.function.recording.audioSource.description\": \"Select audio recording source.\",\n  \"settings.function.recording.audioSource.gameOnly\": \"Game Audio Only\",\n  \"settings.function.recording.audioSource.gameOnlyFallbackHint\": \"\\\"Game Audio Only\\\" is not supported on this system and will automatically fall back to System Audio.\",\n  \"settings.function.recording.audioSource.label\": \"Audio Source\",\n  \"settings.function.recording.audioSource.none\": \"No Audio\",\n  \"settings.function.recording.audioSource.system\": \"System Audio\",\n  \"settings.function.recording.autoRestartOnResize.description\": \"When window size changes during recording, automatically end the current segment and continue with the new size.\",\n  \"settings.function.recording.autoRestartOnResize.label\": \"Auto Split On Resize\",\n  \"settings.function.recording.bitrate.description\": \"Fixed bitrate for CBR mode (Mbps).\",\n  \"settings.function.recording.bitrate.label\": \"Bitrate\",\n  \"settings.function.recording.captureClientArea.description\": \"Capture only the target window client area, excluding title bar and borders.\",\n  \"settings.function.recording.captureClientArea.label\": \"Borderless Capture\",\n  \"settings.function.recording.captureCursor.description\": \"Whether to include the mouse cursor in recording.\",\n  \"settings.function.recording.captureCursor.label\": \"Show Cursor\",\n  \"settings.function.recording.captureCursor.unsupportedHint\": \"Full cursor capture control is not supported on this system, so this toggle may not fully take effect.\",\n  \"settings.function.recording.codec.description\": \"Select video encoding format.\",\n  \"settings.function.recording.codec.label\": \"Codec\",\n  \"settings.function.recording.description\": \"Configure screen recording parameters.\",\n  \"settings.function.recording.encoderMode.auto\": \"Auto (GPU Preferred)\",\n  \"settings.function.recording.encoderMode.cpu\": \"CPU Software Encoding\",\n  \"settings.function.recording.encoderMode.description\": \"Select video encoder type.\",\n  \"settings.function.recording.encoderMode.gpu\": \"GPU Hardware Encoding\",\n  \"settings.function.recording.encoderMode.label\": \"Encoder Mode\",\n  \"settings.function.recording.fps.description\": \"Recording frame rate (FPS).\",\n  \"settings.function.recording.fps.label\": \"Frame Rate\",\n  \"settings.function.recording.qp.description\": \"Target QP value for Manual QP mode (0-51, lower is better, requires hardware support).\",\n  \"settings.function.recording.qp.label\": \"Quantization Parameter (QP)\",\n  \"settings.function.recording.quality.description\": \"Quality level for VBR mode (0-100, higher is better).\",\n  \"settings.function.recording.quality.label\": \"Quality\",\n  \"settings.function.recording.rateControl.cbr\": \"CBR (Constant Bitrate)\",\n  \"settings.function.recording.rateControl.description\": \"Choose between constant bitrate, quality-based, or manual QP mode.\",\n  \"settings.function.recording.rateControl.label\": \"Rate Control Mode\",\n  \"settings.function.recording.rateControl.manualQp\": \"Manual QP (Advanced)\",\n  \"settings.function.recording.rateControl.vbr\": \"VBR (Quality-Based)\",\n  \"settings.function.recording.title\": \"Recording Settings\",\n  \"settings.function.replayBuffer.description\": \"Configure Instant Replay parameters. Recording settings are inherited from Recording Settings.\",\n  \"settings.function.replayBuffer.duration.description\": \"Maximum duration for instant replay saves (seconds).\",\n  \"settings.function.replayBuffer.duration.label\": \"Replay Duration\",\n  \"settings.function.replayBuffer.title\": \"Instant Replay\",\n  \"settings.function.reset.description\": \"Are you sure you want to reset all function settings?\",\n  \"settings.function.reset.title\": \"Reset Function Settings\",\n  \"settings.function.screenshot.description\": \"Configure game album directory path for quick access to game screenshot folder.\",\n  \"settings.function.screenshot.gameAlbum.description\": \"Auto-detect game screenshot directory\",\n  \"settings.function.screenshot.gameAlbum.dialogTitle\": \"Select Game Album Directory\",\n  \"settings.function.screenshot.gameAlbum.label\": \"Game Album Directory\",\n  \"settings.function.screenshot.gameAlbum.selectButton\": \"Select\",\n  \"settings.function.screenshot.gameAlbum.selecting\": \"Selecting...\",\n  \"settings.function.screenshot.title\": \"Game Album\",\n  \"settings.function.title\": \"Function Settings\",\n  \"settings.function.windowControl.centerLockCursor.description\": \"When the target window has already locked the cursor, further constrain it to a small region at the center to reduce accidental clicks on the floating window or other always-on-top windows.\",\n  \"settings.function.windowControl.centerLockCursor.label\": \"Force Cursor to Center While Locked\",\n  \"settings.function.windowControl.description\": \"Configure target window and taskbar behavior.\",\n  \"settings.function.windowControl.layeredCaptureWorkaround.description\": \"When the target window is larger than the screen, temporarily enable a layered-window workaround to improve preview and screenshot capture stability outside the visible area.\",\n  \"settings.function.windowControl.layeredCaptureWorkaround.label\": \"Oversized Capture Workaround\",\n  \"settings.function.windowControl.resetResolution.height.description\": \"Window height for fixed resolution mode.\",\n  \"settings.function.windowControl.resetResolution.height.label\": \"Height\",\n  \"settings.function.windowControl.resetResolution.mode.custom\": \"Fixed Resolution\",\n  \"settings.function.windowControl.resetResolution.mode.description\": \"Choose whether the \\\"Reset Window\\\" command uses screen size or a fixed resolution.\",\n  \"settings.function.windowControl.resetResolution.mode.label\": \"Reset Window Size\",\n  \"settings.function.windowControl.resetResolution.mode.screen\": \"Follow Screen\",\n  \"settings.function.windowControl.resetResolution.width.description\": \"Window width for fixed resolution mode.\",\n  \"settings.function.windowControl.resetResolution.width.label\": \"Width\",\n  \"settings.function.windowControl.title\": \"Window Control\",\n  \"settings.function.windowControl.windowTitle.description\": \"Name of the target window to adjust.\",\n  \"settings.function.windowControl.windowTitle.label\": \"Target Window Title\",\n  \"settings.function.windowControl.windowTitle.placeholder\": \"Enter window title...\",\n  \"settings.function.windowControl.windowTitle.update\": \"Update\",\n  \"settings.general.description\": \"Manage general app configuration including language, logging, and updates.\",\n  \"settings.general.hotkey.description\": \"Customize global hotkeys for the app.\",\n  \"settings.general.hotkey.floatingWindow\": \"Show/Hide\",\n  \"settings.general.hotkey.floatingWindowDescription\": \"Toggle floating window visibility.\",\n  \"settings.general.hotkey.recorder.notSet\": \"Not Set\",\n  \"settings.general.hotkey.recorder.pressKey\": \"Press key...\",\n  \"settings.general.hotkey.recording\": \"Recording\",\n  \"settings.general.hotkey.recordingDescription\": \"Hotkey to start/stop recording.\",\n  \"settings.general.hotkey.screenshot\": \"Screenshot\",\n  \"settings.general.hotkey.screenshotDescription\": \"Hotkey to trigger screenshot.\",\n  \"settings.general.hotkey.title\": \"Hotkeys\",\n  \"settings.general.language.description\": \"Select the app's display language.\",\n  \"settings.general.language.displayLanguage\": \"Display Language\",\n  \"settings.general.language.displayLanguageDescription\": \"Primary language for the application.\",\n  \"settings.general.language.title\": \"Language\",\n  \"settings.general.logger.description\": \"Configure app logging level for troubleshooting.\",\n  \"settings.general.logger.level\": \"Log Level\",\n  \"settings.general.logger.levelDescription\": \"Select the verbosity of logging.\",\n  \"settings.general.logger.title\": \"Logging\",\n  \"settings.general.title\": \"General Settings\",\n  \"settings.general.update.autoCheck.description\": \"Checks for new versions in the background after the app starts without blocking startup.\",\n  \"settings.general.update.autoCheck.label\": \"Automatically check for updates on startup\",\n  \"settings.general.update.autoUpdateOnExit.description\": \"After a new version is detected, the update package is downloaded in the background and applied the next time you exit the app.\",\n  \"settings.general.update.autoUpdateOnExit.label\": \"Automatically install updates on exit after one is found\",\n  \"settings.general.update.autoUpdateOnExit.requiresAutoCheck\": \"Enable automatic update checks on startup first.\",\n  \"settings.general.update.descriptionLink\": \"About\",\n  \"settings.general.update.descriptionPrefix\": \"Manage automatic update behavior; manual update checks remain available on the \",\n  \"settings.general.update.descriptionSuffix\": \" page.\",\n  \"settings.general.update.title\": \"Updates\",\n  \"settings.hotkeys.description\": \"Customize global hotkeys for quick actions.\",\n  \"settings.hotkeys.error.retry\": \"Retry\",\n  \"settings.hotkeys.error.title\": \"Failed to Load Hotkey Settings\",\n  \"settings.hotkeys.loading\": \"Loading hotkey settings...\",\n  \"settings.hotkeys.reset.description\": \"Are you sure you want to reset all hotkey settings?\",\n  \"settings.hotkeys.reset.title\": \"Reset Hotkeys\",\n  \"settings.hotkeys.title\": \"Hotkey Settings\",\n  \"settings.layout.capture.description\": \"Screenshot and Recording Settings\",\n  \"settings.layout.capture.title\": \"Capture & Output\",\n  \"settings.layout.extensions.description\": \"Infinity Nikki and other game integrations\",\n  \"settings.layout.extensions.title\": \"Extensions\",\n  \"settings.layout.floatingWindow.description\": \"Floating Window Menu and Layout\",\n  \"settings.layout.floatingWindow.title\": \"Floating Window\",\n  \"settings.layout.general.description\": \"General Settings\",\n  \"settings.layout.general.title\": \"General\",\n  \"settings.layout.hotkeys.description\": \"Global Hotkey Settings\",\n  \"settings.layout.hotkeys.title\": \"Hotkeys\",\n  \"settings.layout.webAppearance.description\": \"Web Interface Visual Style\",\n  \"settings.layout.webAppearance.title\": \"Main UI Appearance\",\n  \"settings.layout.windowScene.description\": \"Window behavior and scene framing settings\",\n  \"settings.layout.windowScene.title\": \"Window & Scene\",\n  \"settings.loading\": \"Loading settings...\",\n  \"settings.menu.actions.add\": \"Add\",\n  \"settings.menu.actions.addCustomItem\": \"Add Custom Item\",\n  \"settings.menu.actions.cancel\": \"Cancel\",\n  \"settings.menu.aspectRatio.description\": \"Manage available window aspect ratio presets.\",\n  \"settings.menu.aspectRatio.placeholder\": \"e.g. 16:9\",\n  \"settings.menu.aspectRatio.title\": \"Aspect Ratio Presets\",\n  \"settings.menu.description\": \"Customize app menu items and presets.\",\n  \"settings.menu.error.retry\": \"Retry\",\n  \"settings.menu.error.title\": \"Failed to Load Menu Settings\",\n  \"settings.menu.feature.description\": \"Manage feature buttons displayed in menu.\",\n  \"settings.menu.feature.title\": \"Feature Items\",\n  \"settings.menu.loading\": \"Loading menu settings...\",\n  \"settings.menu.reset.description\": \"Are you sure you want to reset all menu settings?\",\n  \"settings.menu.reset.title\": \"Reset Menu Settings\",\n  \"settings.menu.resolution.description\": \"Manage available window resolution presets.\",\n  \"settings.menu.resolution.placeholder\": \"e.g. 1920x1080\",\n  \"settings.menu.resolution.title\": \"Resolution Presets\",\n  \"settings.menu.status.hidden\": \"Hidden\",\n  \"settings.menu.status.noFeatureItems\": \"No feature items\",\n  \"settings.menu.status.noPresetItems\": \"No preset items\",\n  \"settings.menu.status.visible\": \"Visible\",\n  \"settings.menu.title\": \"Menu Settings\",\n  \"settings.reset.description\": \"Are you sure you want to reset this page's settings? This action cannot be undone.\",\n  \"settings.reset.dialog.cancelText\": \"Cancel\",\n  \"settings.reset.dialog.confirmText\": \"Confirm Reset\",\n  \"settings.reset.dialog.resetting\": \"Resetting...\",\n  \"settings.reset.dialog.triggerText\": \"Reset\",\n  \"settings.reset.success\": \"Settings Reset\",\n  \"settings.reset.title\": \"Reset Settings\",\n  \"settings.webAppearance.description\": \"Customize web interface theme and background effects.\",\n  \"settings.webAppearance.error.retry\": \"Retry\",\n  \"settings.webAppearance.error.title\": \"Failed to Load Main UI Appearance Settings\",\n  \"settings.webAppearance.loading\": \"Loading main UI appearance settings...\",\n  \"settings.webAppearance.reset.description\": \"Are you sure you want to reset web theme and background settings?\",\n  \"settings.webAppearance.reset.title\": \"Reset Main UI Appearance\",\n  \"settings.webAppearance.title\": \"Main UI Appearance\",\n  \"settings.windowScene.description\": \"Configure target window behavior and scene framing options.\",\n  \"settings.windowScene.error.retry\": \"Retry\",\n  \"settings.windowScene.error.title\": \"Failed to Load Window and Scene Settings\",\n  \"settings.windowScene.loading\": \"Loading window and scene settings...\",\n  \"settings.windowScene.reset.description\": \"Are you sure you want to reset all window and scene settings?\",\n  \"settings.windowScene.reset.title\": \"Reset Window & Scene Settings\",\n  \"settings.windowScene.title\": \"Window & Scene\"\n}\n"
  },
  {
    "path": "web/src/core/i18n/locales/zh-CN/about.json",
    "content": "{\n  \"about.actions.checkingUpdate\": \"正在检查更新...\",\n  \"about.actions.copyDiagnostics\": \"复制诊断信息\",\n  \"about.actions.downloadUpdate\": \"下载更新 {version}\",\n  \"about.actions.installDownloadedUpdate\": \"立即安装 {version}\",\n  \"about.actions.installingUpdate\": \"正在安装...\",\n  \"about.actions.openDataDirectory\": \"应用数据\",\n  \"about.actions.openLogDirectory\": \"日志目录\",\n  \"about.diagnostics.title\": \"SpinningMomo 诊断信息\",\n  \"about.issuesDialog.description\": \"若遇到问题，可先复制下方环境信息，再前往 GitHub 提交 Issue，便于开发者排查。\",\n  \"about.issuesDialog.openOnGithub\": \"前往 GitHub 提交 Issue\",\n  \"about.footer.creditLink\": \"NUAN5.PRO\",\n  \"about.footer.creditSuffix\": \" 的支持。\",\n  \"about.footer.openSourceLink\": \"开源项目\",\n  \"about.footer.openSourcePrefix\": \"旋转吧大喵 的诞生，离不开众多优秀的 \",\n  \"about.footer.openSourceSuffix\": \" 以及 \",\n  \"about.footer.rightsReserved\": \"保留所有权利。\",\n  \"about.links.issues\": \"问题反馈\",\n  \"about.links.legalNotice\": \"法律与隐私说明\",\n  \"about.links.license\": \"开源协议\",\n  \"about.links.officialWebsite\": \"官方网站\",\n  \"about.runtime.available\": \"可用\",\n  \"about.runtime.capture\": \"图形捕获支持\",\n  \"about.runtime.environment\": \"运行环境\",\n  \"about.runtime.environmentWeb\": \"浏览器 (Web)\",\n  \"about.runtime.environmentWebview\": \"桌面 WebView2\",\n  \"about.runtime.loopback\": \"游戏音频回环支持\",\n  \"about.runtime.os\": \"Windows 版本\",\n  \"about.runtime.supported\": \"支持\",\n  \"about.runtime.unavailable\": \"不可用\",\n  \"about.runtime.unsupported\": \"不支持\",\n  \"about.runtime.version\": \"应用版本\",\n  \"about.runtime.webview2\": \"WebView2\",\n  \"about.status.copied\": \"已复制\",\n  \"about.toast.openDataDirectoryFailed\": \"打开数据目录失败\",\n  \"about.toast.openLogDirectoryFailed\": \"打开日志目录失败\",\n  \"about.toast.updateAvailable\": \"发现可用更新\",\n  \"about.toast.updateCheckFailed\": \"检查更新失败\",\n  \"about.toast.updateDownloadFailed\": \"启动更新下载失败\",\n  \"about.toast.updateInstallFailed\": \"安装更新失败\",\n  \"about.toast.upToDate\": \"当前已是最新版本\"\n}\n"
  },
  {
    "path": "web/src/core/i18n/locales/zh-CN/app.json",
    "content": "{\n  \"app.header.gallery.extension.infinityNikki.gameDirMissingDescription\": \"请先在欢迎页或设置中完成 InfinityNikki 游戏目录配置。\",\n  \"app.header.gallery.extension.infinityNikki.gameDirMissingTitle\": \"未配置 InfinityNikki 游戏目录\",\n  \"app.header.gallery.extension.infinityNikki.importAlbum\": \"导入无限暖暖相册\",\n  \"app.header.gallery.infinityNikki.menuTitle\": \"无限暖暖\",\n  \"app.header.gallery.toggleDetails.hide\": \"隐藏右侧面板\",\n  \"app.header.gallery.toggleDetails.show\": \"显示右侧面板\",\n  \"app.header.gallery.toggleSidebar.hide\": \"隐藏左侧面板\",\n  \"app.header.gallery.toggleSidebar.show\": \"显示左侧面板\",\n  \"app.header.tasks.activeCount\": \"任务 ({count})\",\n  \"app.header.tasks.button\": \"后台任务\",\n  \"app.header.tasks.clearFailedTitle\": \"清空后台任务失败\",\n  \"app.header.tasks.clearFinished\": \"清空已结束\",\n  \"app.header.tasks.collapse\": \"收起\",\n  \"app.header.tasks.expand\": \"展开\",\n  \"app.header.tasks.none\": \"暂无后台任务。\",\n  \"app.header.tasks.recordCount\": \"共 {count} 条记录\",\n  \"app.header.tasks.status.cancelled\": \"已取消\",\n  \"app.header.tasks.status.failed\": \"失败\",\n  \"app.header.tasks.status.queued\": \"排队中\",\n  \"app.header.tasks.status.running\": \"进行中\",\n  \"app.header.tasks.status.succeeded\": \"已完成\",\n  \"app.header.tasks.type.galleryScan\": \"图库扫描\",\n  \"app.header.tasks.type.infinityNikkiExtractPhotoParams\": \"暖暖照片元数据解析\",\n  \"app.header.tasks.type.infinityNikkiInitialScan\": \"暖暖相册首次扫描\",\n  \"app.header.tasks.type.infinityNikkiInitScreenshotHardlinks\": \"ScreenShot 硬链接初始化\",\n  \"app.header.tasks.type.updateDownload\": \"应用更新下载\",\n  \"app.name\": \"旋转吧大喵\"\n}\n"
  },
  {
    "path": "web/src/core/i18n/locales/zh-CN/common.json",
    "content": "{\n  \"common.languageEnUs\": \"English\",\n  \"common.languageLabelBilingual\": \"语言 / Language\",\n  \"common.languageZhCn\": \"简体中文\",\n  \"common.no\": \"否\",\n  \"common.windowTitlePicker.empty\": \"未找到可用窗口\",\n  \"common.windowTitlePicker.loadFailed\": \"获取窗口列表失败\",\n  \"common.windowTitlePicker.loading\": \"正在获取窗口列表...\",\n  \"common.windowTitlePicker.retry\": \"重试\",\n  \"common.windowTitlePicker.trigger\": \"从当前窗口选择\",\n  \"common.yes\": \"是\"\n}\n"
  },
  {
    "path": "web/src/core/i18n/locales/zh-CN/extensions.json",
    "content": "{\n  \"extensions.infinityNikki.album.rootDisplayName\": \"无限暖暖\",\n  \"extensions.infinityNikki.scanRules.excludeAll\": \"默认排除所有文件\",\n  \"extensions.infinityNikki.scanRules.includeHighQualityPhotos\": \"包含高质量照片目录\"\n}\n"
  },
  {
    "path": "web/src/core/i18n/locales/zh-CN/gallery.json",
    "content": "{\n  \"gallery.contextMenu.copyFiles.failedTitle\": \"复制文件失败\",\n  \"gallery.contextMenu.copyFiles.label\": \"复制文件\",\n  \"gallery.contextMenu.copyFiles.partialDescription\": \"已复制 {copied} 项，失败 {failed} 项，未找到 {notFound} 项\",\n  \"gallery.contextMenu.copyFiles.partialTitle\": \"部分复制到剪贴板\",\n  \"gallery.contextMenu.copyFiles.successDescription\": \"共复制 {count} 个文件\",\n  \"gallery.contextMenu.copyFiles.successTitle\": \"已复制到剪贴板\",\n  \"gallery.contextMenu.moveToTrash.failedDescription\": \"移动失败：失败 {failed} 项，未找到 {notFound} 项\",\n  \"gallery.contextMenu.moveToTrash.failedTitle\": \"移到回收站失败\",\n  \"gallery.contextMenu.moveToTrash.label\": \"移到回收站\",\n  \"gallery.contextMenu.moveToTrash.partialDescription\": \"已移动 {moved} 项，失败 {failed} 项，未找到 {notFound} 项\",\n  \"gallery.contextMenu.moveToTrash.partialTitle\": \"部分移到回收站\",\n  \"gallery.contextMenu.moveToTrash.successDescription\": \"共处理 {count} 项文件\",\n  \"gallery.contextMenu.moveToTrash.successTitle\": \"已移到回收站\",\n  \"gallery.contextMenu.openDefaultApp.failedTitle\": \"打开文件失败\",\n  \"gallery.contextMenu.openDefaultApp.label\": \"默认应用打开\",\n  \"gallery.contextMenu.revealInExplorer.failedTitle\": \"在资源管理器中定位失败\",\n  \"gallery.contextMenu.revealInExplorer.label\": \"在资源管理器中打开\",\n  \"gallery.contextMenu.review.flag.clear\": \"取消弃置\",\n  \"gallery.contextMenu.review.flag.label\": \"弃置\",\n  \"gallery.contextMenu.review.rating.clear\": \"清除评分\",\n  \"gallery.contextMenu.review.rating.label\": \"评分\",\n  \"gallery.details.asset.basicInfo\": \"基本信息\",\n  \"gallery.details.asset.copyFileNameFailed\": \"复制文件名失败\",\n  \"gallery.details.asset.copyFileNameSuccess\": \"文件名已复制\",\n  \"gallery.details.asset.description\": \"描述\",\n  \"gallery.details.asset.descriptionPlaceholder\": \"添加描述\",\n  \"gallery.details.asset.fileName\": \"文件名\",\n  \"gallery.details.asset.fileSize\": \"文件大小\",\n  \"gallery.details.asset.preview\": \"预览\",\n  \"gallery.details.asset.resolution\": \"分辨率\",\n  \"gallery.details.asset.sizeInfo\": \"尺寸信息\",\n  \"gallery.details.asset.storagePath\": \"存储路径\",\n  \"gallery.details.asset.type\": \"类型\",\n  \"gallery.details.asset.updateDescriptionFailed\": \"更新描述失败\",\n  \"gallery.details.assetCount\": \"文件数量\",\n  \"gallery.details.assetType.unknown\": \"未知\",\n  \"gallery.details.batch.currentFocus\": \"当前焦点项\",\n  \"gallery.details.batch.focusUnavailable\": \"当前焦点项不可用（可能尚未加载或已被筛选条件过滤）\",\n  \"gallery.details.batch.helpTitle\": \"批量评分\",\n  \"gallery.details.batch.placeholder\": \"敬请期待...\",\n  \"gallery.details.batch.reviewHint\": \"支持使用数字键 0~5、P、X、U 直接批量更新当前选中项。\",\n  \"gallery.details.batch.selectedCount\": \"已选中 {count} 项\",\n  \"gallery.details.batch.title\": \"批量操作\",\n  \"gallery.details.colors.copyFailed\": \"复制颜色失败\",\n  \"gallery.details.colors.copySuccess\": \"已复制 {hex}\",\n  \"gallery.details.colors.title\": \"主色\",\n  \"gallery.details.empty\": \"选择文件或文件夹查看详情\",\n  \"gallery.details.folderDisplayName\": \"显示名称\",\n  \"gallery.details.folderInfo\": \"文件夹信息\",\n  \"gallery.details.folderName\": \"文件夹名\",\n  \"gallery.details.fullPath\": \"完整路径\",\n  \"gallery.details.histogram.empty\": \"暂无可用直方图\",\n  \"gallery.details.histogram.loading\": \"正在计算直方图...\",\n  \"gallery.details.histogram.title\": \"直方图\",\n  \"gallery.details.histogram.unavailable\": \"无法生成直方图\",\n  \"gallery.details.infinityNikki.apertureValue\": \"光圈\",\n  \"gallery.details.infinityNikki.bloomIntensity\": \"柔光强度\",\n  \"gallery.details.infinityNikki.bloomThreshold\": \"柔光范围\",\n  \"gallery.details.infinityNikki.brightness\": \"亮度\",\n  \"gallery.details.infinityNikki.cameraFocalLength\": \"焦距\",\n  \"gallery.details.infinityNikki.cameraParams\": \"相机参数码\",\n  \"gallery.details.infinityNikki.contrast\": \"对比度\",\n  \"gallery.details.infinityNikki.codeType.dye\": \"染色码\",\n  \"gallery.details.infinityNikki.codeType.homeBuilding\": \"家园码\",\n  \"gallery.details.infinityNikki.copyCameraParams\": \"复制\",\n  \"gallery.details.infinityNikki.copyCameraParamsFailed\": \"复制相机参数码失败\",\n  \"gallery.details.infinityNikki.copyCameraParamsSuccess\": \"相机参数码已复制\",\n  \"gallery.details.infinityNikki.copyCodeValue\": \"复制\",\n  \"gallery.details.infinityNikki.copyCodeValueFailed\": \"复制记录内容失败\",\n  \"gallery.details.infinityNikki.copyCodeValueSuccess\": \"记录内容已复制\",\n  \"gallery.details.infinityNikki.exposure\": \"曝光\",\n  \"gallery.details.infinityNikki.filterId\": \"滤镜\",\n  \"gallery.details.infinityNikki.filterStrength\": \"滤镜强度\",\n  \"gallery.details.infinityNikki.gameTime\": \"游戏内时间\",\n  \"gallery.details.infinityNikki.highlights\": \"高光\",\n  \"gallery.details.infinityNikki.lightId\": \"灯光\",\n  \"gallery.details.infinityNikki.lightStrength\": \"灯光强度\",\n  \"gallery.details.infinityNikki.nikkiHidden\": \"隐藏暖暖\",\n  \"gallery.details.infinityNikki.nikkiLocation\": \"位置\",\n  \"gallery.details.infinityNikki.pasteCodeValue\": \"粘贴\",\n  \"gallery.details.infinityNikki.pasteCodeValueFailed\": \"粘贴记录内容失败\",\n  \"gallery.details.infinityNikki.poseId\": \"动作\",\n  \"gallery.details.infinityNikki.rotation\": \"镜头旋转\",\n  \"gallery.details.infinityNikki.saturation\": \"饱和度\",\n  \"gallery.details.infinityNikki.saveUserRecordFailed\": \"保存玩家记录失败\",\n  \"gallery.details.infinityNikki.shadow\": \"阴影\",\n  \"gallery.details.infinityNikki.title\": \"无限暖暖\",\n  \"gallery.details.infinityNikki.vibrance\": \"自然饱和度\",\n  \"gallery.details.infinityNikki.vignetteIntensity\": \"晕影调节\",\n  \"gallery.details.infinityNikki.vertical\": \"竖构图\",\n  \"gallery.details.itemCount\": \"{count} 项\",\n  \"gallery.details.parentTagId\": \"父标签 ID\",\n  \"gallery.details.review.clearFlag\": \"取消弃置\",\n  \"gallery.details.review.clearRating\": \"清除评分\",\n  \"gallery.details.review.starLabel\": \"星\",\n  \"gallery.details.review.summary\": \"{rating} 星 · {flag}\",\n  \"gallery.details.review.title\": \"评分\",\n  \"gallery.details.rootFolderSummary.assetCount\": \"文件总数\",\n  \"gallery.details.rootFolderSummary.folderCount\": \"根文件夹总数\",\n  \"gallery.details.rootFolderSummary.title\": \"根文件夹概览\",\n  \"gallery.details.rootTagSummary.assetCount\": \"文件总数\",\n  \"gallery.details.rootTagSummary.tagCount\": \"根标签总数\",\n  \"gallery.details.rootTagSummary.title\": \"根标签概览\",\n  \"gallery.details.sortOrder\": \"排序顺序\",\n  \"gallery.details.tagInfo\": \"标签信息\",\n  \"gallery.details.tagName\": \"标签名\",\n  \"gallery.details.tags.add\": \"添加标签\",\n  \"gallery.details.tags.empty\": \"暂无标签\",\n  \"gallery.details.tags.title\": \"标签\",\n  \"gallery.details.title\": \"详情\",\n  \"gallery.guide.infinityNikki.actionFailedTitle\": \"应用配置失败\",\n  \"gallery.guide.infinityNikki.actions.applyRecommended\": \"一键开启推荐配置\",\n  \"gallery.guide.infinityNikki.actions.confirmAndApply\": \"开始应用\",\n  \"gallery.guide.infinityNikki.actions.enable\": \"开启\",\n  \"gallery.guide.infinityNikki.actions.enableMetadataOnly\": \"仅开启元数据解析\",\n  \"gallery.guide.infinityNikki.actions.skip\": \"跳过\",\n  \"gallery.guide.infinityNikki.actions.skipForNow\": \"暂不处理\",\n  \"gallery.guide.infinityNikki.credit\": \"照片数据解析服务由\",\n  \"gallery.guide.infinityNikki.creditLink\": \"NUAN5.PRO\",\n  \"gallery.guide.infinityNikki.creditPowered\": \"强力驱动\",\n  \"gallery.guide.infinityNikki.description\": \"推荐开启以下功能，以获得更完整的图库特性并节省磁盘空间。\",\n  \"gallery.guide.infinityNikki.hardlinksDescription\": \"游戏在拍摄时会保存两份相同照片。该功能会将 ScreenShot 目录内的文件替换为指向大喵相册的硬链接，只保留一份物理文件以节省存储空间。\",\n  \"gallery.guide.infinityNikki.hardlinksDetailsContent\": \"• 本功能会按  NikkiPhotos_HighQuality 在 ScreenShot 中建立硬链接镜像，使两边指向同一份文件。\\n• 新增照片自动同步，日常复制或发送也不受影响。\",\n  \"gallery.guide.infinityNikki.hardlinksDetailsTrigger\": \"查看目录变更细节\",\n  \"gallery.guide.infinityNikki.hardlinksTaskStartedDescription\": \"你可以在右上角的后台任务中查看进度。\",\n  \"gallery.guide.infinityNikki.hardlinksTaskStartedTitle\": \"ScreenShot 硬链接初始化已开始\",\n  \"gallery.guide.infinityNikki.hardlinksTitle\": \"优化 ScreenShot 目录的照片为硬链接\",\n  \"gallery.guide.infinityNikki.header.subtitle\": \"首次启动配置\",\n  \"gallery.guide.infinityNikki.header.title\": \"无限暖暖图库\",\n  \"gallery.guide.infinityNikki.metadataDescription\": \"自动获取并解析照片隐藏的拍摄参数（包含焦距、滤镜、光照及动作等），在图库中展示更丰富的信息。（需联网）\",\n  \"gallery.guide.infinityNikki.metadataTaskStartedDescription\": \"你可以在右上角的后台任务中查看进度。\",\n  \"gallery.guide.infinityNikki.metadataTaskStartedTitle\": \"已开始解析照片元数据\",\n  \"gallery.guide.infinityNikki.metadataTitle\": \"开启照片数据解析\",\n  \"gallery.guide.infinityNikki.recommendedTaskStartedDescription\": \"照片数据解析和 ScreenShot 硬链接初始化已经进入后台任务。\",\n  \"gallery.guide.infinityNikki.recommendedTaskStartedTitle\": \"已开始应用推荐配置\",\n  \"gallery.guide.infinityNikki.saveFailedTitle\": \"保存图库引导状态失败\",\n  \"gallery.guide.infinityNikki.step1.title\": \"照片数据解析\",\n  \"gallery.guide.infinityNikki.step2.title\": \"存储空间优化\",\n  \"gallery.guide.infinityNikki.step3.description\": \"接下来将扫描游戏照片目录并初始化相关任务。你可以在右上角后台任务中查看进度，期间不影响继续使用。\",\n  \"gallery.guide.infinityNikki.step3.progressHint\": \"你可以在右上角后台任务中查看进度，期间不影响继续使用。\",\n  \"gallery.guide.infinityNikki.step3.timeCostNotice\": \"照片数量较多时，首次处理可能耗时较长。\",\n  \"gallery.guide.infinityNikki.step3.title\": \"开始应用配置\",\n  \"gallery.guide.infinityNikki.title\": \"欢迎使用无限暖暖图库\",\n  \"gallery.infinityNikki.extractDialog.cancel\": \"取消\",\n  \"gallery.infinityNikki.extractDialog.confirm\": \"开始解析\",\n  \"gallery.infinityNikki.extractDialog.description\": \"将按你输入的 UID，解析文件夹“{folderName}”及其子文件夹中的照片元数据。\",\n  \"gallery.infinityNikki.extractDialog.failedTitle\": \"启动解析失败\",\n  \"gallery.infinityNikki.extractDialog.invalidUidDescription\": \"请输入纯数字 UID。\",\n  \"gallery.infinityNikki.extractDialog.invalidUidTitle\": \"UID 无效\",\n  \"gallery.infinityNikki.extractDialog.onlyMissingHint\": \"关闭后会重新解析当前文件夹范围内的已有记录。\",\n  \"gallery.infinityNikki.extractDialog.onlyMissingLabel\": \"仅解析缺失项\",\n  \"gallery.infinityNikki.extractDialog.submitting\": \"提交中\",\n  \"gallery.infinityNikki.extractDialog.successDescription\": \"后台任务已创建：{taskId}\",\n  \"gallery.infinityNikki.extractDialog.successTitle\": \"已开始解析元数据\",\n  \"gallery.infinityNikki.extractDialog.title\": \"解析无限暖暖元数据\",\n  \"gallery.infinityNikki.extractDialog.uidHint\": \"该操作适用于当前文件夹下照片都属于同一个 UID 的情况。\",\n  \"gallery.infinityNikki.extractDialog.uidLabel\": \"UID\",\n  \"gallery.infinityNikki.extractDialog.uidPlaceholder\": \"请输入该相册对应的 UID\",\n  \"gallery.lightbox.contextMenu.actual\": \"实际大小\",\n  \"gallery.lightbox.contextMenu.exitImmersive\": \"退出沉浸模式\",\n  \"gallery.lightbox.contextMenu.fit\": \"适合视图\",\n  \"gallery.lightbox.contextMenu.hideFilmstrip\": \"隐藏缩略图\",\n  \"gallery.lightbox.contextMenu.immersive\": \"进入沉浸模式\",\n  \"gallery.lightbox.contextMenu.showFilmstrip\": \"显示缩略图\",\n  \"gallery.lightbox.contextMenu.zoomIn\": \"放大\",\n  \"gallery.lightbox.contextMenu.zoomOut\": \"缩小\",\n  \"gallery.lightbox.image.fitIndicator\": \"适合 ({percent}%)\",\n  \"gallery.lightbox.image.loadFailed\": \"图片加载失败\",\n  \"gallery.lightbox.image.nextTitle\": \"下一张 (→)\",\n  \"gallery.lightbox.image.previousTitle\": \"上一张 (←)\",\n  \"gallery.lightbox.toolbar.actualTitle\": \"100% 实际像素 (Z)\",\n  \"gallery.lightbox.toolbar.backTitle\": \"返回 (ESC)\",\n  \"gallery.lightbox.toolbar.closeTitle\": \"关闭 (ESC)\",\n  \"gallery.lightbox.toolbar.exitImmersiveTitle\": \"退出沉浸模式 (F)\",\n  \"gallery.lightbox.toolbar.filmstripHideTitle\": \"隐藏缩略图 (Tab)\",\n  \"gallery.lightbox.toolbar.filmstripShowTitle\": \"显示缩略图 (Tab)\",\n  \"gallery.lightbox.toolbar.fit\": \"适合\",\n  \"gallery.lightbox.toolbar.fitTitle\": \"适合显示区域 (Z)\",\n  \"gallery.lightbox.toolbar.immersiveTitle\": \"沉浸模式 (F)\",\n  \"gallery.lightbox.toolbar.selected\": \"已选\",\n  \"gallery.lightbox.toolbar.zoomInTitle\": \"放大 (= / +)\",\n  \"gallery.lightbox.toolbar.zoomOutTitle\": \"缩小 (-)\",\n  \"gallery.review.flag.none\": \"未弃置\",\n  \"gallery.review.flag.picked\": \"留用\",\n  \"gallery.review.flag.rejected\": \"弃置\",\n  \"gallery.review.update.failedTitle\": \"更新审片状态失败\",\n  \"gallery.sidebar.common.loading\": \"加载中...\",\n  \"gallery.sidebar.folders.menu.extractInfinityNikkiMetadata\": \"解析无限暖暖元数据\",\n  \"gallery.sidebar.folders.menu.openInExplorer\": \"在资源管理器中打开\",\n  \"gallery.sidebar.folders.menu.rescan\": \"重新分析该文件夹\",\n  \"gallery.sidebar.folders.menu.removeWatch\": \"移出监听并清理索引\",\n  \"gallery.sidebar.folders.menu.renameDisplayName\": \"重命名显示名称\",\n  \"gallery.sidebar.folders.moveAssets.failedDescription\": \"失败 {failed} 项，未找到 {notFound} 项，未变更 {unchanged} 项\",\n  \"gallery.sidebar.folders.moveAssets.failedTitle\": \"移动失败\",\n  \"gallery.sidebar.folders.moveAssets.partialDescription\": \"已移动 {moved} 项，失败 {failed} 项，未找到 {notFound} 项，未变更 {unchanged} 项\",\n  \"gallery.sidebar.folders.moveAssets.partialTitle\": \"部分移动成功\",\n  \"gallery.sidebar.folders.moveAssets.successDescription\": \"共移动 {count} 项\",\n  \"gallery.sidebar.folders.moveAssets.successTitle\": \"移动完成\",\n  \"gallery.sidebar.folders.openInExplorer.failedTitle\": \"打开文件夹失败\",\n  \"gallery.sidebar.folders.removeWatch.cancel\": \"取消\",\n  \"gallery.sidebar.folders.removeWatch.confirm\": \"确认移出\",\n  \"gallery.sidebar.folders.removeWatch.confirmDescription\": \"将停止监听该文件夹，并清理其在图库中的索引记录（包含子文件夹）。不会删除磁盘中的原始文件。\",\n  \"gallery.sidebar.folders.removeWatch.confirmTitle\": \"移出「{name}」？\",\n  \"gallery.sidebar.folders.removeWatch.failedTitle\": \"移出监听失败\",\n  \"gallery.sidebar.folders.removeWatch.successDescription\": \"已停止监听并清理索引，可随时重新添加并扫描。\",\n  \"gallery.sidebar.folders.removeWatch.successTitle\": \"已移出文件夹\",\n  \"gallery.sidebar.folders.rescan.cancel\": \"取消\",\n  \"gallery.sidebar.folders.rescan.confirm\": \"确认重新分析\",\n  \"gallery.sidebar.folders.rescan.confirmDescription\": \"将重新分析该文件夹及其子文件夹中的所有文件元数据，并覆盖重建缩略图。该操作可能耗时较长。\",\n  \"gallery.sidebar.folders.rescan.confirmTitle\": \"重新分析「{name}」？\",\n  \"gallery.sidebar.folders.rescan.failedTitle\": \"启动重新分析失败\",\n  \"gallery.sidebar.folders.rescan.folderNotFoundDescription\": \"未找到目标文件夹，请刷新后重试。\",\n  \"gallery.sidebar.folders.rescan.queuedDescription\": \"后台任务已创建：{taskId}\",\n  \"gallery.sidebar.folders.rescan.queuedTitle\": \"已开始重新分析\",\n  \"gallery.sidebar.folders.rename.failedTitle\": \"更新显示名称失败\",\n  \"gallery.sidebar.folders.rename.placeholder\": \"输入显示名称...\",\n  \"gallery.sidebar.folders.title\": \"文件夹\",\n  \"gallery.sidebar.scan.addRule\": \"添加规则\",\n  \"gallery.sidebar.scan.advancedOptions\": \"高级参数（可选）\",\n  \"gallery.sidebar.scan.cancel\": \"取消\",\n  \"gallery.sidebar.scan.dialogDescription\": \"选择一个目录加入图库，并立即执行一次扫描。\",\n  \"gallery.sidebar.scan.dialogTitle\": \"添加并扫描文件夹\",\n  \"gallery.sidebar.scan.directoryLabel\": \"文件夹路径\",\n  \"gallery.sidebar.scan.directoryPlaceholder\": \"请选择要添加的文件夹\",\n  \"gallery.sidebar.scan.failedTitle\": \"添加文件夹失败\",\n  \"gallery.sidebar.scan.generateThumbnails\": \"生成缩略图\",\n  \"gallery.sidebar.scan.generateThumbnailsHint\": \"关闭后只索引文件，不生成缩略图。\",\n  \"gallery.sidebar.scan.ignoreRules\": \"忽略规则（正则）\",\n  \"gallery.sidebar.scan.loading\": \"正在添加并扫描文件夹...\",\n  \"gallery.sidebar.scan.noRules\": \"暂无忽略规则。你可以添加正则表达式规则来 include 或 exclude 文件。\",\n  \"gallery.sidebar.scan.partialErrorsTitle\": \"扫描完成，但有部分错误\",\n  \"gallery.sidebar.scan.patternType\": \"匹配类型\",\n  \"gallery.sidebar.scan.patternTypeGlob\": \"glob\",\n  \"gallery.sidebar.scan.patternTypeRegex\": \"regex\",\n  \"gallery.sidebar.scan.queuedDescription\": \"任务 {taskId} 已加入后台队列。\",\n  \"gallery.sidebar.scan.queuedTitle\": \"扫描任务已创建\",\n  \"gallery.sidebar.scan.ruleDescription\": \"描述\",\n  \"gallery.sidebar.scan.ruleDescriptionPlaceholder\": \"可选说明\",\n  \"gallery.sidebar.scan.rulePattern\": \"规则表达式\",\n  \"gallery.sidebar.scan.rulePatternPlaceholder\": \"例如：^X6Game\\\\\\\\ScreenShot\\\\\\\\.*\",\n  \"gallery.sidebar.scan.ruleType\": \"规则类型\",\n  \"gallery.sidebar.scan.ruleTypeExclude\": \"exclude\",\n  \"gallery.sidebar.scan.ruleTypeInclude\": \"include\",\n  \"gallery.sidebar.scan.scanning\": \"扫描中...\",\n  \"gallery.sidebar.scan.selectDialogTitle\": \"选择要添加并扫描的文件夹\",\n  \"gallery.sidebar.scan.selectDirectory\": \"选择目录\",\n  \"gallery.sidebar.scan.selectDirectoryFailed\": \"选择文件夹失败\",\n  \"gallery.sidebar.scan.selectDirectoryRequired\": \"请先选择文件夹\",\n  \"gallery.sidebar.scan.selectingDirectory\": \"选择中...\",\n  \"gallery.sidebar.scan.submit\": \"添加并扫描\",\n  \"gallery.sidebar.scan.submitting\": \"提交中...\",\n  \"gallery.sidebar.scan.successDescription\": \"扫描 {totalFiles} 个文件，新增 {newItems}，更新 {updatedItems}\",\n  \"gallery.sidebar.scan.successTitle\": \"文件夹添加并扫描完成\",\n  \"gallery.sidebar.scan.supportedExtensions\": \"支持扩展名\",\n  \"gallery.sidebar.scan.supportedExtensionsHint\": \"支持逗号、分号、空格或换行分隔。\",\n  \"gallery.sidebar.scan.thumbnailShortEdge\": \"缩略图短边像素\",\n  \"gallery.sidebar.tags.addAssets.failedDescription\": \"失败 {failed} 项，未变更 {unchanged} 项\",\n  \"gallery.sidebar.tags.addAssets.failedTitle\": \"添加标签失败\",\n  \"gallery.sidebar.tags.addAssets.partialDescription\": \"已添加标签 {affected} 项，失败 {failed} 项，未变更 {unchanged} 项\",\n  \"gallery.sidebar.tags.addAssets.partialTitle\": \"部分添加标签成功\",\n  \"gallery.sidebar.tags.addAssets.successDescription\": \"共添加标签 {count} 项\",\n  \"gallery.sidebar.tags.addAssets.successTitle\": \"添加标签完成\",\n  \"gallery.sidebar.tags.createPlaceholder\": \"输入标签名...\",\n  \"gallery.sidebar.tags.title\": \"标签\",\n  \"gallery.toolbar.colorFilter.apply\": \"应用\",\n  \"gallery.toolbar.colorFilter.clear\": \"清除\",\n  \"gallery.toolbar.colorFilter.none\": \"未筛选\",\n  \"gallery.toolbar.colorFilter.title\": \"颜色筛选\",\n  \"gallery.toolbar.colorFilter.tooltip\": \"颜色筛选\",\n  \"gallery.toolbar.deleteSelected.button\": \"删除 ({count})\",\n  \"gallery.toolbar.deleteSelected.tooltip\": \"删除选中的 {count} 项文件\",\n  \"gallery.toolbar.filter.flag.all\": \"全部\",\n  \"gallery.toolbar.filter.flag.label\": \"弃置筛选\",\n  \"gallery.toolbar.filter.flag.none\": \"未弃置\",\n  \"gallery.toolbar.filter.flag.picked\": \"留用\",\n  \"gallery.toolbar.filter.flag.rejected\": \"弃置\",\n  \"gallery.toolbar.filter.rating.all\": \"全部评分\",\n  \"gallery.toolbar.filter.rating.five\": \"5 星\",\n  \"gallery.toolbar.filter.rating.four\": \"4 星\",\n  \"gallery.toolbar.filter.rating.label\": \"评分筛选\",\n  \"gallery.toolbar.filter.rating.one\": \"1 星\",\n  \"gallery.toolbar.filter.rating.three\": \"3 星\",\n  \"gallery.toolbar.filter.rating.two\": \"2 星\",\n  \"gallery.toolbar.filter.rating.unrated\": \"未评分\",\n  \"gallery.toolbar.filter.review.tooltip\": \"评分与弃置筛选\",\n  \"gallery.toolbar.filter.type.all\": \"全部类型\",\n  \"gallery.toolbar.filter.type.label\": \"文件类型\",\n  \"gallery.toolbar.filter.type.livePhoto\": \"实况\",\n  \"gallery.toolbar.filter.type.photo\": \"照片\",\n  \"gallery.toolbar.filter.type.video\": \"视频\",\n  \"gallery.toolbar.filterAndSort.tooltip\": \"筛选与排序\",\n  \"gallery.toolbar.folderOptions.includeSubfolders\": \"包含子文件夹\",\n  \"gallery.toolbar.folderOptions.label\": \"文件夹选项\",\n  \"gallery.toolbar.search.placeholder\": \"搜索文件名称...\",\n  \"gallery.toolbar.sort.createdAt\": \"创建日期\",\n  \"gallery.toolbar.sort.label\": \"排序方式\",\n  \"gallery.toolbar.sort.name\": \"名称\",\n  \"gallery.toolbar.sort.resolution\": \"分辨率\",\n  \"gallery.toolbar.sort.size\": \"大小\",\n  \"gallery.toolbar.sortOrder.asc\": \"升序排列\",\n  \"gallery.toolbar.sortOrder.desc\": \"降序排列\",\n  \"gallery.toolbar.thumbnailSize.fine\": \"精致\",\n  \"gallery.toolbar.thumbnailSize.label\": \"缩略图大小\",\n  \"gallery.toolbar.thumbnailSize.showcase\": \"展示\",\n  \"gallery.toolbar.viewMode.adaptive\": \"自适应\",\n  \"gallery.toolbar.viewMode.grid\": \"网格\",\n  \"gallery.toolbar.viewMode.label\": \"视图模式\",\n  \"gallery.toolbar.viewMode.list\": \"列表\",\n  \"gallery.toolbar.viewMode.masonry\": \"瀑布流\",\n  \"gallery.toolbar.viewSettings.tooltip\": \"视图设置\"\n}\n"
  },
  {
    "path": "web/src/core/i18n/locales/zh-CN/home.json",
    "content": "{\n  \"home.outputDir.openButton\": \"打开输出目录\",\n  \"home.outputDir.openFailed\": \"打开输出目录失败\"\n}\n"
  },
  {
    "path": "web/src/core/i18n/locales/zh-CN/map.json",
    "content": "{\n  \"map.popup.fallbackTitle\": \"照片\",\n  \"map.cluster.title\": \"{count} 张照片\"\n}\n"
  },
  {
    "path": "web/src/core/i18n/locales/zh-CN/menu.json",
    "content": "{\n  \"menu.app_exit\": \"退出\",\n  \"menu.app_float\": \"悬浮窗\",\n  \"menu.app_main\": \"主界面\",\n  \"menu.external_album_open_folder\": \"打开游戏相册\",\n  \"menu.letterbox_toggle\": \"黑边模式\",\n  \"menu.motion_photo_toggle\": \"动态照片\",\n  \"menu.output_open_folder\": \"打开输出目录\",\n  \"menu.overlay_toggle\": \"叠加层\",\n  \"menu.preview_toggle\": \"预览窗\",\n  \"menu.recording_toggle\": \"录制\",\n  \"menu.replay_buffer_save\": \"保存回放\",\n  \"menu.replay_buffer_toggle\": \"即时回放\",\n  \"menu.screenshot_capture\": \"截图\",\n  \"menu.window_reset\": \"重置窗口\"\n}\n"
  },
  {
    "path": "web/src/core/i18n/locales/zh-CN/onboarding.json",
    "content": "{\n  \"onboarding.actions.complete\": \"完成\",\n  \"onboarding.actions.completing\": \"保存中...\",\n  \"onboarding.actions.next\": \"下一步\",\n  \"onboarding.actions.previous\": \"上一步\",\n  \"onboarding.common.saveFailed\": \"保存欢迎配置失败，请重试。\",\n  \"onboarding.completed.description\": \"你可以关闭此页面了。\",\n  \"onboarding.completed.title\": \"配置完成！\",\n  \"onboarding.description\": \"首次启动请完成以下配置，后续可在设置中随时修改。\",\n  \"onboarding.step1.description\": \"先选择你偏好的界面语言和主题。\",\n  \"onboarding.step1.themeLabel\": \"界面主题\",\n  \"onboarding.step1.title\": \"步骤 1：语言与主题\",\n  \"onboarding.step2.description\": \"可选填写你要操作的窗口标题；如果现在还没启动游戏，也可以先留空。\",\n  \"onboarding.step2.targetTitleHint\": \"后续可通过悬浮窗右键菜单中的“选择窗口”完成设置。\",\n  \"onboarding.step2.targetTitlePlaceholder\": \"输入窗口标题\",\n  \"onboarding.step2.title\": \"步骤 2：目标窗口标题\",\n  \"onboarding.title\": \"欢迎使用 旋转吧大喵\"\n}\n"
  },
  {
    "path": "web/src/core/i18n/locales/zh-CN/settings.json",
    "content": "{\n  \"settings.appearance.background.backgroundBlurAmount.description\": \"调整全局背景图层的模糊程度。\",\n  \"settings.appearance.background.backgroundBlurAmount.label\": \"背景模糊度\",\n  \"settings.appearance.background.backgroundOpacity.description\": \"调整全局背景图层的不透明度。\",\n  \"settings.appearance.background.backgroundOpacity.label\": \"背景不透明度\",\n  \"settings.appearance.background.description\": \"自定义应用背景样式。\",\n  \"settings.appearance.background.image.autoThemeDescription\": \"根据背景自动应用主题色和叠加色。\",\n  \"settings.appearance.background.image.label\": \"背景图片\",\n  \"settings.appearance.background.image.removeButton\": \"移除图片\",\n  \"settings.appearance.background.image.selectButton\": \"选择图片\",\n  \"settings.appearance.background.overlayOpacity.description\": \"调整背景渐变叠加色的不透明度。\",\n  \"settings.appearance.background.overlayOpacity.label\": \"叠加色不透明度\",\n  \"settings.appearance.background.overlayPalette.description\": \"支持单色到四色的对称渐变排布，并可快速套用预设。\",\n  \"settings.appearance.background.overlayPalette.label\": \"叠加渐变调色板\",\n  \"settings.appearance.background.overlayPalette.mode.double\": \"双色\",\n  \"settings.appearance.background.overlayPalette.mode.quad\": \"四色\",\n  \"settings.appearance.background.overlayPalette.mode.single\": \"单色\",\n  \"settings.appearance.background.overlayPalette.mode.title\": \"颜色数量\",\n  \"settings.appearance.background.overlayPalette.mode.triple\": \"三色\",\n  \"settings.appearance.background.overlayPalette.presets.title\": \"预设方案\",\n  \"settings.appearance.background.overlayPalette.sampleFromWallpaper\": \"从壁纸取色\",\n  \"settings.appearance.background.surfaceOpacity.description\": \"仅调整页面面板层的不透明度，不影响背景图层。\",\n  \"settings.appearance.background.surfaceOpacity.label\": \"面板不透明度\",\n  \"settings.appearance.background.title\": \"背景设置\",\n  \"settings.appearance.quickSetup.title\": \"快速设置\",\n  \"settings.appearance.description\": \"自定义应用的主题和布局。\",\n  \"settings.appearance.error.retry\": \"重试\",\n  \"settings.appearance.error.title\": \"加载外观设置失败\",\n  \"settings.appearance.floatingWindowTheme.dark\": \"深色\",\n  \"settings.appearance.floatingWindowTheme.description\": \"浮窗（UI 边框）的主题模式。\",\n  \"settings.appearance.floatingWindowTheme.label\": \"浮窗主题\",\n  \"settings.appearance.floatingWindowTheme.light\": \"浅色\",\n  \"settings.appearance.layout.baseFontSize.description\": \"基础字体大小。\",\n  \"settings.appearance.layout.baseFontSize.label\": \"字体大小\",\n  \"settings.appearance.layout.baseIndicatorWidth.description\": \"基础指示器宽度。\",\n  \"settings.appearance.layout.baseIndicatorWidth.label\": \"指示器宽度\",\n  \"settings.appearance.layout.baseItemHeight.description\": \"基础项高度。\",\n  \"settings.appearance.layout.baseItemHeight.label\": \"项高度\",\n  \"settings.appearance.layout.baseRatioColumnWidth.description\": \"基础比例列宽度。\",\n  \"settings.appearance.layout.baseRatioColumnWidth.label\": \"比例列宽度\",\n  \"settings.appearance.layout.baseRatioIndicatorWidth.description\": \"基础比例指示器宽度。\",\n  \"settings.appearance.layout.baseRatioIndicatorWidth.label\": \"比例指示器宽度\",\n  \"settings.appearance.layout.baseResolutionColumnWidth.description\": \"基础分辨率列宽度。\",\n  \"settings.appearance.layout.baseResolutionColumnWidth.label\": \"分辨率列宽度\",\n  \"settings.appearance.layout.baseSeparatorHeight.description\": \"基础分隔符高度。\",\n  \"settings.appearance.layout.baseSeparatorHeight.label\": \"分隔符高度\",\n  \"settings.appearance.layout.baseSettingsColumnWidth.description\": \"基础设置列宽度。\",\n  \"settings.appearance.layout.baseSettingsColumnWidth.label\": \"设置列宽度\",\n  \"settings.appearance.layout.baseTextPadding.description\": \"基础文字内边距。\",\n  \"settings.appearance.layout.baseTextPadding.label\": \"文字内边距\",\n  \"settings.appearance.layout.baseTitleHeight.description\": \"基础标题高度。\",\n  \"settings.appearance.layout.baseTitleHeight.label\": \"标题高度\",\n  \"settings.appearance.layout.description\": \"微调窗口布局尺寸（高级）。\",\n  \"settings.appearance.layout.maxVisibleRows.description\": \"翻页模式下每列同时显示的行数，下限为 1。\",\n  \"settings.appearance.layout.maxVisibleRows.label\": \"每列可见行数\",\n  \"settings.appearance.layout.rowsUnit\": \"行\",\n  \"settings.appearance.layout.title\": \"布局参数\",\n  \"settings.appearance.layout.unit\": \"px\",\n  \"settings.appearance.loading\": \"加载外观设置中...\",\n  \"settings.appearance.reset.description\": \"确定要重置所有外观设置吗？\",\n  \"settings.appearance.reset.title\": \"重置外观设置\",\n  \"settings.appearance.theme.customCss.description\": \"可在此编写 CSS，微调字体、颜色等界面样式。\",\n  \"settings.appearance.theme.customCss.label\": \"自定义 CSS\",\n  \"settings.appearance.theme.customCss.placeholder\": \"例如：:root { --app-font-cjk: 'Your Font', 'Microsoft YaHei', sans-serif; }\",\n  \"settings.appearance.theme.dark\": \"深色\",\n  \"settings.appearance.theme.description\": \"Web 界面高级自定义选项。\",\n  \"settings.appearance.theme.light\": \"浅色\",\n  \"settings.appearance.theme.mode.description\": \"控制文字和界面元素的明暗配色，不影响背景叠加色。\",\n  \"settings.appearance.theme.mode.label\": \"UI 配色模式\",\n  \"settings.appearance.theme.primaryColor.description\": \"用于按钮、选中态和焦点高亮颜色。\",\n  \"settings.appearance.theme.primaryColor.label\": \"主题色\",\n  \"settings.appearance.theme.system\": \"跟随系统\",\n  \"settings.appearance.theme.title\": \"界面设置\",\n  \"settings.appearance.title\": \"外观设置\",\n  \"settings.appearance.webview.transparentBackground.description\": \"启用后可显示透明背景效果；关闭后将使用随主题切换的不透明底色以提升性能。\",\n  \"settings.appearance.webview.transparentBackground.label\": \"WebView 透明背景\",\n  \"settings.capture.description\": \"配置截图、录制、动态照片和回放的输出参数。\",\n  \"settings.capture.error.retry\": \"重试\",\n  \"settings.capture.error.title\": \"加载捕获与导出设置失败\",\n  \"settings.capture.loading\": \"加载捕获与导出设置中...\",\n  \"settings.capture.reset.description\": \"确定要重置所有捕获与导出设置吗？\",\n  \"settings.capture.reset.title\": \"重置捕获与导出设置\",\n  \"settings.capture.title\": \"捕获与导出\",\n  \"settings.error.retry\": \"重试\",\n  \"settings.error.title\": \"加载设置失败\",\n  \"settings.extensions.description\": \"配置内置游戏拓展与相关行为。\",\n  \"settings.extensions.error.retry\": \"重试\",\n  \"settings.extensions.error.title\": \"加载拓展设置失败\",\n  \"settings.extensions.infinityNikki.description\": \"配置无限暖暖图库监听、元数据提取与 ScreenShot 硬链接行为。\",\n  \"settings.extensions.infinityNikki.enable.description\": \"启用后可在图库中使用无限暖暖相关的导入和自动化能力。\",\n  \"settings.extensions.infinityNikki.enable.label\": \"启用无限暖暖拓展\",\n  \"settings.extensions.infinityNikki.gameDir.detectButton\": \"自动检测\",\n  \"settings.extensions.infinityNikki.gameDir.detectFailedDescription\": \"请确认已安装游戏，或手动选择目录。\",\n  \"settings.extensions.infinityNikki.gameDir.detectFailedTitle\": \"未能检测到 InfinityNikki 游戏目录\",\n  \"settings.extensions.infinityNikki.gameDir.detecting\": \"检测中...\",\n  \"settings.extensions.infinityNikki.gameDir.detectSuccessTitle\": \"已检测到 InfinityNikki 游戏目录\",\n  \"settings.extensions.infinityNikki.gameDir.dialogTitle\": \"选择 InfinityNikki 游戏目录\",\n  \"settings.extensions.infinityNikki.gameDir.empty\": \"未配置 InfinityNikki 游戏目录\",\n  \"settings.extensions.infinityNikki.gameDir.invalidDescription\": \"请选择包含 InfinityNikki.exe 的游戏根目录。\",\n  \"settings.extensions.infinityNikki.gameDir.invalidTitle\": \"目录无效\",\n  \"settings.extensions.infinityNikki.gameDir.label\": \"游戏目录\",\n  \"settings.extensions.infinityNikki.gameDir.selectButton\": \"选择目录\",\n  \"settings.extensions.infinityNikki.gameDir.selectFailedTitle\": \"选择 InfinityNikki 游戏目录失败\",\n  \"settings.extensions.infinityNikki.gameDir.selecting\": \"选择中...\",\n  \"settings.extensions.infinityNikki.gameDir.selectSuccessTitle\": \"已更新 InfinityNikki 游戏目录\",\n  \"settings.extensions.infinityNikki.hardlinks.description\": \"将 ScreenShot 目录中的重复照片替换为指向大喵相册的硬链接，以节省磁盘空间。\",\n  \"settings.extensions.infinityNikki.hardlinks.label\": \"管理 ScreenShot 硬链接\",\n  \"settings.extensions.infinityNikki.initialization.completeButton\": \"完成初始化\",\n  \"settings.extensions.infinityNikki.initialization.completed\": \"已完成\",\n  \"settings.extensions.infinityNikki.initialization.completedDescription\": \"无限暖暖图库监听已经初始化完成，后续会按当前设置自动处理新照片。\",\n  \"settings.extensions.infinityNikki.initialization.completeFailedTitle\": \"初始化无限暖暖图库失败\",\n  \"settings.extensions.infinityNikki.initialization.completeSuccessDescription\": \"你可以在右上角的后台任务中查看初始化进度。\",\n  \"settings.extensions.infinityNikki.initialization.completeSuccessTitle\": \"已开始初始化无限暖暖图库\",\n  \"settings.extensions.infinityNikki.initialization.completing\": \"初始化中...\",\n  \"settings.extensions.infinityNikki.initialization.label\": \"图库初始化\",\n  \"settings.extensions.infinityNikki.initialization.notReady\": \"未就绪\",\n  \"settings.extensions.infinityNikki.initialization.notReadyDescription\": \"请先启用拓展并配置有效的游戏目录，然后再完成初始化。\",\n  \"settings.extensions.infinityNikki.initialization.pending\": \"待完成\",\n  \"settings.extensions.infinityNikki.initialization.pendingDescription\": \"完成初始化后会注册图库监听，并根据当前开关启动初始扫描或后台任务。\",\n  \"settings.extensions.infinityNikki.initialization.requirementsDescription\": \"请先启用无限暖暖拓展并配置有效的游戏目录。\",\n  \"settings.extensions.infinityNikki.initialization.requirementsTitle\": \"无法完成初始化\",\n  \"settings.extensions.infinityNikki.metadata.description\": \"自动获取并解析照片隐藏的拍摄参数，在图库中展示更丰富的信息。\",\n  \"settings.extensions.infinityNikki.metadata.label\": \"自动提取照片元数据\",\n  \"settings.extensions.infinityNikki.saveFailedTitle\": \"保存无限暖暖设置失败\",\n  \"settings.extensions.infinityNikki.title\": \"无限暖暖\",\n  \"settings.extensions.loading\": \"加载拓展设置中...\",\n  \"settings.extensions.reset.description\": \"确定要重置所有拓展设置吗？\",\n  \"settings.extensions.reset.title\": \"重置拓展设置\",\n  \"settings.extensions.title\": \"拓展设置\",\n  \"settings.floatingWindow.description\": \"配置悬浮窗功能项、预设、主题与布局参数。\",\n  \"settings.floatingWindow.error.retry\": \"重试\",\n  \"settings.floatingWindow.error.title\": \"加载悬浮窗设置失败\",\n  \"settings.floatingWindow.loading\": \"加载悬浮窗设置中...\",\n  \"settings.floatingWindow.reset.description\": \"确定要重置所有悬浮窗设置吗？\",\n  \"settings.floatingWindow.reset.title\": \"重置悬浮窗设置\",\n  \"settings.floatingWindow.theme.description\": \"调整悬浮窗的主题模式。\",\n  \"settings.floatingWindow.theme.title\": \"悬浮窗主题\",\n  \"settings.floatingWindow.title\": \"悬浮窗设置\",\n  \"settings.function.description\": \"配置应用的核心功能行为。\",\n  \"settings.function.error.title\": \"加载功能设置失败\",\n  \"settings.function.loading\": \"加载功能设置中...\",\n  \"settings.function.motionPhoto.audioBitrate.description\": \"动态照片视频的音频码率（kbps）。\",\n  \"settings.function.motionPhoto.audioBitrate.label\": \"音频码率\",\n  \"settings.function.motionPhoto.audioSource.description\": \"动态照片视频的音频来源。\",\n  \"settings.function.motionPhoto.audioSource.label\": \"音频源\",\n  \"settings.function.motionPhoto.bitrate.description\": \"CBR 模式下的固定码率（Mbps）。\",\n  \"settings.function.motionPhoto.bitrate.label\": \"比特率\",\n  \"settings.function.motionPhoto.codec.description\": \"动态照片视频的编码格式。\",\n  \"settings.function.motionPhoto.codec.label\": \"编码格式\",\n  \"settings.function.motionPhoto.description\": \"配置动态照片功能参数。\",\n  \"settings.function.motionPhoto.duration.description\": \"动态照片中视频的时长（秒）。\",\n  \"settings.function.motionPhoto.duration.label\": \"视频时长\",\n  \"settings.function.motionPhoto.enabled.description\": \"截图时自动生成包含视频的动态照片。\",\n  \"settings.function.motionPhoto.enabled.label\": \"启用动态照片\",\n  \"settings.function.motionPhoto.fps.description\": \"动态照片视频的帧率（FPS）。\",\n  \"settings.function.motionPhoto.fps.label\": \"帧率\",\n  \"settings.function.motionPhoto.quality.description\": \"VBR 模式下的质量等级（0-100，值越高质量越好）。\",\n  \"settings.function.motionPhoto.quality.label\": \"质量\",\n  \"settings.function.motionPhoto.rateControl.description\": \"选择固定码率或质量优先模式。\",\n  \"settings.function.motionPhoto.rateControl.label\": \"码率控制模式\",\n  \"settings.function.motionPhoto.resolution.description\": \"动态照片视频的短边分辨率。0 表示使用捕获原始分辨率，不缩放。\",\n  \"settings.function.motionPhoto.resolution.label\": \"视频分辨率\",\n  \"settings.function.motionPhoto.resolution.original\": \"原始\",\n  \"settings.function.motionPhoto.title\": \"动态照片\",\n  \"settings.function.outputDir.default\": \"使用用户视频文件夹 (Videos/SpinningMomo)\",\n  \"settings.function.outputDir.description\": \"配置截图和录制文件的保存位置。\",\n  \"settings.function.outputDir.dialogTitle\": \"选择输出目录\",\n  \"settings.function.outputDir.driveRootNotAllowedDescription\": \"请选择一个独立的文件夹（例如 D:\\\\Videos\\\\SpinningMomo），不要只选择盘符根目录。\",\n  \"settings.function.outputDir.driveRootNotAllowedTitle\": \"无法使用磁盘根目录\",\n  \"settings.function.outputDir.label\": \"保存目录\",\n  \"settings.function.outputDir.selectButton\": \"选择\",\n  \"settings.function.outputDir.selecting\": \"选择中...\",\n  \"settings.function.outputDir.title\": \"输出目录\",\n  \"settings.function.recording.audioBitrate.description\": \"音频编码的码率（kbps）。\",\n  \"settings.function.recording.audioBitrate.label\": \"音频码率\",\n  \"settings.function.recording.audioSource.description\": \"选择录制的音频来源。\",\n  \"settings.function.recording.audioSource.gameOnly\": \"仅游戏音频\",\n  \"settings.function.recording.audioSource.gameOnlyFallbackHint\": \"当前系统不支持“仅游戏音频”，选择后会自动回退为系统音频。\",\n  \"settings.function.recording.audioSource.label\": \"音频源\",\n  \"settings.function.recording.audioSource.none\": \"无音频\",\n  \"settings.function.recording.audioSource.system\": \"系统音频\",\n  \"settings.function.recording.autoRestartOnResize.description\": \"录制过程中检测到窗口尺寸变化时，自动结束当前片段并按新尺寸继续录制。\",\n  \"settings.function.recording.autoRestartOnResize.label\": \"尺寸变化自动切段\",\n  \"settings.function.recording.bitrate.description\": \"CBR 模式下的固定码率（Mbps）。\",\n  \"settings.function.recording.bitrate.label\": \"比特率\",\n  \"settings.function.recording.captureClientArea.description\": \"仅捕获目标窗口客户区，不包含标题栏和窗口边框。\",\n  \"settings.function.recording.captureClientArea.label\": \"无边框捕获\",\n  \"settings.function.recording.captureCursor.description\": \"录制时是否包含鼠标指针。\",\n  \"settings.function.recording.captureCursor.label\": \"显示鼠标指针\",\n  \"settings.function.recording.captureCursor.unsupportedHint\": \"当前系统不支持完整的鼠标捕获控制，此开关可能无法完全生效。\",\n  \"settings.function.recording.codec.description\": \"选择视频编码格式。\",\n  \"settings.function.recording.codec.label\": \"编码格式\",\n  \"settings.function.recording.description\": \"配置屏幕录制功能参数。\",\n  \"settings.function.recording.encoderMode.auto\": \"自动（优先 GPU）\",\n  \"settings.function.recording.encoderMode.cpu\": \"CPU 软件编码\",\n  \"settings.function.recording.encoderMode.description\": \"选择视频编码器类型。\",\n  \"settings.function.recording.encoderMode.gpu\": \"GPU 硬件编码\",\n  \"settings.function.recording.encoderMode.label\": \"编码器模式\",\n  \"settings.function.recording.fps.description\": \"录制的帧率（FPS）。\",\n  \"settings.function.recording.fps.label\": \"帧率\",\n  \"settings.function.recording.qp.description\": \"手动 QP 模式下的目标 QP 值（0-51，值越小质量越好，需硬件支持）。\",\n  \"settings.function.recording.qp.label\": \"量化参数 (QP)\",\n  \"settings.function.recording.quality.description\": \"VBR 模式下的质量等级（0-100，值越高质量越好）。\",\n  \"settings.function.recording.quality.label\": \"质量\",\n  \"settings.function.recording.rateControl.cbr\": \"CBR （固定码率）\",\n  \"settings.function.recording.rateControl.description\": \"选择固定码率、质量优先或手动 QP 模式。\",\n  \"settings.function.recording.rateControl.label\": \"码率控制模式\",\n  \"settings.function.recording.rateControl.manualQp\": \"手动 QP （高级模式）\",\n  \"settings.function.recording.rateControl.vbr\": \"VBR （质量优先）\",\n  \"settings.function.recording.title\": \"录制设置\",\n  \"settings.function.replayBuffer.description\": \"配置即时回放功能参数。录制参数继承自录制设置。\",\n  \"settings.function.replayBuffer.duration.description\": \"即时回放保存的最大时长（秒）。\",\n  \"settings.function.replayBuffer.duration.label\": \"回放时长\",\n  \"settings.function.replayBuffer.title\": \"即时回放\",\n  \"settings.function.reset.description\": \"确定要重置所有功能设置吗？\",\n  \"settings.function.reset.title\": \"重置功能设置\",\n  \"settings.function.screenshot.description\": \"设置游戏相册目录路径，用于快速打开游戏截图文件夹。\",\n  \"settings.function.screenshot.gameAlbum.description\": \"自动检测游戏截图目录\",\n  \"settings.function.screenshot.gameAlbum.dialogTitle\": \"选择游戏相册目录\",\n  \"settings.function.screenshot.gameAlbum.label\": \"游戏相册目录\",\n  \"settings.function.screenshot.gameAlbum.selectButton\": \"选择\",\n  \"settings.function.screenshot.gameAlbum.selecting\": \"选择中...\",\n  \"settings.function.screenshot.title\": \"游戏相册\",\n  \"settings.function.title\": \"功能设置\",\n  \"settings.function.windowControl.centerLockCursor.description\": \"当目标窗口已锁定鼠标时，将鼠标进一步限制在窗口中心的小区域，减少误触悬浮窗或其他置顶窗口。\",\n  \"settings.function.windowControl.centerLockCursor.label\": \"锁鼠时强制居中\",\n  \"settings.function.windowControl.description\": \"配置目标窗口和任务栏行为。\",\n  \"settings.function.windowControl.layeredCaptureWorkaround.description\": \"当目标窗口尺寸超出屏幕时，临时启用 layered 窗口兼容方案，以改善预览和截图只更新可见区域的问题。\",\n  \"settings.function.windowControl.layeredCaptureWorkaround.label\": \"超屏捕获稳定性兼容\",\n  \"settings.function.windowControl.resetResolution.height.description\": \"固定分辨率模式下的窗口高度。\",\n  \"settings.function.windowControl.resetResolution.height.label\": \"高度\",\n  \"settings.function.windowControl.resetResolution.mode.custom\": \"固定分辨率\",\n  \"settings.function.windowControl.resetResolution.mode.description\": \"设置“重置窗口”命令使用屏幕尺寸还是固定分辨率。\",\n  \"settings.function.windowControl.resetResolution.mode.label\": \"重置窗口尺寸\",\n  \"settings.function.windowControl.resetResolution.mode.screen\": \"跟随屏幕\",\n  \"settings.function.windowControl.resetResolution.width.description\": \"固定分辨率模式下的窗口宽度。\",\n  \"settings.function.windowControl.resetResolution.width.label\": \"宽度\",\n  \"settings.function.windowControl.title\": \"窗口控制\",\n  \"settings.function.windowControl.windowTitle.description\": \"应用将调整的目标窗口名称。\",\n  \"settings.function.windowControl.windowTitle.label\": \"目标窗口标题\",\n  \"settings.function.windowControl.windowTitle.placeholder\": \"输入窗口标题...\",\n  \"settings.function.windowControl.windowTitle.update\": \"更新\",\n  \"settings.general.description\": \"管理应用的通用配置，包括语言、日志和更新。\",\n  \"settings.general.hotkey.description\": \"自定义应用的全局快捷键。\",\n  \"settings.general.hotkey.floatingWindow\": \"显示/隐藏\",\n  \"settings.general.hotkey.floatingWindowDescription\": \"切换悬浮窗的显示和隐藏。\",\n  \"settings.general.hotkey.recorder.notSet\": \"未设置\",\n  \"settings.general.hotkey.recorder.pressKey\": \"请按键...\",\n  \"settings.general.hotkey.recording\": \"录制\",\n  \"settings.general.hotkey.recordingDescription\": \"触发开始/停止录制的快捷键。\",\n  \"settings.general.hotkey.screenshot\": \"截图\",\n  \"settings.general.hotkey.screenshotDescription\": \"触发截图功能的快捷键。\",\n  \"settings.general.hotkey.title\": \"快捷键\",\n  \"settings.general.language.description\": \"选择应用的显示语言。\",\n  \"settings.general.language.displayLanguage\": \"显示语言\",\n  \"settings.general.language.displayLanguageDescription\": \"应用程序的主要语言。\",\n  \"settings.general.language.title\": \"语言\",\n  \"settings.general.logger.description\": \"配置应用日志级别，用于故障排除。\",\n  \"settings.general.logger.level\": \"日志级别\",\n  \"settings.general.logger.levelDescription\": \"选择记录的日志详细程度。\",\n  \"settings.general.logger.title\": \"日志\",\n  \"settings.general.title\": \"通用设置\",\n  \"settings.general.update.autoCheck.description\": \"应用启动后在后台检查新版本，不阻塞启动。\",\n  \"settings.general.update.autoCheck.label\": \"启动时自动检查更新\",\n  \"settings.general.update.autoUpdateOnExit.description\": \"检测到新版本后会先后台下载，并在你下次退出程序时完成更新。\",\n  \"settings.general.update.autoUpdateOnExit.label\": \"发现更新后，在退出时自动安装\",\n  \"settings.general.update.autoUpdateOnExit.requiresAutoCheck\": \"需要先开启“启动时自动检查更新”。\",\n  \"settings.general.update.descriptionLink\": \"关于\",\n  \"settings.general.update.descriptionPrefix\": \"管理自动更新行为；手动检查更新仍可在“\",\n  \"settings.general.update.descriptionSuffix\": \"”页面进行。\",\n  \"settings.general.update.title\": \"更新\",\n  \"settings.hotkeys.description\": \"配置全局快捷键以快速触发常用操作。\",\n  \"settings.hotkeys.error.retry\": \"重试\",\n  \"settings.hotkeys.error.title\": \"加载快捷键设置失败\",\n  \"settings.hotkeys.loading\": \"加载快捷键设置中...\",\n  \"settings.hotkeys.reset.description\": \"确定要重置所有快捷键设置吗？\",\n  \"settings.hotkeys.reset.title\": \"重置快捷键设置\",\n  \"settings.hotkeys.title\": \"快捷键设置\",\n  \"settings.layout.capture.description\": \"截图与录制参数配置\",\n  \"settings.layout.capture.title\": \"捕获与导出\",\n  \"settings.layout.extensions.description\": \"无限暖暖等游戏拓展设置\",\n  \"settings.layout.extensions.title\": \"拓展\",\n  \"settings.layout.floatingWindow.description\": \"悬浮窗菜单与布局配置\",\n  \"settings.layout.floatingWindow.title\": \"悬浮窗\",\n  \"settings.layout.general.description\": \"通用设置\",\n  \"settings.layout.general.title\": \"通用\",\n  \"settings.layout.hotkeys.description\": \"全局快捷键配置\",\n  \"settings.layout.hotkeys.title\": \"快捷键\",\n  \"settings.layout.webAppearance.description\": \"Web 界面主题与背景\",\n  \"settings.layout.webAppearance.title\": \"主界面外观\",\n  \"settings.layout.windowScene.description\": \"窗口行为与画面构图设置\",\n  \"settings.layout.windowScene.title\": \"窗口与画面\",\n  \"settings.loading\": \"加载设置中...\",\n  \"settings.menu.actions.add\": \"添加\",\n  \"settings.menu.actions.addCustomItem\": \"添加自定义项\",\n  \"settings.menu.actions.cancel\": \"取消\",\n  \"settings.menu.aspectRatio.description\": \"管理可选的窗口比例预设。\",\n  \"settings.menu.aspectRatio.placeholder\": \"例如 16:9\",\n  \"settings.menu.aspectRatio.title\": \"比例预设\",\n  \"settings.menu.description\": \"自定义应用菜单项和预设值。\",\n  \"settings.menu.error.retry\": \"重试\",\n  \"settings.menu.error.title\": \"加载菜单设置失败\",\n  \"settings.menu.feature.description\": \"管理菜单中显示的功能按钮。\",\n  \"settings.menu.feature.title\": \"功能项\",\n  \"settings.menu.loading\": \"加载菜单设置中...\",\n  \"settings.menu.reset.description\": \"确定要重置所有菜单设置吗？\",\n  \"settings.menu.reset.title\": \"重置菜单设置\",\n  \"settings.menu.resolution.description\": \"管理可选的窗口分辨率预设。\",\n  \"settings.menu.resolution.placeholder\": \"例如 1920x1080\",\n  \"settings.menu.resolution.title\": \"分辨率预设\",\n  \"settings.menu.status.hidden\": \"隐藏\",\n  \"settings.menu.status.noFeatureItems\": \"没有功能项\",\n  \"settings.menu.status.noPresetItems\": \"没有预设项\",\n  \"settings.menu.status.visible\": \"显示\",\n  \"settings.menu.title\": \"菜单设置\",\n  \"settings.reset.description\": \"确定要重置此页面的设置吗？此操作无法撤销。\",\n  \"settings.reset.dialog.cancelText\": \"取消\",\n  \"settings.reset.dialog.confirmText\": \"确认重置\",\n  \"settings.reset.dialog.resetting\": \"重置中...\",\n  \"settings.reset.dialog.triggerText\": \"重置\",\n  \"settings.reset.success\": \"设置已重置\",\n  \"settings.reset.title\": \"重置设置\",\n  \"settings.webAppearance.description\": \"自定义 Web 界面主题和背景效果。\",\n  \"settings.webAppearance.error.retry\": \"重试\",\n  \"settings.webAppearance.error.title\": \"加载主界面外观设置失败\",\n  \"settings.webAppearance.loading\": \"加载主界面外观设置中...\",\n  \"settings.webAppearance.reset.description\": \"确定要重置 Web 主题和背景设置吗？\",\n  \"settings.webAppearance.reset.title\": \"重置主界面外观\",\n  \"settings.webAppearance.title\": \"主界面外观\",\n  \"settings.windowScene.description\": \"配置目标窗口行为和画面构图相关选项。\",\n  \"settings.windowScene.error.retry\": \"重试\",\n  \"settings.windowScene.error.title\": \"加载窗口与画面设置失败\",\n  \"settings.windowScene.loading\": \"加载窗口与画面设置中...\",\n  \"settings.windowScene.reset.description\": \"确定要重置所有窗口与画面设置吗？\",\n  \"settings.windowScene.reset.title\": \"重置窗口与画面设置\",\n  \"settings.windowScene.title\": \"窗口与画面设置\"\n}\n"
  },
  {
    "path": "web/src/core/i18n/types.ts",
    "content": "import type { Ref } from 'vue'\n\nexport type Locale = 'zh-CN' | 'en-US'\n\nexport type Messages = Record<string, string>\n\nexport interface I18nInstance {\n  locale: Ref<Locale>\n  t: (key: string, params?: Record<string, any>) => string\n  setLocale: (newLocale: Locale) => Promise<void>\n}\n"
  },
  {
    "path": "web/src/core/rpc/core.ts",
    "content": "import type { TransportMethods, TransportType } from './transport/types'\nimport { createWebViewTransport } from './transport/webview'\nimport { createHttpTransport } from './transport/http'\nimport { getCurrentEnvironment } from '@/core/env'\n\n// 模块级状态\nlet currentTransport: TransportMethods | null = null\nlet currentTransportType: TransportType | null = null\nconst isDebugMode = import.meta.env.DEV\n\n/**\n * 创建传输方法集合\n */\nfunction createTransportMethods(type: TransportType): TransportMethods {\n  switch (type) {\n    case 'webview':\n      return createWebViewTransport()\n    case 'http':\n      return createHttpTransport()\n    default:\n      throw new Error(`Unsupported transport type: ${type}`)\n  }\n}\n\n/**\n * 确保传输已初始化\n */\nfunction ensureTransportInitialized(): TransportMethods {\n  if (!currentTransport) {\n    const transportType = getCurrentEnvironment() === 'webview' ? 'webview' : 'http'\n    currentTransportType = transportType\n    currentTransport = createTransportMethods(transportType)\n\n    if (isDebugMode) {\n      console.log(`[RPC] Using ${transportType} transport`)\n    }\n  }\n  return currentTransport\n}\n\n/**\n * 获取当前传输类型\n */\nexport function getCurrentTransportType(): TransportType | null {\n  return currentTransportType\n}\n\n/**\n * 获取传输方法\n */\nexport function getTransportMethods(): TransportMethods {\n  return ensureTransportInitialized()\n}\n\n/**\n * 重置传输（用于测试）\n */\nexport function resetTransport(): void {\n  if (currentTransport) {\n    currentTransport.dispose()\n  }\n  currentTransport = null\n  currentTransportType = null\n}\n"
  },
  {
    "path": "web/src/core/rpc/index.ts",
    "content": "import { getTransportMethods, getCurrentTransportType, resetTransport } from './core'\nimport type { TransportStats, TransportType } from './types'\n\n// 导出类型\nexport type {\n  JsonRpcRequest,\n  JsonRpcResponse,\n  JsonRpcNotification,\n  JsonRpcErrorCode,\n  TransportType,\n  TransportStats,\n} from './types'\nexport { JsonRpcError } from './types'\n\n/**\n * 调用远程方法（请求-响应模式）\n */\nexport async function call<T = unknown>(\n  method: string,\n  params?: unknown,\n  timeout = 10000\n): Promise<T> {\n  const transport = getTransportMethods()\n  return transport.call<T>(method, params, timeout)\n}\n\n/**\n * 监听事件通知\n */\nexport function on(method: string, handler: (params: unknown) => void): void {\n  const transport = getTransportMethods()\n  transport.on(method, handler)\n}\n\n/**\n * 取消事件监听\n */\nexport function off(method: string, handler: (params: unknown) => void): void {\n  const transport = getTransportMethods()\n  transport.off(method, handler)\n}\n\n/**\n * 获取统计信息\n */\nexport function getStats(): TransportStats {\n  const transport = getTransportMethods()\n  return transport.getStats()\n}\n\n/**\n * 清理资源，在应用卸载时调用\n */\nexport function dispose(): void {\n  const transport = getTransportMethods()\n  transport.dispose()\n}\n\n/**\n * 初始化 RPC 通信，应在应用启动时调用一次\n */\nexport function initializeRPC(): void {\n  const transport = getTransportMethods()\n  // 异步初始化，不阻塞主线程\n  transport.initialize().catch((error) => {\n    console.error('Failed to initialize RPC transport:', error)\n  })\n}\n\n/**\n * 获取当前传输类型\n */\nexport function getTransportType(): TransportType | null {\n  return getCurrentTransportType()\n}\n\n/**\n * 检查是否连接可用\n */\nexport function isConnected(): boolean {\n  try {\n    const stats = getStats()\n    return stats.isConnected\n  } catch {\n    return false\n  }\n}\n\n// 内部使用的重置函数（测试用）\nexport const __internal = {\n  resetTransport,\n}\n\n// --- 类型辅助工具 ---\nexport type RpcMethod<TParams = unknown, TResult = unknown> = {\n  params: TParams\n  result: TResult\n}\n\nexport type RpcEventHandler<T = unknown> = (params: T) => void\n"
  },
  {
    "path": "web/src/core/rpc/transport/http.ts",
    "content": "import type { TransportMethods } from './types'\nimport {\n  JsonRpcError,\n  JsonRpcErrorCode,\n  type JsonRpcRequest,\n  type JsonRpcNotification,\n  type TransportStats,\n} from '../types'\n\n/**\n * 创建 HTTP 传输方法集合\n */\nexport function createHttpTransport(): TransportMethods {\n  const eventHandlers = new Map<string, Set<(params: unknown) => void>>()\n  let nextId = 1\n  const isDebugMode = import.meta.env.DEV\n  let eventSource: EventSource | null = null\n  let isInitialized = false\n\n  function isHttpAvailable(): boolean {\n    return typeof window !== 'undefined' && typeof fetch !== 'undefined'\n  }\n\n  function ensureEventSourceConnected(): void {\n    if (!eventSource || eventSource.readyState === EventSource.CLOSED) {\n      connectEventSource()\n    }\n  }\n\n  function connectEventSource(): void {\n    try {\n      eventSource = new EventSource('/sse')\n\n      eventSource.onmessage = (event) => {\n        try {\n          const message = JSON.parse(event.data)\n          if (isValidJsonRpcNotification(message)) {\n            handleNotification(message)\n          }\n        } catch (error) {\n          console.error('Failed to parse SSE message:', error)\n        }\n      }\n\n      eventSource.onerror = (error) => {\n        console.error('SSE connection error:', error)\n        // 自动重连逻辑\n        setTimeout(() => {\n          if (eventSource?.readyState === EventSource.CLOSED) {\n            connectEventSource()\n          }\n        }, 5000)\n      }\n\n      eventSource.onopen = () => {\n        if (isDebugMode) console.log('[HTTP RPC]', 'SSE connected')\n      }\n    } catch (error) {\n      console.error('Failed to create EventSource:', error)\n    }\n  }\n\n  function isValidJsonRpcNotification(message: unknown): boolean {\n    if (typeof message !== 'object' || !message) return false\n\n    const msg = message as Record<string, unknown>\n\n    return (\n      msg.jsonrpc === '2.0' && 'method' in msg && typeof msg.method === 'string' && !('id' in msg)\n    )\n  }\n\n  function handleNotification(notification: JsonRpcNotification): void {\n    const handlers = eventHandlers.get(notification.method)\n    if (handlers && handlers.size > 0) {\n      handlers.forEach((handler) => {\n        try {\n          handler(notification.params)\n        } catch (error) {\n          console.error(`Error in event handler for ${notification.method}:`, error)\n        }\n      })\n      if (isDebugMode)\n        console.log('[HTTP RPC]', 'Event received:', notification.method, notification.params)\n    } else {\n      if (isDebugMode) console.log('[HTTP RPC]', 'No handlers for event:', notification.method)\n    }\n  }\n\n  // 返回 TransportMethods 接口实现\n  return {\n    call: async <T>(method: string, params?: unknown, timeout = 10000): Promise<T> => {\n      return new Promise((resolve, reject) => {\n        if (!isHttpAvailable()) {\n          reject(new JsonRpcError(JsonRpcErrorCode.WEBVIEW_NOT_AVAILABLE, 'HTTP not available'))\n          return\n        }\n\n        const id = nextId++\n        const request: JsonRpcRequest = {\n          jsonrpc: '2.0',\n          method,\n          params,\n          id,\n        }\n\n        // 设置超时 (timeout=0 表示永不超时)\n        const abortController = new AbortController()\n        let timeoutHandle: ReturnType<typeof setTimeout> | null = null\n\n        if (timeout > 0) {\n          timeoutHandle = setTimeout(() => {\n            abortController.abort()\n            reject(\n              new JsonRpcError(JsonRpcErrorCode.TIMEOUT, `Request timeout: ${method}`, {\n                method,\n                timeout,\n              })\n            )\n          }, timeout)\n        }\n\n        // 发送 HTTP 请求\n        fetch('/rpc', {\n          method: 'POST',\n          headers: {\n            'Content-Type': 'application/json',\n          },\n          body: JSON.stringify(request),\n          signal: abortController.signal,\n        })\n          .then((response) => {\n            if (timeoutHandle) clearTimeout(timeoutHandle)\n\n            if (!response.ok) {\n              throw new JsonRpcError(\n                JsonRpcErrorCode.INTERNAL_ERROR,\n                `HTTP ${response.status}: ${response.statusText}`\n              )\n            }\n\n            return response.json()\n          })\n          .then((responseJson) => {\n            if (responseJson.error) {\n              reject(\n                new JsonRpcError(\n                  responseJson.error.code as JsonRpcErrorCode,\n                  responseJson.error.message,\n                  responseJson.error.data\n                )\n              )\n            } else {\n              resolve(responseJson.result as T)\n            }\n\n            if (isDebugMode) console.log('[HTTP RPC]', 'RPC response:', method, responseJson)\n          })\n          .catch((error) => {\n            if (timeoutHandle) clearTimeout(timeoutHandle)\n\n            if (error.name === 'AbortError') {\n              // 超时已经处理过了，不需要再次 reject\n              return\n            }\n\n            reject(\n              new JsonRpcError(\n                JsonRpcErrorCode.WEBVIEW_NOT_AVAILABLE,\n                `Network error: ${error.message}`,\n                { method, originalError: error }\n              )\n            )\n          })\n\n        if (isDebugMode) console.log('[HTTP RPC]', 'RPC call:', method, params)\n      })\n    },\n\n    on: (method: string, handler: (params: unknown) => void): void => {\n      if (!eventHandlers.has(method)) {\n        eventHandlers.set(method, new Set())\n      }\n      eventHandlers.get(method)!.add(handler)\n\n      // 确保 SSE 连接已建立\n      ensureEventSourceConnected()\n\n      if (isDebugMode) console.log('[HTTP RPC]', 'Event listener added:', method)\n    },\n\n    off: (method: string, handler: (params: unknown) => void): void => {\n      const handlers = eventHandlers.get(method)\n      if (handlers) {\n        handlers.delete(handler)\n        if (handlers.size === 0) {\n          eventHandlers.delete(method)\n        }\n      }\n      if (isDebugMode) console.log('[HTTP RPC]', 'Event listener removed:', method)\n    },\n\n    initialize: async (): Promise<void> => {\n      if (isInitialized) {\n        if (isDebugMode) console.log('[HTTP RPC]', 'RPC already initialized.')\n        return\n      }\n\n      if (!isHttpAvailable()) {\n        throw new JsonRpcError(JsonRpcErrorCode.WEBVIEW_NOT_AVAILABLE, 'HTTP not available')\n      }\n\n      isInitialized = true\n      if (isDebugMode) console.log('[HTTP RPC]', 'HTTP RPC initialized')\n\n      // 页面卸载时清理资源\n      if (typeof window !== 'undefined') {\n        window.addEventListener('beforeunload', () => {\n          // Clean up directly since we're in a closure\n          eventHandlers.clear()\n\n          // 关闭 SSE 连接\n          if (eventSource) {\n            eventSource.close()\n            eventSource = null\n          }\n        })\n      }\n    },\n\n    dispose: (): void => {\n      // 清理事件处理器\n      eventHandlers.clear()\n\n      // 关闭 SSE 连接\n      if (eventSource) {\n        eventSource.close()\n        eventSource = null\n      }\n\n      isInitialized = false\n      if (isDebugMode) console.log('[HTTP RPC]', 'HTTP RPC disposed')\n    },\n\n    getStats: (): TransportStats => {\n      return {\n        pendingRequests: 0,\n        eventHandlers: Array.from(eventHandlers.entries()).map(([method, handlers]) => ({\n          method,\n          handlerCount: handlers.size,\n        })),\n        isConnected: eventSource?.readyState === EventSource.OPEN,\n        transportType: 'http',\n      }\n    },\n  }\n}\n"
  },
  {
    "path": "web/src/core/rpc/transport/types.ts",
    "content": "import type { TransportStats } from '../types'\n\nexport type { TransportType } from '../types'\n\n/**\n * 传输层统一接口\n * 所有传输实现（WebView, HTTP）必须实现此接口\n */\nexport interface TransportMethods {\n  /**\n   * 调用远程方法（请求-响应模式）\n   */\n  call: <T>(method: string, params?: unknown, timeout?: number) => Promise<T>\n\n  /**\n   * 监听事件通知\n   */\n  on: (method: string, handler: (params: unknown) => void) => void\n\n  /**\n   * 取消事件监听\n   */\n  off: (method: string, handler: (params: unknown) => void) => void\n\n  /**\n   * 初始化传输层\n   */\n  initialize: () => Promise<void>\n\n  /**\n   * 清理资源\n   */\n  dispose: () => void\n\n  /**\n   * 获取统计信息\n   */\n  getStats: () => TransportStats\n}\n"
  },
  {
    "path": "web/src/core/rpc/transport/webview.ts",
    "content": "import type { TransportMethods } from './types'\nimport {\n  JsonRpcError,\n  JsonRpcErrorCode,\n  type PendingRequest,\n  type JsonRpcRequest,\n  type JsonRpcResponse,\n  type JsonRpcNotification,\n  type TransportStats,\n} from '../types'\n\n/**\n * 创建 WebView 传输方法集合\n */\nexport function createWebViewTransport(): TransportMethods {\n  const pendingRequests = new Map<string | number, PendingRequest>()\n  const eventHandlers = new Map<string, Set<(params: unknown) => void>>()\n  let nextId = 1\n  const isDebugMode = import.meta.env.DEV\n  let isInitialized = false\n\n  function isWebViewAvailable(): boolean {\n    return typeof window !== 'undefined' && !!window.chrome?.webview\n  }\n\n  function postMessage(message: JsonRpcRequest | JsonRpcNotification): void {\n    if (isWebViewAvailable() && window.chrome?.webview) {\n      window.chrome.webview.postMessage(message)\n    } else if (isDebugMode) {\n      console.log('[WebView RPC]', 'Mock message (WebView2 not available):', message)\n    } else {\n      throw new JsonRpcError(JsonRpcErrorCode.WEBVIEW_NOT_AVAILABLE, 'WebView2 not available')\n    }\n  }\n\n  function handleResponse(response: JsonRpcResponse): void {\n    const pendingRequest = pendingRequests.get(response.id)\n    if (!pendingRequest) return\n\n    const { resolve, reject, timeout } = pendingRequest\n    if (timeout) clearTimeout(timeout)\n    pendingRequests.delete(response.id)\n\n    if (response.error) {\n      const error = new JsonRpcError(\n        response.error.code as JsonRpcErrorCode,\n        response.error.message,\n        response.error.data\n      )\n      reject(error)\n      if (isDebugMode) console.log('[WebView RPC]', 'RPC error:', response.error)\n    } else {\n      resolve(response.result)\n      if (isDebugMode) console.log('[WebView RPC]', 'RPC response:', response.id, response.result)\n    }\n  }\n\n  function handleNotification(notification: JsonRpcNotification): void {\n    const handlers = eventHandlers.get(notification.method)\n    if (handlers && handlers.size > 0) {\n      handlers.forEach((handler) => {\n        try {\n          handler(notification.params)\n        } catch (error) {\n          console.error(`Error in event handler for ${notification.method}:`, error)\n        }\n      })\n      if (isDebugMode)\n        console.log('[WebView RPC]', 'Event received:', notification.method, notification.params)\n    } else {\n      if (isDebugMode) console.log('[WebView RPC]', 'No handlers for event:', notification.method)\n    }\n  }\n\n  function isValidJsonRpcMessage(message: unknown): boolean {\n    if (typeof message !== 'object' || !message) return false\n\n    const msg = message as Record<string, unknown>\n\n    if (msg.jsonrpc !== '2.0') return false\n\n    if ('method' in msg && typeof msg.method === 'string') return true\n\n    if ('id' in msg && ('result' in msg || 'error' in msg)) return true\n\n    return false\n  }\n\n  function handleMessage(event: MessageEvent): void {\n    try {\n      const message = event.data\n\n      if (!isValidJsonRpcMessage(message)) {\n        if (isDebugMode) console.log('[WebView RPC]', 'Invalid JSON-RPC message:', message)\n        return\n      }\n\n      if ('id' in message && pendingRequests.has(message.id)) {\n        handleResponse(message as JsonRpcResponse)\n      } else if ('method' in message && !('id' in message)) {\n        handleNotification(message as JsonRpcNotification)\n      }\n    } catch (error) {\n      console.error('Failed to handle WebView message:', error)\n    }\n  }\n\n  // 返回 TransportMethods 接口实现\n  return {\n    call: async <T>(method: string, params?: unknown, timeout = 10000): Promise<T> => {\n      return new Promise((resolve, reject) => {\n        if (!isWebViewAvailable()) {\n          reject(new JsonRpcError(JsonRpcErrorCode.WEBVIEW_NOT_AVAILABLE, 'WebView2 not available'))\n          return\n        }\n\n        const id = nextId++\n        const request: JsonRpcRequest = {\n          jsonrpc: '2.0',\n          method,\n          params,\n          id,\n        }\n\n        // 设置超时 (timeout=0 表示永不超时)\n        let timeoutHandle: ReturnType<typeof setTimeout> | null = null\n\n        if (timeout > 0) {\n          timeoutHandle = setTimeout(() => {\n            pendingRequests.delete(id)\n            reject(\n              new JsonRpcError(JsonRpcErrorCode.TIMEOUT, `Request timeout: ${method}`, {\n                method,\n                timeout,\n              })\n            )\n          }, timeout)\n        }\n\n        pendingRequests.set(id, {\n          resolve: (value: unknown) => resolve(value as T),\n          reject,\n          timeout: timeoutHandle,\n        })\n\n        try {\n          postMessage(request)\n          if (isDebugMode) console.log('[WebView RPC]', 'RPC call:', method, params)\n        } catch (error) {\n          if (timeoutHandle) clearTimeout(timeoutHandle)\n          pendingRequests.delete(id)\n          reject(error)\n        }\n      })\n    },\n\n    on: (method: string, handler: (params: unknown) => void): void => {\n      if (!eventHandlers.has(method)) {\n        eventHandlers.set(method, new Set())\n      }\n      eventHandlers.get(method)!.add(handler)\n      if (isDebugMode) console.log('[WebView RPC]', 'Event listener added:', method)\n    },\n\n    off: (method: string, handler: (params: unknown) => void): void => {\n      const handlers = eventHandlers.get(method)\n      if (handlers) {\n        handlers.delete(handler)\n        if (handlers.size === 0) {\n          eventHandlers.delete(method)\n        }\n      }\n      if (isDebugMode) console.log('[WebView RPC]', 'Event listener removed:', method)\n    },\n\n    initialize: async (): Promise<void> => {\n      if (isInitialized) {\n        if (isDebugMode) console.log('[WebView RPC]', 'RPC already initialized.')\n        return\n      }\n\n      if (isWebViewAvailable() && window.chrome?.webview) {\n        window.chrome.webview.addEventListener('message', handleMessage)\n        isInitialized = true\n        if (isDebugMode) console.log('[WebView RPC]', 'WebView RPC initialized')\n      } else if (isDebugMode) {\n        console.log('[WebView RPC]', 'WebView2 not available, running in mock mode')\n      }\n\n      // 确保在页面卸载时清理资源\n      if (typeof window !== 'undefined') {\n        window.addEventListener('beforeunload', () => {\n          for (const [, request] of pendingRequests) {\n            if (request.timeout) clearTimeout(request.timeout)\n            request.reject(\n              new JsonRpcError(JsonRpcErrorCode.INTERNAL_ERROR, 'WebView RPC disposed')\n            )\n          }\n          pendingRequests.clear()\n          eventHandlers.clear()\n        })\n      }\n    },\n\n    dispose: (): void => {\n      for (const [, request] of pendingRequests) {\n        if (request.timeout) clearTimeout(request.timeout)\n        request.reject(new JsonRpcError(JsonRpcErrorCode.INTERNAL_ERROR, 'WebView RPC disposed'))\n      }\n      pendingRequests.clear()\n      eventHandlers.clear()\n\n      if (isWebViewAvailable() && window.chrome?.webview) {\n        window.chrome.webview.removeEventListener('message', handleMessage)\n      }\n\n      isInitialized = false\n      if (isDebugMode) console.log('[WebView RPC]', 'WebView RPC disposed')\n    },\n\n    getStats: (): TransportStats => {\n      return {\n        pendingRequests: pendingRequests.size,\n        eventHandlers: Array.from(eventHandlers.entries()).map(([method, handlers]) => ({\n          method,\n          handlerCount: handlers.size,\n        })),\n        isConnected: isWebViewAvailable(),\n        transportType: 'webview',\n      }\n    },\n  }\n}\n"
  },
  {
    "path": "web/src/core/rpc/types.ts",
    "content": "// JSON-RPC 2.0 协议类型定义\n\nexport interface JsonRpcRequest {\n  jsonrpc: '2.0'\n  method: string\n  params?: unknown\n  id: string | number\n}\n\nexport interface JsonRpcResponse {\n  jsonrpc: '2.0'\n  result?: unknown\n  error?: {\n    code: number\n    message: string\n    data?: unknown\n  }\n  id: string | number\n}\n\nexport interface JsonRpcNotification {\n  jsonrpc: '2.0'\n  method: string\n  params?: unknown\n}\n\n// 内部类型\nexport interface PendingRequest {\n  resolve: (value: unknown) => void\n  reject: (error: Error) => void\n  timeout: ReturnType<typeof setTimeout> | null\n}\n\nexport interface TransportStats {\n  pendingRequests: number\n  eventHandlers: Array<{ method: string; handlerCount: number }>\n  isConnected: boolean\n  transportType: TransportType\n}\n\nexport type TransportType = 'webview' | 'http'\n\n// JSON-RPC 错误码\nexport const JsonRpcErrorCode = {\n  PARSE_ERROR: -32700,\n  INVALID_REQUEST: -32600,\n  METHOD_NOT_FOUND: -32601,\n  INVALID_PARAMS: -32602,\n  INTERNAL_ERROR: -32603,\n  TIMEOUT: -32001,\n  WEBVIEW_NOT_AVAILABLE: -32002,\n} as const\n\nexport type JsonRpcErrorCode = (typeof JsonRpcErrorCode)[keyof typeof JsonRpcErrorCode]\n\n// JSON-RPC 错误类\nexport class JsonRpcError extends Error {\n  code: JsonRpcErrorCode\n  data?: unknown\n\n  constructor(code: JsonRpcErrorCode, message: string, data?: unknown) {\n    super(message)\n    this.name = 'JsonRpcError'\n    this.code = code\n    this.data = data\n  }\n}\n"
  },
  {
    "path": "web/src/core/tasks/store.ts",
    "content": "import { computed, ref } from 'vue'\nimport { defineStore } from 'pinia'\nimport { call, on, off } from '@/core/rpc'\nimport type { TaskSnapshot } from './types'\n\ninterface ClearFinishedTasksResult {\n  clearedCount: number\n}\n\nfunction isTaskActiveStatus(status: string): boolean {\n  return status === 'queued' || status === 'running'\n}\n\nfunction isTaskSnapshot(value: unknown): value is TaskSnapshot {\n  if (typeof value !== 'object' || value === null) {\n    return false\n  }\n\n  const candidate = value as Record<string, unknown>\n  return (\n    typeof candidate.taskId === 'string' &&\n    typeof candidate.type === 'string' &&\n    typeof candidate.status === 'string' &&\n    typeof candidate.createdAt === 'number'\n  )\n}\n\nfunction sortTasks(tasks: TaskSnapshot[]): TaskSnapshot[] {\n  return [...tasks].sort((a, b) => b.createdAt - a.createdAt)\n}\n\nexport const useTaskStore = defineStore('core-task-store', () => {\n  const tasks = ref<TaskSnapshot[]>([])\n  const isInitialized = ref(false)\n  const error = ref<string | null>(null)\n\n  let taskUpdatedHandler: ((params: unknown) => void) | null = null\n\n  function upsertTask(snapshot: TaskSnapshot): void {\n    const index = tasks.value.findIndex((item) => item.taskId === snapshot.taskId)\n    if (index >= 0) {\n      tasks.value[index] = snapshot\n    } else {\n      tasks.value.push(snapshot)\n    }\n\n    tasks.value = sortTasks(tasks.value).slice(0, 50)\n  }\n\n  async function initialize(): Promise<void> {\n    if (isInitialized.value) {\n      return\n    }\n\n    try {\n      const remoteTasks = await call<TaskSnapshot[]>('task.list', {})\n      tasks.value = Array.isArray(remoteTasks)\n        ? sortTasks(remoteTasks.filter((item) => isTaskSnapshot(item))).slice(0, 50)\n        : []\n      error.value = null\n    } catch (e) {\n      error.value = e instanceof Error ? e.message : String(e)\n      tasks.value = []\n    }\n\n    taskUpdatedHandler = (params: unknown) => {\n      if (!isTaskSnapshot(params)) {\n        return\n      }\n      upsertTask(params)\n    }\n\n    on('task.updated', taskUpdatedHandler)\n    isInitialized.value = true\n  }\n\n  function dispose(): void {\n    if (taskUpdatedHandler) {\n      off('task.updated', taskUpdatedHandler)\n      taskUpdatedHandler = null\n    }\n    isInitialized.value = false\n  }\n\n  const activeTasks = computed(() => tasks.value.filter((item) => isTaskActiveStatus(item.status)))\n\n  async function clearFinished(): Promise<number> {\n    const result = await call<ClearFinishedTasksResult>('task.clearFinished', {})\n    tasks.value = tasks.value.filter((item) => isTaskActiveStatus(item.status))\n    return result.clearedCount\n  }\n\n  return {\n    tasks,\n    activeTasks,\n    isInitialized,\n    error,\n    initialize,\n    clearFinished,\n    dispose,\n  }\n})\n"
  },
  {
    "path": "web/src/core/tasks/types.ts",
    "content": "export type TaskStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled'\n\nexport interface TaskProgress {\n  stage: string\n  current: number\n  total: number\n  percent?: number\n  message?: string\n}\n\nexport interface TaskSnapshot {\n  taskId: string\n  type: string\n  status: TaskStatus\n  createdAt: number\n  startedAt?: number\n  finishedAt?: number\n  progress?: TaskProgress\n  errorMessage?: string\n  context?: string\n}\n"
  },
  {
    "path": "web/src/extensions/infinity_nikki/index.ts",
    "content": "import type { FolderTreeNode, ScanAssetsParams, ScanIgnoreRule } from '@/features/gallery/types'\nimport { call } from '@/core/rpc'\nimport { useI18n } from '@/core/i18n'\n\ninterface InfinityNikkiAlbumIgnoreRuleTemplate extends Omit<ScanIgnoreRule, 'description'> {\n  descriptionKey: string\n}\n\nexport const INFINITY_NIKKI_LAST_UID_STORAGE_KEY = 'spinningmomo.infinityNikki.extract.lastUid'\n\nconst INFINITY_NIKKI_ALBUM_IGNORE_RULES: InfinityNikkiAlbumIgnoreRuleTemplate[] = [\n  {\n    pattern: '^.*$',\n    patternType: 'regex',\n    ruleType: 'exclude',\n    descriptionKey: 'extensions.infinityNikki.scanRules.excludeAll',\n  },\n  {\n    pattern: '^[0-9]+/NikkiPhotos_HighQuality(/.*)?$',\n    patternType: 'regex',\n    ruleType: 'include',\n    descriptionKey: 'extensions.infinityNikki.scanRules.includeHighQualityPhotos',\n  },\n]\n\nfunction resolveInfinityNikkiAlbumDirectory(gameDir: string): string {\n  const normalizedGameDir = gameDir.trim().replace(/[\\\\/]+$/, '')\n  return `${normalizedGameDir}/X6Game/Saved/GamePlayPhotos`\n}\n\ninterface StartTaskResult {\n  taskId: string\n}\n\ninterface StartExtractPhotoParamsForFolderParams {\n  folderId: number\n  uid: string\n  onlyMissing?: boolean\n}\n\n/**\n * 生成 InfinityNikki 游戏相册扫描参数\n * @param gameDir InfinityNikki 游戏根目录\n */\nexport function createInfinityNikkiAlbumScanParams(gameDir: string): ScanAssetsParams {\n  const { t } = useI18n()\n\n  return {\n    directory: resolveInfinityNikkiAlbumDirectory(gameDir),\n    generateThumbnails: true,\n    thumbnailShortEdge: 480,\n    ignoreRules: INFINITY_NIKKI_ALBUM_IGNORE_RULES.map((rule) => ({\n      pattern: rule.pattern,\n      patternType: rule.patternType,\n      ruleType: rule.ruleType,\n      description: t(rule.descriptionKey),\n    })),\n  }\n}\n\nexport async function startExtractInfinityNikkiPhotoParams(onlyMissing = true): Promise<string> {\n  const result = await call<StartTaskResult>('extensions.infinityNikki.startExtractPhotoParams', {\n    onlyMissing,\n  })\n  return result.taskId\n}\n\nexport async function startExtractInfinityNikkiPhotoParamsForFolder(\n  params: StartExtractPhotoParamsForFolderParams\n): Promise<string> {\n  const result = await call<StartTaskResult>(\n    'extensions.infinityNikki.startExtractPhotoParamsForFolder',\n    params\n  )\n  return result.taskId\n}\n\nexport async function startInitializeInfinityNikkiScreenshotHardlinks(): Promise<string> {\n  const result = await call<StartTaskResult>(\n    'extensions.infinityNikki.startInitializeScreenshotHardlinks',\n    {}\n  )\n  return result.taskId\n}\n\n/**\n * 从 Infinity Nikki 标准相册路径中提取 UID。\n * 例如：.../GamePlayPhotos/123456/NikkiPhotos_HighQuality\n */\nexport function extractInfinityNikkiUidFromFolderPath(path: string): string | null {\n  const normalizedPath = path.replace(/\\\\/g, '/')\n  const match = normalizedPath.match(\n    /(?:^|\\/)GamePlayPhotos\\/(\\d+)\\/NikkiPhotos_HighQuality(?:\\/|$)/\n  )\n  return match?.[1] ?? null\n}\n\n/**\n * Infinity Nikki 游戏照片管理拓展\n *\n * 将深层文件夹结构简化为两层：\n * - 第一层：GamePlayPhotos（默认显示为大喵相册 / Momo's Album；若库中有 display_name 则优先）\n * - 第二层：各账号的 NikkiPhotos_HighQuality（默认显示为 UID；若库中有 display_name 则优先）\n */\n\nfunction pickFolderDisplayName(stored: string | undefined, fallback: string): string {\n  const trimmed = stored?.trim()\n  return trimmed ? trimmed : fallback\n}\n\n/**\n * 转换 InfinityNikki 文件夹树结构\n * @param tree 原始文件夹树\n * @returns 转换后的文件夹树\n */\nexport function transformInfinityNikkiTree(tree: FolderTreeNode[]): FolderTreeNode[] {\n  const { t } = useI18n()\n\n  // 在顶层查找 GamePlayPhotos 节点\n  const gamePlayPhotosIndex = tree.findIndex((node) => node.name === 'GamePlayPhotos')\n\n  // 如果没有找到 GamePlayPhotos 节点，返回原始树\n  if (gamePlayPhotosIndex === -1) {\n    return tree\n  }\n\n  const gamePlayPhotosNode = tree[gamePlayPhotosIndex]!\n\n  const secondLevelNodes: FolderTreeNode[] = []\n\n  if (gamePlayPhotosNode.children) {\n    for (const uidNode of gamePlayPhotosNode.children) {\n      if (!/^\\d+$/.test(uidNode.name)) {\n        continue\n      }\n\n      const nikkiPhotosNode = uidNode.children?.find(\n        (node) => node.name === 'NikkiPhotos_HighQuality'\n      )\n\n      if (nikkiPhotosNode) {\n        secondLevelNodes.push({\n          ...nikkiPhotosNode,\n          displayName: pickFolderDisplayName(nikkiPhotosNode.displayName, uidNode.name),\n          children: [],\n        })\n      }\n    }\n  }\n\n  const newGamePlayPhotosNode: FolderTreeNode = {\n    ...gamePlayPhotosNode,\n    displayName: pickFolderDisplayName(\n      gamePlayPhotosNode.displayName,\n      t('extensions.infinityNikki.album.rootDisplayName')\n    ),\n    children: secondLevelNodes,\n  }\n\n  const newTree = [...tree]\n  newTree[gamePlayPhotosIndex] = newGamePlayPhotosNode\n\n  return newTree\n}\n"
  },
  {
    "path": "web/src/features/about/pages/AboutPage.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed, onBeforeUnmount, onMounted, ref } from 'vue'\nimport { call } from '@/core/rpc'\nimport { useTaskStore } from '@/core/tasks/store'\nimport { getCurrentEnvironment } from '@/core/env'\nimport { useI18n } from '@/composables/useI18n'\nimport { useToast } from '@/composables/useToast'\nimport { copyToClipboard } from '@/lib/utils'\nimport { Button } from '@/components/ui/button'\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from '@/components/ui/dialog'\nimport { ScrollArea } from '@/components/ui/scroll-area'\nimport {\n  Info,\n  Monitor,\n  Check,\n  Bug,\n  Heart,\n  FolderOpen,\n  ExternalLink,\n  Globe,\n  Loader2,\n  Package,\n  Download,\n  ChevronRight,\n} from 'lucide-vue-next'\n\ninterface RuntimeInfo {\n  version: string\n  osName: string\n  osMajorVersion: number\n  osMinorVersion: number\n  osBuildNumber: number\n  isWebview2Available: boolean\n  webview2Version: string\n  isCaptureSupported: boolean\n  isProcessLoopbackAudioSupported: boolean\n}\n\ninterface CheckUpdateResult {\n  hasUpdate: boolean\n  latestVersion: string\n  currentVersion: string\n}\n\ninterface StartDownloadUpdateResult {\n  taskId: string\n  status: 'started' | 'already_running'\n}\n\ninterface OpenAppDataDirectoryResult {\n  success: boolean\n  message: string\n}\n\ninterface OpenLogDirectoryResult {\n  success: boolean\n  message: string\n}\n\nconst { t, locale } = useI18n()\nconst { toast } = useToast()\nconst taskStore = useTaskStore()\n\nconst scrollAreaRef = ref<InstanceType<typeof ScrollArea>>()\nconst runtimeInfo = ref<RuntimeInfo | null>(null)\nconst currentVersionFromUpdate = ref<string | null>(null)\nconst isLoading = ref(false)\nconst error = ref<string | null>(null)\nconst isCheckingUpdate = ref(false)\nconst isStartingDownload = ref(false)\nconst isInstallingUpdate = ref(false)\nconst isOpeningAppDataDirectory = ref(false)\nconst isOpeningLogDirectory = ref(false)\nconst hasUpdate = ref<boolean | null>(null)\nconst latestVersion = ref<string | null>(null)\nconst updateError = ref<string | null>(null)\nconst updateChecked = ref(false)\nconst issuesDialogOpen = ref(false)\nconst copied = ref(false)\nconst environment = getCurrentEnvironment()\n\nlet copiedTimer: ReturnType<typeof setTimeout> | null = null\nlet updateCheckedTimer: ReturnType<typeof setTimeout> | null = null\n\nconst issuesUrl = 'https://github.com/ChanIok/SpinningMomo/issues'\nconst licenseUrl = 'https://github.com/ChanIok/SpinningMomo/blob/main/LICENSE'\nconst legalNoticeZhUrl = 'https://spin.infinitymomo.com/zh/about/legal'\nconst legalNoticeEnUrl = 'https://spin.infinitymomo.com/en/about/legal'\nconst creditsZhUrl = 'https://spin.infinitymomo.com/zh/about/credits'\nconst creditsEnUrl = 'https://spin.infinitymomo.com/en/about/credits'\nconst nuan5Url = 'https://NUAN5.PRO'\n\nconst legalNoticeUrl = computed(() =>\n  locale.value === 'en-US' ? legalNoticeEnUrl : legalNoticeZhUrl\n)\n\nconst creditsUrl = computed(() => (locale.value === 'en-US' ? creditsEnUrl : creditsZhUrl))\n\nconst appVersionText = computed(\n  () => runtimeInfo.value?.version || currentVersionFromUpdate.value || '-'\n)\n\nconst currentUpdateTask = computed(() => {\n  return taskStore.tasks.find((task) => task.type === 'update.download') ?? null\n})\n\nconst isDownloadingUpdate = computed(() => {\n  const status = currentUpdateTask.value?.status\n  return status === 'queued' || status === 'running'\n})\n\nconst isDownloadedUpdateReady = computed(\n  () => currentUpdateTask.value?.status === 'succeeded' && hasUpdate.value !== false\n)\n\nconst environmentText = computed(() => {\n  return environment === 'webview'\n    ? t('about.runtime.environmentWebview')\n    : t('about.runtime.environmentWeb')\n})\n\nconst osText = computed(() => {\n  if (!runtimeInfo.value) {\n    return '-'\n  }\n  return `${runtimeInfo.value.osName} ${runtimeInfo.value.osMajorVersion}.${runtimeInfo.value.osMinorVersion}.${runtimeInfo.value.osBuildNumber}`\n})\n\nconst webview2Text = computed(() => {\n  if (!runtimeInfo.value) {\n    return '-'\n  }\n  if (!runtimeInfo.value.isWebview2Available) {\n    return t('about.runtime.unavailable')\n  }\n  return runtimeInfo.value.webview2Version || t('about.runtime.available')\n})\n\nconst diagnosticsText = computed(() => {\n  return [\n    t('about.diagnostics.title'),\n    `${t('about.runtime.version')}: ${appVersionText.value}`,\n    `${t('about.runtime.environment')}: ${environmentText.value}`,\n    `${t('about.runtime.os')}: ${osText.value}`,\n    `${t('about.runtime.webview2')}: ${webview2Text.value}`,\n    `${t('about.runtime.capture')}: ${formatCapability(runtimeInfo.value?.isCaptureSupported)}`,\n    `${t('about.runtime.loopback')}: ${formatCapability(runtimeInfo.value?.isProcessLoopbackAudioSupported)}`,\n  ].join('\\n')\n})\n\nconst toErrorMessage = (value: unknown): string => {\n  if (value instanceof Error) {\n    return value.message\n  }\n  return String(value)\n}\n\nconst formatCapability = (value: boolean | undefined): string => {\n  if (value === true) {\n    return t('about.runtime.supported')\n  }\n  if (value === false) {\n    return t('about.runtime.unsupported')\n  }\n  return '-'\n}\n\nconst markCopied = () => {\n  copied.value = true\n  if (copiedTimer) {\n    clearTimeout(copiedTimer)\n  }\n  copiedTimer = setTimeout(() => {\n    copied.value = false\n  }, 1500)\n}\n\nconst loadRuntimeInfo = async () => {\n  isLoading.value = true\n  error.value = null\n  try {\n    runtimeInfo.value = await call<RuntimeInfo>('runtime_info.get')\n  } catch (e) {\n    error.value = toErrorMessage(e)\n  } finally {\n    isLoading.value = false\n  }\n}\n\nconst checkForUpdate = async (silent = false) => {\n  if (isCheckingUpdate.value || isStartingDownload.value || isInstallingUpdate.value) {\n    return\n  }\n\n  isCheckingUpdate.value = true\n  updateError.value = null\n  updateChecked.value = false\n\n  if (updateCheckedTimer) {\n    clearTimeout(updateCheckedTimer)\n  }\n\n  try {\n    const result = await call<CheckUpdateResult>('update.check_for_update')\n\n    hasUpdate.value = result.hasUpdate\n    latestVersion.value = result.latestVersion\n    currentVersionFromUpdate.value = result.currentVersion\n\n    if (result.hasUpdate) {\n      if (!silent) toast.success(t('about.toast.updateAvailable'))\n    } else {\n      if (!silent) {\n        toast.info(t('about.toast.upToDate'))\n        updateChecked.value = true\n        updateCheckedTimer = setTimeout(() => {\n          updateChecked.value = false\n        }, 3000)\n      }\n    }\n  } catch (e) {\n    updateError.value = toErrorMessage(e)\n    if (!silent) toast.error(t('about.toast.updateCheckFailed'))\n  } finally {\n    isCheckingUpdate.value = false\n  }\n}\n\nconst downloadAndInstallUpdate = async () => {\n  if (isCheckingUpdate.value || isStartingDownload.value || isInstallingUpdate.value) {\n    return\n  }\n\n  if (isDownloadedUpdateReady.value) {\n    isInstallingUpdate.value = true\n    updateError.value = null\n\n    try {\n      await call('update.install_update', { restart: true })\n    } catch (e) {\n      updateError.value = toErrorMessage(e)\n      toast.error(t('about.toast.updateInstallFailed'))\n      isInstallingUpdate.value = false\n    }\n    return\n  }\n\n  if (!hasUpdate.value) {\n    return\n  }\n\n  isStartingDownload.value = true\n  updateError.value = null\n\n  try {\n    await call<StartDownloadUpdateResult>('update.start_download')\n  } catch (e) {\n    updateError.value = toErrorMessage(e)\n    toast.error(t('about.toast.updateDownloadFailed'))\n  } finally {\n    isStartingDownload.value = false\n  }\n}\n\nconst handleUpdateAction = async () => {\n  if (hasUpdate.value || isDownloadedUpdateReady.value) {\n    await downloadAndInstallUpdate()\n    return\n  }\n  await checkForUpdate()\n}\n\nconst copyDiagnostics = async () => {\n  const success = await copyToClipboard(diagnosticsText.value)\n  if (success) {\n    markCopied()\n  }\n}\n\nconst openAppDataDirectory = async () => {\n  if (isOpeningAppDataDirectory.value) {\n    return\n  }\n\n  isOpeningAppDataDirectory.value = true\n  try {\n    const result = await call<OpenAppDataDirectoryResult>('file.openAppDataDirectory')\n    if (!result.success) {\n      throw new Error(result.message || t('about.toast.openDataDirectoryFailed'))\n    }\n  } catch (e) {\n    toast.error(t('about.toast.openDataDirectoryFailed'))\n  } finally {\n    isOpeningAppDataDirectory.value = false\n  }\n}\n\nconst openLogDirectory = async () => {\n  if (isOpeningLogDirectory.value) {\n    return\n  }\n\n  isOpeningLogDirectory.value = true\n  try {\n    const result = await call<OpenLogDirectoryResult>('file.openLogDirectory')\n    if (!result.success) {\n      throw new Error(result.message || t('about.toast.openLogDirectoryFailed'))\n    }\n  } catch (e) {\n    toast.error(t('about.toast.openLogDirectoryFailed'))\n  } finally {\n    isOpeningLogDirectory.value = false\n  }\n}\n\nonMounted(() => {\n  void loadRuntimeInfo()\n  void checkForUpdate(true)\n})\n\nonBeforeUnmount(() => {\n  if (copiedTimer) {\n    clearTimeout(copiedTimer)\n  }\n  if (updateCheckedTimer) {\n    clearTimeout(updateCheckedTimer)\n  }\n})\n</script>\n\n<template>\n  <ScrollArea class=\"h-full text-foreground\" ref=\"scrollAreaRef\">\n    <div class=\"flex h-full w-full items-center justify-center py-[clamp(1.5rem,6vh,3rem)]\">\n      <div class=\"mx-auto flex w-full max-w-2xl flex-col items-center px-8\">\n        <!-- Header: Logo, Name, Version -->\n        <div class=\"group mb-[clamp(1.5rem,6vh,3rem)] flex flex-col items-center\">\n          <!-- Spinning Logo -->\n          <div class=\"perspective-1000 relative mb-[clamp(0.75rem,3vh,1.5rem)] h-28 w-28\">\n            <img\n              src=\"/logo_192x192.png\"\n              alt=\"SpinningMomo Logo\"\n              class=\"h-full w-full object-contain transition-transform duration-[1.5s] ease-out group-hover:rotate-[360deg]\"\n            />\n          </div>\n          <h1\n            class=\"mb-[clamp(0.25rem,1.5vh,0.75rem)] text-3xl font-bold tracking-tight text-foreground\"\n          >\n            {{ t('app.name') }}\n          </h1>\n          <button\n            v-if=\"appVersionText !== '-'\"\n            @click=\"handleUpdateAction\"\n            :disabled=\"\n              isCheckingUpdate || isStartingDownload || isInstallingUpdate || isDownloadingUpdate\n            \"\n            class=\"group/badge flex items-center gap-2 rounded-full border border-border/50 px-3 py-1 transition-all duration-300 disabled:opacity-80\"\n            :class=\"[\n              hasUpdate || isDownloadedUpdateReady\n                ? 'border-primary bg-primary text-sm text-primary-foreground shadow-sm hover:opacity-90'\n                : 'bg-secondary/50 text-sm font-medium text-muted-foreground hover:border-border hover:bg-secondary',\n            ]\"\n          >\n            <!-- Status Icon -->\n            <Loader2\n              v-if=\"\n                isCheckingUpdate || isStartingDownload || isInstallingUpdate || isDownloadingUpdate\n              \"\n              class=\"h-3.5 w-3.5 animate-spin\"\n            />\n            <Package v-else-if=\"isDownloadedUpdateReady\" class=\"h-3.5 w-3.5\" />\n            <Download v-else-if=\"hasUpdate\" class=\"h-3.5 w-3.5\" />\n            <Check\n              v-else\n              class=\"h-3.5 w-3.5 text-green-500 transition-transform group-hover/badge:scale-110\"\n            />\n\n            <!-- Status Text -->\n            <span>\n              <template v-if=\"isCheckingUpdate\">{{ t('about.actions.checkingUpdate') }}</template>\n              <template v-else-if=\"isInstallingUpdate\">{{\n                t('about.actions.installingUpdate')\n              }}</template>\n              <template v-else-if=\"isDownloadedUpdateReady\">{{\n                t('about.actions.installDownloadedUpdate', { version: latestVersion || '' })\n              }}</template>\n              <template v-else-if=\"hasUpdate\">{{\n                t('about.actions.downloadUpdate', { version: latestVersion || '' })\n              }}</template>\n              <template v-else>{{ t('about.runtime.version') }} {{ appVersionText }}</template>\n            </span>\n          </button>\n        </div>\n\n        <!-- Actions Card -->\n        <div\n          class=\"surface-top mb-[clamp(1.5rem,6vh,3rem)] w-full overflow-hidden rounded-md shadow-md\"\n        >\n          <!-- Official Website Row -->\n          <a\n            href=\"https://spin.infinitymomo.com\"\n            target=\"_blank\"\n            rel=\"noopener noreferrer\"\n            class=\"group/link flex items-center justify-between border-b border-border/50 px-5 py-[clamp(0.75rem,3vh,1rem)] transition-colors hover:bg-accent/50\"\n          >\n            <div class=\"flex items-center gap-4\">\n              <div\n                class=\"flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary\"\n              >\n                <Globe class=\"h-4 w-4\" />\n              </div>\n              <span class=\"text-[15px] font-medium text-card-foreground\">{{\n                t('about.links.officialWebsite')\n              }}</span>\n            </div>\n            <div class=\"flex items-center gap-2\">\n              <span\n                class=\"text-sm text-muted-foreground transition-colors group-hover/link:text-foreground\"\n                >spin.infinitymomo.com</span\n              >\n              <ExternalLink\n                class=\"h-4 w-4 text-muted-foreground opacity-70 transition-colors group-hover/link:text-foreground group-hover/link:opacity-100\"\n              />\n            </div>\n          </a>\n\n          <!-- Report Issues Row -->\n          <button\n            type=\"button\"\n            class=\"group/link flex w-full items-center justify-between px-5 py-[clamp(0.75rem,3vh,1rem)] text-left transition-colors hover:bg-accent/50\"\n            @click=\"issuesDialogOpen = true\"\n          >\n            <div class=\"flex items-center gap-4\">\n              <div\n                class=\"flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary\"\n              >\n                <Bug class=\"h-4 w-4\" />\n              </div>\n              <span class=\"text-[15px] font-medium text-card-foreground\">{{\n                t('about.links.issues')\n              }}</span>\n            </div>\n            <ChevronRight\n              class=\"h-4 w-4 shrink-0 text-muted-foreground opacity-70 transition-colors group-hover/link:text-foreground group-hover/link:opacity-100\"\n            />\n          </button>\n        </div>\n\n        <Dialog v-model:open=\"issuesDialogOpen\">\n          <DialogContent class=\"gap-4 sm:max-w-md\">\n            <DialogHeader>\n              <DialogTitle>{{ t('about.links.issues') }}</DialogTitle>\n              <DialogDescription>{{ t('about.issuesDialog.description') }}</DialogDescription>\n            </DialogHeader>\n\n            <div\n              class=\"max-h-[min(280px,40vh)] divide-y divide-border/40 overflow-y-auto rounded-lg border border-border/60 bg-muted/35 px-3 text-xs\"\n            >\n              <div class=\"flex justify-between gap-3 py-2.5 first:pt-3 last:pb-3\">\n                <span class=\"shrink-0 text-muted-foreground\">{{ t('about.runtime.version') }}</span>\n                <span class=\"min-w-0 text-right font-medium text-foreground\">{{\n                  appVersionText\n                }}</span>\n              </div>\n              <div class=\"flex justify-between gap-3 py-2.5 first:pt-3 last:pb-3\">\n                <span class=\"shrink-0 text-muted-foreground\">{{\n                  t('about.runtime.environment')\n                }}</span>\n                <span class=\"min-w-0 text-right font-medium text-foreground\">{{\n                  environmentText\n                }}</span>\n              </div>\n              <div class=\"flex justify-between gap-3 py-2.5 first:pt-3 last:pb-3\">\n                <span class=\"flex shrink-0 items-center gap-1.5 text-muted-foreground\"\n                  ><Monitor class=\"h-3.5 w-3.5\" /> {{ t('about.runtime.os') }}</span\n                >\n                <span class=\"min-w-0 text-right font-medium text-foreground\">{{ osText }}</span>\n              </div>\n              <div class=\"flex justify-between gap-3 py-2.5 first:pt-3 last:pb-3\">\n                <span class=\"flex shrink-0 items-center gap-1.5 text-muted-foreground\"\n                  ><Heart class=\"h-3.5 w-3.5\" /> {{ t('about.runtime.webview2') }}</span\n                >\n                <span class=\"min-w-0 text-right font-medium text-foreground\">{{\n                  webview2Text\n                }}</span>\n              </div>\n              <div class=\"flex justify-between gap-3 py-2.5 first:pt-3 last:pb-3\">\n                <span class=\"shrink-0 text-muted-foreground\">{{ t('about.runtime.capture') }}</span>\n                <span class=\"min-w-0 text-right font-medium text-foreground\">{{\n                  formatCapability(runtimeInfo?.isCaptureSupported)\n                }}</span>\n              </div>\n              <div class=\"flex justify-between gap-3 py-2.5 first:pt-3 last:pb-3\">\n                <span class=\"shrink-0 text-muted-foreground\">{{\n                  t('about.runtime.loopback')\n                }}</span>\n                <span class=\"min-w-0 text-right font-medium text-foreground\">{{\n                  formatCapability(runtimeInfo?.isProcessLoopbackAudioSupported)\n                }}</span>\n              </div>\n            </div>\n\n            <div class=\"flex flex-wrap gap-2\">\n              <Button\n                variant=\"secondary\"\n                size=\"sm\"\n                class=\"h-8 text-xs\"\n                @click=\"openAppDataDirectory\"\n              >\n                <FolderOpen class=\"mr-1.5 h-3.5 w-3.5\" /> {{ t('about.actions.openDataDirectory') }}\n              </Button>\n              <Button variant=\"secondary\" size=\"sm\" class=\"h-8 text-xs\" @click=\"openLogDirectory\">\n                <FolderOpen class=\"mr-1.5 h-3.5 w-3.5\" /> {{ t('about.actions.openLogDirectory') }}\n              </Button>\n              <Button variant=\"secondary\" size=\"sm\" class=\"h-8 text-xs\" @click=\"copyDiagnostics\">\n                <Check v-if=\"copied\" class=\"mr-1.5 h-3.5 w-3.5 text-green-500\" />\n                <Info v-else class=\"mr-1.5 h-3.5 w-3.5\" />\n                {{ copied ? t('about.status.copied') : t('about.actions.copyDiagnostics') }}\n              </Button>\n            </div>\n\n            <DialogFooter>\n              <Button as-child class=\"w-full sm:w-full\">\n                <a :href=\"issuesUrl\" target=\"_blank\" rel=\"noopener noreferrer\">\n                  {{ t('about.issuesDialog.openOnGithub') }}\n                  <ExternalLink class=\"ml-2 inline h-4 w-4 align-text-bottom opacity-90\" />\n                </a>\n              </Button>\n            </DialogFooter>\n          </DialogContent>\n        </Dialog>\n\n        <!-- Footer -->\n        <div\n          class=\"flex flex-col items-center space-y-3 text-center text-[12px] text-muted-foreground\"\n        >\n          <p>&copy; 2026 InfinityMomo. {{ t('about.footer.rightsReserved') }}</p>\n          <p>\n            {{ t('about.footer.openSourcePrefix') }}\n            <a\n              :href=\"creditsUrl\"\n              target=\"_blank\"\n              rel=\"noopener noreferrer\"\n              class=\"text-primary/80 transition-colors hover:text-primary hover:underline\"\n            >\n              {{ t('about.footer.openSourceLink') }} </a\n            >{{ t('about.footer.openSourceSuffix') }}\n            <a\n              :href=\"nuan5Url\"\n              target=\"_blank\"\n              rel=\"noopener noreferrer\"\n              class=\"text-green-500 transition-colors hover:text-green-600 hover:underline dark:text-green-400 dark:hover:text-green-300\"\n            >\n              {{ t('about.footer.creditLink') }} </a\n            >{{ t('about.footer.creditSuffix') }}\n          </p>\n          <div class=\"flex items-center justify-center gap-5 pt-2\">\n            <a\n              :href=\"legalNoticeUrl\"\n              target=\"_blank\"\n              rel=\"noopener noreferrer\"\n              class=\"transition-colors hover:text-foreground hover:underline\"\n              >{{ t('about.links.legalNotice') }}</a\n            >\n            <a\n              :href=\"licenseUrl\"\n              target=\"_blank\"\n              rel=\"noopener noreferrer\"\n              class=\"transition-colors hover:text-foreground hover:underline\"\n              >{{ t('about.links.license') }}</a\n            >\n          </div>\n        </div>\n      </div>\n    </div>\n  </ScrollArea>\n</template>\n\n<style scoped>\n:deep([data-slot='scroll-area-viewport'] > div) {\n  height: 100%;\n}\n</style>\n"
  },
  {
    "path": "web/src/features/common/pages/NotFoundPage.vue",
    "content": "<template>\n  <div class=\"flex h-full items-center justify-center p-6\">\n    <div class=\"text-center\">\n      <h1 class=\"text-2xl font-bold\">404</h1>\n      <p class=\"mt-2 text-sm text-muted-foreground\">Page not found.</p>\n    </div>\n  </div>\n</template>\n"
  },
  {
    "path": "web/src/features/gallery/api/dto.ts",
    "content": "import type { Asset, AssetType, ReviewFlag, SortBy, SortOrder } from '../types'\n\n// =========================\n// Gallery API DTO (RPC types)\n// =========================\n\n// ============= 文件夹/标签/资产动作参数 =============\n\nexport interface UpdateFolderDisplayNameParams {\n  id: number\n  displayName: string\n}\n\n// 标签统计类型（仅 API 查询/展示用）\nexport interface TagStats {\n  id: number\n  name: string\n  assetCount: number\n}\n\nexport interface HomeStats {\n  totalCount: number\n  photoCount: number\n  videoCount: number\n  livePhotoCount: number\n  totalSize: number\n  todayAddedCount: number\n}\n\n// 创建标签参数\nexport interface CreateTagParams {\n  name: string\n  parentId?: number\n  sortOrder?: number\n}\n\n// 更新标签参数\nexport interface UpdateTagParams {\n  id: number\n  name?: string\n  parentId?: number\n  sortOrder?: number\n}\n\n// 为资产添加标签参数\nexport interface AddTagsToAssetParams {\n  assetId: number\n  tagIds: number[]\n}\n\nexport interface AddTagToAssetsParams {\n  assetIds: number[]\n  tagId: number\n}\n\n// 从资产移除标签参数\nexport interface RemoveTagsFromAssetParams {\n  assetId: number\n  tagIds: number[]\n}\n\nexport interface UpdateAssetsReviewStateParams {\n  assetIds: number[]\n  rating?: number\n  reviewFlag?: ReviewFlag\n}\n\nexport interface MoveAssetsToFolderParams {\n  ids: number[]\n  targetFolderId: number\n}\n\nexport interface UpdateAssetDescriptionParams {\n  assetId: number\n  description?: string\n}\n\nexport type InfinityNikkiUserRecordCodeType = 'dye' | 'home_building'\n\nexport interface SetInfinityNikkiUserRecordParams {\n  assetId: number\n  codeType: InfinityNikkiUserRecordCodeType\n  codeValue?: string\n}\n\n// 获取资产标签参数\nexport interface GetAssetTagsParams {\n  assetId: number\n}\n\n// 资产标签关联类型\nexport interface AssetTag {\n  assetId: number\n  tagId: number\n  createdAt: number\n}\n\nexport interface AssetMainColor {\n  r: number\n  g: number\n  b: number\n  weight: number\n}\n\n// 忽略规则类型\nexport interface IgnoreRule {\n  id: number\n  folderId?: number\n  rulePattern: string\n  patternType: 'glob' | 'regex'\n  ruleType: 'exclude' | 'include'\n  isEnabled: boolean\n  description?: string\n  createdAt: number\n  updatedAt: number\n}\n\n// 扫描时提交的忽略规则（对应后端 ScanIgnoreRule）\nexport interface ScanIgnoreRule {\n  pattern: string\n  patternType?: 'glob' | 'regex'\n  ruleType?: 'exclude' | 'include'\n  description?: string\n}\n\n// ============= API请求/响应类型 =============\n\n// 查询响应数据（grid/list/masonry 时间线等共用）\nexport interface QueryAssetsResponseData {\n  items: Asset[]\n  totalCount: number\n  currentPage: number\n  perPage: number\n  totalPages: number\n  activeAssetIndex?: number\n}\n\n// 操作结果\nexport interface OperationResult {\n  success: boolean\n  message: string\n  affectedCount?: number\n  failedCount?: number\n  notFoundCount?: number\n  unchangedCount?: number\n}\n\n// 扫描参数\nexport interface ScanAssetsParams {\n  directory: string\n  generateThumbnails?: boolean\n  thumbnailShortEdge?: number\n  supportedExtensions?: string[]\n  ignoreRules?: ScanIgnoreRule[]\n  forceReanalyze?: boolean\n  rebuildThumbnails?: boolean\n}\n\n// 扫描结果\nexport interface ScanAssetsResult {\n  totalFiles: number\n  newItems: number\n  updatedItems: number\n  deletedItems: number\n  errors: string[]\n  scanDuration: string\n}\n\nexport interface StartScanAssetsResult {\n  taskId: string\n}\n\nexport interface AssetReachability {\n  exists: boolean\n  readable: boolean\n  path?: string\n  reason?: string\n}\n\n// ============= 统一查询相关类型 =============\n\n// 查询过滤器\nexport interface QueryAssetsFilters {\n  folderId?: number\n  includeSubfolders?: boolean\n  month?: string // \"2024-10\" 格式\n  year?: string // \"2024\" 格式\n  type?: AssetType // photo, video, live_photo\n  search?: string // 搜索关键词\n  rating?: number\n  reviewFlag?: ReviewFlag\n  tagIds?: number[]\n  tagMatchMode?: 'any' | 'all'\n  clothIds?: number[]\n  clothMatchMode?: 'any' | 'all'\n  colorHexes?: string[]\n  colorMatchMode?: 'any' | 'all'\n  colorDistance?: number\n}\n\n// 查询参数\nexport interface QueryAssetsParams {\n  filters: QueryAssetsFilters\n  sortBy?: SortBy\n  sortOrder?: SortOrder\n  activeAssetId?: number\n  // 分页是可选的：传page就分页，不传就返回所有结果\n  page?: number\n  perPage?: number\n}\n\n// 查询响应（grid/list/masonry 时间线等共用）\nexport type QueryAssetsResponse = QueryAssetsResponseData\n\nexport interface AssetLayoutMetaItem {\n  id: number\n  width?: number\n  height?: number\n}\n\nexport interface QueryAssetLayoutMetaParams {\n  filters: QueryAssetsFilters\n  sortBy?: SortBy\n  sortOrder?: SortOrder\n}\n\nexport interface QueryAssetLayoutMetaResponse {\n  items: AssetLayoutMetaItem[]\n  totalCount: number\n}\n\nexport interface AdaptiveLayoutRowItem {\n  index: number\n  id: number\n  width: number\n  height: number\n  aspectRatio: number\n}\n\nexport interface AdaptiveLayoutRow {\n  index: number\n  start: number\n  size: number\n  items: AdaptiveLayoutRowItem[]\n}\n\nexport interface QueryPhotoMapPointsParams {\n  filters: QueryAssetsFilters\n  sortBy?: SortBy\n  sortOrder?: SortOrder\n}\n\nexport interface PhotoMapPoint {\n  assetId: number\n  name: string\n  hash?: string\n  fileCreatedAt?: number\n  nikkiLocX: number\n  nikkiLocY: number\n  nikkiLocZ?: number\n  assetIndex: number\n}\n\nexport interface InfinityNikkiExtractedParams {\n  cameraParams?: string\n  timeHour?: number\n  timeMin?: number\n  cameraFocalLength?: number\n  rotation?: number\n  apertureValue?: number\n  filterId?: number\n  filterStrength?: number\n  vignetteIntensity?: number\n  lightId?: number\n  lightStrength?: number\n  vertical?: number\n  bloomIntensity?: number\n  bloomThreshold?: number\n  brightness?: number\n  exposure?: number\n  contrast?: number\n  saturation?: number\n  vibrance?: number\n  highlights?: number\n  shadow?: number\n  nikkiLocX?: number\n  nikkiLocY?: number\n  nikkiLocZ?: number\n  nikkiHidden?: number\n  poseId?: number\n}\n\nexport interface InfinityNikkiUserRecord {\n  codeType: InfinityNikkiUserRecordCodeType\n  codeValue: string\n}\n\nexport interface InfinityNikkiDetails {\n  extracted?: InfinityNikkiExtractedParams\n  userRecord?: InfinityNikkiUserRecord\n}\n\nexport interface GetInfinityNikkiMetadataNamesParams {\n  filterId?: number\n  poseId?: number\n  lightId?: number\n  locale?: 'zh-CN' | 'en-US'\n}\n\nexport interface InfinityNikkiMetadataNames {\n  filterName?: string\n  poseName?: string\n  lightName?: string\n}\n\n// ============= 时间线相关类型 =============\n\nexport interface TimelineBucket {\n  month: string // \"2024-10\" 格式\n  count: number // 该月照片数量\n}\n\nexport interface GetTimelineBucketsParams {\n  folderId?: number\n  includeSubfolders?: boolean\n  sortOrder?: 'asc' | 'desc'\n  activeAssetId?: number\n  type?: AssetType\n  search?: string\n  rating?: number\n  reviewFlag?: ReviewFlag\n  tagIds?: number[]\n  tagMatchMode?: 'any' | 'all'\n  clothIds?: number[]\n  clothMatchMode?: 'any' | 'all'\n  colorHexes?: string[]\n  colorMatchMode?: 'any' | 'all'\n  colorDistance?: number\n}\n\nexport interface TimelineBucketsResponse {\n  buckets: TimelineBucket[]\n  totalCount: number\n  activeAssetIndex?: number\n}\n\nexport interface GetAssetsByMonthParams {\n  month: string // \"2024-10\" 格式\n  folderId?: number\n  includeSubfolders?: boolean\n  sortOrder?: 'asc' | 'desc'\n  type?: AssetType\n  search?: string\n  rating?: number\n  reviewFlag?: ReviewFlag\n  tagIds?: number[]\n  tagMatchMode?: 'any' | 'all'\n  clothIds?: number[]\n  clothMatchMode?: 'any' | 'all'\n  colorHexes?: string[]\n  colorMatchMode?: 'any' | 'all'\n  colorDistance?: number\n}\n\nexport interface GetAssetsByMonthResponse {\n  month: string\n  assets: Asset[]\n  count: number\n}\n"
  },
  {
    "path": "web/src/features/gallery/api/urls.ts",
    "content": "import type { Asset } from '../types'\nimport { getStaticUrl, isWebView } from '@/core/env'\n\n/**\n * 逐段编码，保留目录层级中的 '/'，同时正确处理中文、空格、#、% 等特殊字符。\n */\nfunction encodeRelativePathForUrl(relativePath: string): string {\n  return relativePath\n    .split('/')\n    .filter((segment) => segment.length > 0)\n    .map((segment) => encodeURIComponent(segment))\n    .join('/')\n}\n\n/**\n * 获取资产缩略图URL\n * 路径格式: thumbnails/[hash前2位]/[hash第3-4位]/{hash}.webp\n */\nexport function getAssetThumbnailUrl(asset: Asset): string {\n  const hash = asset.hash\n  if (!hash) {\n    return ''\n  }\n\n  const prefix1 = hash.slice(0, 2)\n  const prefix2 = hash.slice(2, 4)\n  const relativePath = `${prefix1}/${prefix2}/${hash}.webp`\n\n  // WebView release 直接走缩略图虚拟主机映射，少一层动态解析。\n  if (isWebView() && !import.meta.env.DEV) {\n    return `https://thumbs.test/${relativePath}`\n  }\n\n  return getStaticUrl(`/static/assets/thumbnails/${relativePath}`)\n}\n\n/**\n * 获取资产原图 URL\n *\n * 新模型下，原图由：\n * - rootId：资源属于哪个 watch root\n * - relativePath：文件在该 root 下的相对路径\n * - hash：版本参数，避免内容更新后 URL 不变\n *\n * 环境差异：\n * - WebView：`https://r-<rootId>.test/<relativePath>?v=<hash>`\n * - 浏览器 dev：`/static/assets/originals/by-root/<rootId>/<relativePath>?v=<hash>`\n */\nexport function getAssetUrl(asset: Asset): string {\n  if (!asset.rootId || !asset.relativePath) {\n    return ''\n  }\n\n  const encodedRelativePath = encodeRelativePathForUrl(asset.relativePath)\n  const versionQuery = asset.hash ? `?v=${encodeURIComponent(asset.hash)}` : ''\n\n  if (isWebView()) {\n    return `https://r-${asset.rootId}.test/${encodedRelativePath}${versionQuery}`\n  }\n\n  return `/static/assets/originals/by-root/${asset.rootId}/${encodedRelativePath}${versionQuery}`\n}\n"
  },
  {
    "path": "web/src/features/gallery/api.ts",
    "content": "import { call } from '@/core/rpc'\nimport type { FolderTreeNode, Tag, TagTreeNode } from './types'\nimport { getAssetThumbnailUrl, getAssetUrl } from './api/urls'\nimport type {\n  OperationResult,\n  ScanAssetsParams,\n  ScanAssetsResult,\n  StartScanAssetsResult,\n  UpdateFolderDisplayNameParams,\n  GetTimelineBucketsParams,\n  TimelineBucketsResponse,\n  GetAssetsByMonthParams,\n  GetAssetsByMonthResponse,\n  QueryAssetsParams,\n  QueryAssetsResponse,\n  QueryAssetLayoutMetaParams,\n  QueryAssetLayoutMetaResponse,\n  QueryPhotoMapPointsParams,\n  PhotoMapPoint,\n  InfinityNikkiDetails,\n  GetInfinityNikkiMetadataNamesParams,\n  InfinityNikkiMetadataNames,\n  AssetMainColor,\n  TagStats,\n  HomeStats,\n  CreateTagParams,\n  UpdateTagParams,\n  AddTagsToAssetParams,\n  AddTagToAssetsParams,\n  RemoveTagsFromAssetParams,\n  UpdateAssetsReviewStateParams,\n  MoveAssetsToFolderParams,\n  UpdateAssetDescriptionParams,\n  SetInfinityNikkiUserRecordParams,\n  AssetReachability,\n} from './api/dto'\nimport { transformInfinityNikkiTree } from '@/extensions/infinity_nikki'\nimport { useI18n } from '@/core/i18n'\n\n/**\n * 转换默认输出文件夹树结构\n * 将 SpinningMomo 根文件夹的显示名称设置为应用名称\n * @param tree 原始文件夹树\n * @returns 转换后的文件夹树\n */\nexport function transformDefaultOutputFolderTree(tree: FolderTreeNode[]): FolderTreeNode[] {\n  const { t } = useI18n()\n\n  // 遍历并转换所有根文件夹\n  return tree.map((node) => {\n    // 检查是否是 SpinningMomo 根文件夹\n    if (node.name === 'SpinningMomo' && !node.displayName) {\n      return {\n        ...node,\n        displayName: t('app.name'),\n      }\n    }\n    return node\n  })\n}\n\n/**\n * 获取文件夹树结构\n */\nexport async function getFolderTree(): Promise<FolderTreeNode[]> {\n  try {\n    const result = await call<FolderTreeNode[]>('gallery.getFolderTree', {})\n\n    console.log('📁 获取文件夹树成功:', result.length, '个根文件夹')\n\n    // 应用默认输出文件夹转换\n    let transformedResult = transformDefaultOutputFolderTree(result)\n\n    // 应用 Infinity Nikki 拓展转换\n    transformedResult = transformInfinityNikkiTree(transformedResult)\n\n    return transformedResult\n  } catch (error) {\n    console.error('Failed to get folder tree:', error)\n    throw new Error('获取文件夹树失败')\n  }\n}\n\n/**\n * 更新文件夹显示名称（仅应用内）\n */\nexport async function updateFolderDisplayName(\n  params: UpdateFolderDisplayNameParams\n): Promise<OperationResult> {\n  try {\n    const result = await call<OperationResult>('gallery.updateFolderDisplayName', params)\n    return result\n  } catch (error) {\n    console.error('Failed to update folder display name:', error)\n    throw new Error('更新文件夹显示名称失败')\n  }\n}\n\n/**\n * 在资源管理器中打开文件夹\n */\nexport async function openFolderInExplorer(folderId: number): Promise<OperationResult> {\n  try {\n    const result = await call<OperationResult>('gallery.openFolderInExplorer', { id: folderId })\n    return result\n  } catch (error) {\n    console.error('Failed to open folder in explorer:', error)\n    throw new Error('打开文件夹失败')\n  }\n}\n\n/**\n * 移出根文件夹监听并清理索引\n */\nexport async function removeFolderWatch(folderId: number): Promise<OperationResult> {\n  try {\n    const result = await call<OperationResult>('gallery.removeFolderWatch', { id: folderId })\n    return result\n  } catch (error) {\n    console.error('Failed to remove folder watch:', error)\n    throw new Error('移出监听失败')\n  }\n}\n\n/**\n * 扫描资产目录\n */\nexport async function scanAssets(params: ScanAssetsParams): Promise<ScanAssetsResult> {\n  try {\n    console.log('🔍 开始扫描资产目录:', params.directory)\n\n    const result = await call<ScanAssetsResult>('gallery.scanDirectory', params, 0)\n\n    console.log('✅ 资产扫描完成:', {\n      total: result.totalFiles,\n      new: result.newItems,\n      updated: result.updatedItems,\n      duration: result.scanDuration,\n    })\n\n    return result\n  } catch (error) {\n    console.error('Failed to scan assets:', error)\n    throw new Error('扫描资产目录失败')\n  }\n}\n\n/**\n * 在后台启动资产扫描任务\n */\nexport async function startScanAssets(params: ScanAssetsParams): Promise<StartScanAssetsResult> {\n  try {\n    console.log('🧵 提交后台扫描任务:', params.directory)\n\n    const result = await call<StartScanAssetsResult>('gallery.startScanDirectory', params)\n\n    console.log('✅ 后台扫描任务已创建:', result.taskId)\n\n    return result\n  } catch (error) {\n    console.error('Failed to start scan task:', error)\n    throw new Error('提交扫描任务失败')\n  }\n}\n\n/**\n * 清理缩略图\n */\nexport async function cleanupThumbnails(): Promise<OperationResult> {\n  try {\n    console.log('🧹 开始清理缩略图')\n\n    const result = await call<OperationResult>('gallery.cleanupThumbnails', {})\n\n    console.log('✅ 缩略图清理完成:', result.message)\n\n    return result\n  } catch (error) {\n    console.error('Failed to cleanup thumbnails:', error)\n    throw new Error('清理缩略图失败')\n  }\n}\n\n/**\n * 获取缩略图统计\n */\nexport async function getThumbnailStats(): Promise<string> {\n  try {\n    const result = await call<string>('gallery.thumbnailStats', {})\n\n    console.log('📊 获取缩略图统计成功')\n\n    return result\n  } catch (error) {\n    console.error('Failed to get thumbnail stats:', error)\n    throw new Error('获取缩略图统计失败')\n  }\n}\n\n/**\n * 使用系统默认应用打开资产文件\n */\nexport async function openAssetDefault(assetId: number): Promise<OperationResult> {\n  try {\n    const result = await call<OperationResult>('gallery.openAssetDefault', { id: assetId })\n    return result\n  } catch (error) {\n    console.error('Failed to open asset with default app:', error)\n    throw new Error('打开文件失败')\n  }\n}\n\n/**\n * 在资源管理器中显示并选中资产文件\n */\nexport async function revealAssetInExplorer(assetId: number): Promise<OperationResult> {\n  try {\n    const result = await call<OperationResult>('gallery.revealAssetInExplorer', { id: assetId })\n    return result\n  } catch (error) {\n    console.error('Failed to reveal asset in explorer:', error)\n    throw new Error('在资源管理器中定位文件失败')\n  }\n}\n\n/**\n * 将资产文件复制到系统剪贴板\n */\nexport async function copyAssetsToClipboard(assetIds: number[]): Promise<OperationResult> {\n  try {\n    const result = await call<OperationResult>('gallery.copyAssetsToClipboard', { ids: assetIds })\n    return result\n  } catch (error) {\n    console.error('Failed to copy assets to clipboard:', error)\n    throw new Error('复制文件失败')\n  }\n}\n\n/**\n * 将资产移动到系统回收站\n */\nexport async function moveAssetsToTrash(assetIds: number[]): Promise<OperationResult> {\n  try {\n    const result = await call<OperationResult>('gallery.moveAssetsToTrash', { ids: assetIds })\n    return result\n  } catch (error) {\n    console.error('Failed to move assets to trash:', error)\n    throw new Error('移到回收站失败')\n  }\n}\n\nexport async function moveAssetsToFolder(\n  params: MoveAssetsToFolderParams\n): Promise<OperationResult> {\n  try {\n    const result = await call<OperationResult>('gallery.moveAssetsToFolder', params)\n    return result\n  } catch (error) {\n    console.error('Failed to move assets to folder:', error)\n    throw new Error('移动到文件夹失败')\n  }\n}\n\nexport async function checkAssetReachable(assetId: number): Promise<AssetReachability> {\n  try {\n    const result = await call<AssetReachability>('gallery.checkAssetReachable', { assetId })\n    return result\n  } catch (error) {\n    console.error('Failed to check asset reachability:', error)\n    throw new Error('检查资产可达性失败')\n  }\n}\n\n/**\n * 批量更新资产的审片状态（评分 / 留用 / 弃置）\n */\nexport async function updateAssetsReviewState(\n  params: UpdateAssetsReviewStateParams\n): Promise<OperationResult> {\n  try {\n    const result = await call<OperationResult>('gallery.updateAssetsReviewState', params)\n    return result\n  } catch (error) {\n    console.error('Failed to update assets review state:', error)\n    throw new Error('更新审片状态失败')\n  }\n}\n\n/**\n * 获取时间线桶（月份元数据）\n */\nexport async function getTimelineBuckets(\n  params: GetTimelineBucketsParams = {}\n): Promise<TimelineBucketsResponse> {\n  try {\n    const result = await call<TimelineBucketsResponse>('gallery.getTimelineBuckets', params)\n\n    return result\n  } catch (error) {\n    console.error('Failed to get timeline buckets:', error)\n    throw new Error('获取时间线桶失败')\n  }\n}\n\n/**\n * 获取指定月份的资产\n */\nexport async function getAssetsByMonth(\n  params: GetAssetsByMonthParams\n): Promise<GetAssetsByMonthResponse> {\n  try {\n    const result = await call<GetAssetsByMonthResponse>('gallery.getAssetsByMonth', params)\n\n    return result\n  } catch (error) {\n    console.error('Failed to get assets by month:', error)\n    throw new Error('获取月份资产失败')\n  }\n}\n\n/**\n * 统一资产查询接口（支持灵活过滤器和可选分页）\n */\nexport async function queryAssets(params: QueryAssetsParams): Promise<QueryAssetsResponse> {\n  try {\n    const result = await call<QueryAssetsResponse>('gallery.queryAssets', params)\n\n    console.log('🔍 查询资产成功:', {\n      count: result.items.length,\n      total: result.totalCount,\n      page: result.currentPage,\n      activeAssetIndex: result.activeAssetIndex,\n      filters: params.filters,\n    })\n\n    return result\n  } catch (error) {\n    console.error('Failed to query assets:', error)\n    throw new Error('查询资产失败')\n  }\n}\n\n/**\n * 查询自适应视图需要的轻量布局元数据\n */\nexport async function queryAssetLayoutMeta(\n  params: QueryAssetLayoutMetaParams\n): Promise<QueryAssetLayoutMetaResponse> {\n  try {\n    const result = await call<QueryAssetLayoutMetaResponse>('gallery.queryAssetLayoutMeta', params)\n\n    console.log('🧩 查询布局元数据成功:', {\n      count: result.items.length,\n      total: result.totalCount,\n      filters: params.filters,\n    })\n\n    return result\n  } catch (error) {\n    console.error('Failed to query asset layout meta:', error)\n    throw new Error('查询布局元数据失败')\n  }\n}\n\n/**\n * 查询当前筛选下的地图点位\n */\nexport async function queryPhotoMapPoints(\n  params: QueryPhotoMapPointsParams\n): Promise<PhotoMapPoint[]> {\n  try {\n    const result = await call<PhotoMapPoint[]>('gallery.queryPhotoMapPoints', params)\n\n    console.log('🗺️ 查询地图点位成功:', {\n      count: result.length,\n      filters: params.filters,\n    })\n\n    return result\n  } catch (error) {\n    console.error('Failed to query photo map points:', error)\n    throw new Error('查询地图点位失败')\n  }\n}\n\n/**\n * 获取 Infinity Nikki 详情\n */\nexport async function getInfinityNikkiDetails(assetId: number): Promise<InfinityNikkiDetails> {\n  try {\n    const result = await call<InfinityNikkiDetails>('gallery.getInfinityNikkiDetails', {\n      assetId,\n    })\n\n    return result\n  } catch (error) {\n    console.error('Failed to get Infinity Nikki details:', error)\n    throw new Error('获取无限暖暖详情失败')\n  }\n}\n\n/**\n * 获取 Infinity Nikki 参数 ID 的本地化名称映射\n */\nexport async function getInfinityNikkiMetadataNames(\n  params: GetInfinityNikkiMetadataNamesParams\n): Promise<InfinityNikkiMetadataNames> {\n  try {\n    const result = await call<InfinityNikkiMetadataNames>(\n      'gallery.getInfinityNikkiMetadataNames',\n      params\n    )\n    return result\n  } catch (error) {\n    console.error('Failed to get Infinity Nikki metadata names:', error)\n    // 该接口用于“增强展示”，失败时返回空映射，让 UI 自动回退原始 ID。\n    return {}\n  }\n}\n\n/**\n * 获取资产主色调板\n */\nexport async function getAssetMainColors(assetId: number): Promise<AssetMainColor[]> {\n  try {\n    const result = await call<AssetMainColor[]>('gallery.getAssetMainColors', {\n      assetId,\n    })\n\n    return result\n  } catch (error) {\n    console.error('Failed to get asset main colors:', error)\n    throw new Error('获取主色失败')\n  }\n}\n\n/**\n * 获取标签树结构\n */\nexport async function getTagTree(): Promise<TagTreeNode[]> {\n  try {\n    const result = await call<TagTreeNode[]>('gallery.getTagTree', {})\n\n    console.log('🏷️ 获取标签树成功:', result.length, '个根标签')\n\n    return result\n  } catch (error) {\n    console.error('Failed to get tag tree:', error)\n    throw new Error('获取标签树失败')\n  }\n}\n\n/**\n * 获取所有标签（扫平列表）\n */\nexport async function listTags(): Promise<Tag[]> {\n  try {\n    const result = await call<Tag[]>('gallery.listTags', {})\n\n    console.log('🏷️ 获取标签列表成功:', result.length, '个标签')\n\n    return result\n  } catch (error) {\n    console.error('Failed to list tags:', error)\n    throw new Error('获取标签列表失败')\n  }\n}\n\n/**\n * 创建标签\n */\nexport async function createTag(params: CreateTagParams): Promise<{ id: number }> {\n  try {\n    console.log('➕ 创建标签:', params.name)\n\n    const result = await call<number>('gallery.createTag', params)\n\n    console.log('✅ 标签创建成功:', result)\n\n    return { id: result }\n  } catch (error) {\n    console.error('Failed to create tag:', error)\n    throw new Error('创建标签失败')\n  }\n}\n\n/**\n * 更新标签\n */\nexport async function updateTag(params: UpdateTagParams): Promise<OperationResult> {\n  try {\n    console.log('✏️ 更新标签:', params.id)\n\n    const result = await call<OperationResult>('gallery.updateTag', params)\n\n    console.log('✅ 标签更新成功:', result.message)\n\n    return result\n  } catch (error) {\n    console.error('Failed to update tag:', error)\n    throw new Error('更新标签失败')\n  }\n}\n\n/**\n * 删除标签\n */\nexport async function deleteTag(tagId: number): Promise<OperationResult> {\n  try {\n    console.log('🗑️ 删除标签:', tagId)\n\n    const result = await call<OperationResult>('gallery.deleteTag', { id: tagId })\n\n    console.log('✅ 标签删除成功:', result.message)\n\n    return result\n  } catch (error) {\n    console.error('Failed to delete tag:', error)\n    throw new Error('删除标签失败')\n  }\n}\n\n/**\n * 获取标签统计\n */\nexport async function getTagStats(): Promise<TagStats[]> {\n  try {\n    const result = await call<TagStats[]>('gallery.getTagStats', {})\n\n    console.log('📊 获取标签统计成功')\n\n    return result\n  } catch (error) {\n    console.error('Failed to get tag stats:', error)\n    throw new Error('获取标签统计失败')\n  }\n}\n\n/**\n * 获取首页统计摘要\n */\nexport async function getHomeStats(): Promise<HomeStats> {\n  try {\n    const result = await call<HomeStats>('gallery.getHomeStats', {})\n\n    return result\n  } catch (error) {\n    console.error('Failed to get home stats:', error)\n    throw new Error('获取首页统计失败')\n  }\n}\n\n/**\n * 为资产添加标签\n */\nexport async function addTagsToAsset(params: AddTagsToAssetParams): Promise<OperationResult> {\n  try {\n    console.log('🏷️ 为资产添加标签:', params.assetId, params.tagIds)\n\n    const result = await call<OperationResult>('gallery.addTagsToAsset', params)\n\n    console.log('✅ 标签添加成功:', result.message)\n\n    return result\n  } catch (error) {\n    console.error('Failed to add tags to asset:', error)\n    throw new Error('添加标签失败')\n  }\n}\n\n/**\n * 为多个资产批量添加同一个标签\n */\nexport async function addTagToAssets(params: AddTagToAssetsParams): Promise<OperationResult> {\n  try {\n    console.log('🏷️ 为多个资产添加标签:', params.assetIds.length, params.tagId)\n\n    const result = await call<OperationResult>('gallery.addTagToAssets', params)\n\n    console.log('✅ 批量标签添加完成:', result.message)\n\n    return result\n  } catch (error) {\n    console.error('Failed to add tag to assets:', error)\n    throw new Error('批量添加标签失败')\n  }\n}\n\n/**\n * 从资产移除标签\n */\nexport async function removeTagsFromAsset(\n  params: RemoveTagsFromAssetParams\n): Promise<OperationResult> {\n  try {\n    console.log('🗑️ 从资产移除标签:', params.assetId, params.tagIds)\n\n    const result = await call<OperationResult>('gallery.removeTagsFromAsset', params)\n\n    console.log('✅ 标签移除成功:', result.message)\n\n    return result\n  } catch (error) {\n    console.error('Failed to remove tags from asset:', error)\n    throw new Error('移除标签失败')\n  }\n}\n\n/**\n * 获取资产的所有标签\n */\nexport async function getAssetTags(assetId: number): Promise<Tag[]> {\n  try {\n    const result = await call<Tag[]>('gallery.getAssetTags', { assetId })\n\n    return result\n  } catch (error) {\n    console.error('Failed to get asset tags:', error)\n    throw new Error('获取资产标签失败')\n  }\n}\n\n/**\n * 更新资产描述\n */\nexport async function updateAssetDescription(\n  params: UpdateAssetDescriptionParams\n): Promise<OperationResult> {\n  try {\n    const result = await call<OperationResult>('gallery.updateAssetDescription', params)\n\n    return result\n  } catch (error) {\n    console.error('Failed to update asset description:', error)\n    throw new Error('更新资产描述失败')\n  }\n}\n\n/**\n * 设置 Infinity Nikki 玩家记录\n */\nexport async function setInfinityNikkiUserRecord(\n  params: SetInfinityNikkiUserRecordParams\n): Promise<OperationResult> {\n  try {\n    const result = await call<OperationResult>('gallery.setInfinityNikkiUserRecord', params)\n\n    return result\n  } catch (error) {\n    console.error('Failed to set Infinity Nikki user record:', error)\n    throw new Error('更新无限暖暖玩家记录失败')\n  }\n}\n\n/**\n * 批量获取多个资产的标签\n */\nexport async function getTagsByAssetIds(assetIds: number[]): Promise<Record<number, Tag[]>> {\n  try {\n    const result = await call<Record<number, Tag[]>>('gallery.getTagsByAssetIds', { assetIds })\n\n    return result\n  } catch (error) {\n    console.error('Failed to get tags by asset ids:', error)\n    throw new Error('批量获取资产标签失败')\n  }\n}\n\n/**\n * Gallery API 统一导出\n */\nexport const galleryApi = {\n  // 数据查询\n  getFolderTree,\n  updateFolderDisplayName,\n  openFolderInExplorer,\n  removeFolderWatch,\n  queryAssets, // 统一查询接口\n  queryAssetLayoutMeta,\n  queryPhotoMapPoints,\n  getInfinityNikkiDetails,\n  getInfinityNikkiMetadataNames,\n  getAssetMainColors,\n\n  // 时间线查询\n  getTimelineBuckets,\n  getAssetsByMonth,\n\n  // 数据操作\n  scanAssets,\n  startScanAssets,\n\n  // 维护操作\n  cleanupThumbnails,\n  getThumbnailStats,\n\n  // 标签管理\n  getTagTree,\n  listTags,\n  createTag,\n  updateTag,\n  deleteTag,\n  getTagStats,\n  getHomeStats,\n\n  // 资产-标签关联\n  addTagsToAsset,\n  addTagToAssets,\n  removeTagsFromAsset,\n  getAssetTags,\n  getTagsByAssetIds,\n\n  // URL 工具\n  getAssetThumbnailUrl,\n  getAssetUrl,\n\n  // 资产动作\n  openAssetDefault,\n  revealAssetInExplorer,\n  copyAssetsToClipboard,\n  moveAssetsToTrash,\n  moveAssetsToFolder,\n  checkAssetReachable,\n  updateAssetsReviewState,\n  updateAssetDescription,\n  setInfinityNikkiUserRecord,\n}\n"
  },
  {
    "path": "web/src/features/gallery/components/asset/AssetCard.vue",
    "content": "<script setup lang=\"ts\">\nimport { ref, computed, watch } from 'vue'\nimport { Play } from 'lucide-vue-next'\nimport { hexToHsv, hsvToHex, normalizeToHex } from '@/components/ui/color-picker/colorUtils'\nimport { useGalleryData } from '../../composables/useGalleryData'\nimport MediaStatusChips from './MediaStatusChips.vue'\nimport type { Asset } from '../../types'\n\nconst FALLBACK_PLACEHOLDER_COLOR = '#6B7280'\n\n// Props 定义\ninterface AssetCardProps {\n  asset: Asset\n  isSelected?: boolean\n  aspectRatio?: string\n}\n\nconst props = withDefaults(defineProps<AssetCardProps>(), {\n  isSelected: false,\n  aspectRatio: '1 / 1',\n})\n\n// Emits 定义\nconst emit = defineEmits<{\n  click: [asset: Asset, event: MouseEvent]\n  'double-click': [asset: Asset, event: MouseEvent]\n  'context-menu': [asset: Asset, event: MouseEvent]\n  'drag-start': [asset: Asset, event: DragEvent]\n}>()\n\n// 响应式状态\nconst isImageLoading = ref(true)\nconst imageError = ref(false)\n\n// 使用useGalleryData获取缩略图URL\nconst { getAssetThumbnailUrl } = useGalleryData()\n\n// 缩略图URL - 从useGalleryData中获取\nconst thumbnailUrl = computed(() => {\n  return getAssetThumbnailUrl(props.asset)\n})\n\nconst hasThumbnail = computed(() => thumbnailUrl.value.length > 0)\nconst isVideoAsset = computed(() => props.asset.type === 'video')\n\nconst showPlaceholder = computed(\n  () => isImageLoading.value || imageError.value || !hasThumbnail.value\n)\n\nconst placeholderColor = computed(() => {\n  return getAdjustedPlaceholderColor(props.asset.dominantColorHex)\n})\n\nwatch(\n  thumbnailUrl,\n  (url) => {\n    imageError.value = false\n    isImageLoading.value = url.length > 0\n  },\n  { immediate: true }\n)\n\n// 事件处理\nfunction handleClick(event: MouseEvent) {\n  emit('click', props.asset, event)\n}\n\nfunction handleDoubleClick(event: MouseEvent) {\n  emit('double-click', props.asset, event)\n}\n\nfunction handleContextMenu(event: MouseEvent) {\n  emit('context-menu', props.asset, event)\n}\n\nfunction handleDragStart(event: DragEvent) {\n  emit('drag-start', props.asset, event)\n}\n\n// 图片加载处理\nfunction onImageLoad() {\n  isImageLoading.value = false\n  imageError.value = false\n}\n\nfunction onImageError() {\n  isImageLoading.value = false\n  imageError.value = true\n}\n\nfunction clamp(value: number, min: number, max: number): number {\n  return Math.min(max, Math.max(min, value))\n}\n\nfunction mixHexColors(baseHex: string, overlayHex: string, ratio: number): string {\n  const base = normalizeToHex(baseHex, FALLBACK_PLACEHOLDER_COLOR)\n  const overlay = normalizeToHex(overlayHex, '#FFFFFF')\n  const weight = clamp(ratio, 0, 1)\n\n  const channels = [0, 2, 4].map((offset) => {\n    const baseValue = parseInt(base.slice(offset + 1, offset + 3), 16)\n    const overlayValue = parseInt(overlay.slice(offset + 1, offset + 3), 16)\n    return Math.round(baseValue * (1 - weight) + overlayValue * weight)\n  })\n\n  return `#${channels.map((value) => value.toString(16).padStart(2, '0')).join('')}`.toUpperCase()\n}\n\nfunction getAdjustedPlaceholderColor(hex?: string): string {\n  const normalized = normalizeToHex(hex ?? '', FALLBACK_PLACEHOLDER_COLOR)\n  const hsv = hexToHsv(normalized)\n\n  const adjustedHex = hsvToHex({\n    h: hsv.h,\n    s: clamp(hsv.s, 18, 52),\n    v: clamp(hsv.v, 38, 74),\n  })\n\n  return mixHexColors(adjustedHex, '#FFFFFF', 0.14)\n}\n</script>\n\n<template>\n  <div\n    data-asset-card\n    draggable=\"true\"\n    class=\"group relative w-full overflow-hidden rounded bg-background transition-all duration-200 contain-[layout_size_paint] select-none\"\n    :class=\"[\n      {\n        'ring-2 ring-primary ring-offset-2': isSelected,\n        'shadow-md hover:shadow-lg': !isSelected,\n        'shadow-lg': isSelected,\n      },\n    ]\"\n    :style=\"{ aspectRatio: props.aspectRatio }\"\n    @click=\"handleClick\"\n    @dblclick=\"handleDoubleClick\"\n    @contextmenu=\"handleContextMenu\"\n    @dragstart=\"handleDragStart\"\n  >\n    <!-- 缩略图容器 -->\n    <div data-asset-thumbnail class=\"relative h-full w-full overflow-hidden\">\n      <!-- 缩略图 -->\n      <img\n        v-if=\"hasThumbnail && !imageError\"\n        :src=\"thumbnailUrl\"\n        :alt=\"asset.name\"\n        class=\"h-full w-full object-cover transition-transform duration-200 group-hover:scale-105\"\n        @load=\"onImageLoad\"\n        @error=\"onImageError\"\n      />\n\n      <!-- 主色占位符 -->\n      <div\n        v-if=\"showPlaceholder\"\n        class=\"absolute inset-0\"\n        :style=\"{ backgroundColor: placeholderColor }\"\n      >\n        <div class=\"absolute inset-0 bg-white/24 dark:bg-black/32\" />\n        <div\n          v-if=\"isImageLoading\"\n          class=\"absolute inset-0 animate-pulse bg-gradient-to-br from-white/18 via-transparent to-black/10 dark:from-white/10 dark:to-black/18\"\n        />\n      </div>\n\n      <!-- 错误占位符 -->\n      <div\n        v-if=\"imageError\"\n        class=\"absolute inset-0 flex flex-col items-center justify-center text-white/88\"\n      >\n        <div class=\"rounded-full border border-white/25 bg-black/15 p-2 backdrop-blur-[1px]\">\n          <svg\n            xmlns=\"http://www.w3.org/2000/svg\"\n            width=\"16\"\n            height=\"16\"\n            viewBox=\"0 0 24 24\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            stroke-width=\"2\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\"\n          >\n            <path d=\"M18 6 6 18\" />\n            <path d=\"m6 6 12 12\" />\n          </svg>\n        </div>\n        <div class=\"mt-2 px-2 text-center text-xs font-medium\">加载失败</div>\n      </div>\n\n      <!-- 遮罩层 -->\n      <div\n        data-selection-mask\n        class=\"absolute inset-0 bg-black/0 transition-all duration-200\"\n        :class=\"{\n          'bg-black/20': isSelected,\n          'group-hover:bg-black/10': !isSelected,\n        }\"\n      />\n\n      <div\n        v-if=\"isVideoAsset\"\n        class=\"absolute inset-x-0 bottom-0 flex items-end justify-start bg-gradient-to-t from-black/50 via-black/10 to-transparent p-3\"\n      >\n        <div\n          class=\"flex h-8 w-8 items-center justify-center rounded-full border border-white/20 bg-black/55 text-white shadow-sm backdrop-blur-sm\"\n        >\n          <Play class=\"ml-0.5 h-4 w-4 fill-current\" />\n        </div>\n      </div>\n\n      <MediaStatusChips :rating=\"asset.rating\" :review-flag=\"asset.reviewFlag\" />\n\n      <!-- 选择指示器 -->\n      <div\n        v-if=\"isSelected\"\n        data-selection-indicator\n        class=\"absolute top-2 right-2 flex h-6 w-6 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-sm\"\n      >\n        <svg class=\"h-4 w-4\" fill=\"currentColor\" viewBox=\"0 0 20 20\">\n          <path\n            fill-rule=\"evenodd\"\n            d=\"M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z\"\n            clip-rule=\"evenodd\"\n          />\n        </svg>\n      </div>\n    </div>\n  </div>\n</template>\n"
  },
  {
    "path": "web/src/features/gallery/components/asset/AssetDetailsContent.vue",
    "content": "<script setup lang=\"ts\">\nimport { Separator } from '@/components/ui/separator'\nimport { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'\nimport { useI18n } from '@/composables/useI18n'\nimport { useToast } from '@/composables/useToast'\nimport { copyToClipboard, formatFileSize } from '@/lib/utils'\nimport type { Asset } from '../../types'\n\ninterface AssetDetailsContentProps {\n  asset: Asset\n  thumbnailUrl: string\n  /** 原视频文件 URL（静态解析）；仅 type===video 时传给 <video :src> */\n  assetUrl: string\n}\n\nconst props = defineProps<AssetDetailsContentProps>()\n\nconst { t } = useI18n()\nconst { toast } = useToast()\n\nfunction getAssetTypeLabel(type: Asset['type']): string {\n  switch (type) {\n    case 'photo':\n      return t('gallery.toolbar.filter.type.photo')\n    case 'video':\n      return t('gallery.toolbar.filter.type.video')\n    case 'live_photo':\n      return t('gallery.toolbar.filter.type.livePhoto')\n    default:\n      return t('gallery.details.assetType.unknown')\n  }\n}\n\nasync function handleCopyFileName() {\n  const success = await copyToClipboard(props.asset.name)\n  if (success) {\n    toast.success(t('gallery.details.asset.copyFileNameSuccess'))\n  } else {\n    toast.error(t('gallery.details.asset.copyFileNameFailed'))\n  }\n}\n</script>\n\n<template>\n  <div class=\"space-y-3\">\n    <div class=\"flex justify-center\">\n      <div class=\"flex h-[180px] w-full items-center justify-center rounded bg-muted/40\">\n        <video\n          v-if=\"asset.type === 'video'\"\n          :src=\"assetUrl\"\n          :poster=\"thumbnailUrl\"\n          :aria-label=\"asset.name\"\n          class=\"max-h-full max-w-full rounded object-contain shadow-md\"\n          controls\n          playsinline\n          preload=\"metadata\"\n        />\n        <img\n          v-else\n          :src=\"thumbnailUrl\"\n          :alt=\"asset.name\"\n          class=\"max-h-full max-w-full rounded object-contain shadow-md\"\n        />\n      </div>\n    </div>\n    <slot name=\"after-preview\" />\n  </div>\n\n  <Separator />\n\n  <slot name=\"before-info\" />\n\n  <div>\n    <h4 class=\"mb-2 text-sm font-medium\">{{ t('gallery.details.asset.basicInfo') }}</h4>\n    <div class=\"space-y-2 text-xs\">\n      <div class=\"flex justify-between gap-2\">\n        <span class=\"text-muted-foreground\">{{ t('gallery.details.asset.fileName') }}</span>\n        <TooltipProvider>\n          <Tooltip>\n            <TooltipTrigger as-child>\n              <button\n                type=\"button\"\n                class=\"flex-1 cursor-pointer truncate text-right font-mono transition-colors hover:text-foreground/80 focus:outline-none focus-visible:ring-1 focus-visible:ring-ring\"\n                @click=\"handleCopyFileName\"\n              >\n                {{ asset.name }}\n              </button>\n            </TooltipTrigger>\n            <TooltipContent>\n              <p class=\"max-w-80 font-mono text-xs break-all\">{{ asset.name }}</p>\n            </TooltipContent>\n          </Tooltip>\n        </TooltipProvider>\n      </div>\n      <div class=\"flex justify-between gap-2\">\n        <span class=\"text-muted-foreground\">{{ t('gallery.details.asset.type') }}</span>\n        <span class=\"rounded bg-secondary px-2 py-0.5\">{{ getAssetTypeLabel(asset.type) }}</span>\n      </div>\n      <div v-if=\"asset.width && asset.height\" class=\"flex justify-between gap-2\">\n        <span class=\"text-muted-foreground\">{{ t('gallery.details.asset.resolution') }}</span>\n        <span>{{ asset.width }} × {{ asset.height }}</span>\n      </div>\n      <div v-if=\"asset.size\" class=\"flex justify-between gap-2\">\n        <span class=\"text-muted-foreground\">{{ t('gallery.details.asset.fileSize') }}</span>\n        <span>{{ formatFileSize(asset.size) }}</span>\n      </div>\n      <div class=\"flex items-center justify-between gap-2\">\n        <span class=\"text-muted-foreground\">{{ t('gallery.details.asset.description') }}</span>\n        <div class=\"min-w-0 flex-1\">\n          <slot name=\"description\">\n            <p class=\"rounded bg-muted/50 px-2 py-1.5 text-xs break-words\">\n              {{ asset.description }}\n            </p>\n          </slot>\n        </div>\n      </div>\n    </div>\n  </div>\n\n  <slot name=\"after-info\" />\n</template>\n"
  },
  {
    "path": "web/src/features/gallery/components/asset/AssetHistogram.vue",
    "content": "<script setup lang=\"ts\">\nimport { onBeforeUnmount, onMounted, ref, watch, shallowRef, nextTick } from 'vue'\nimport { useI18n } from '@/composables/useI18n'\n\ninterface AssetHistogramProps {\n  cacheKey: string\n  imageUrl: string\n}\n\ninterface HistogramData {\n  red: number[]\n  green: number[]\n  blue: number[]\n  maxValue: number\n}\n\nconst props = defineProps<AssetHistogramProps>()\n\nconst { t } = useI18n()\n\n// 组件级缓存：详情面板在同一会话内反复切回同一张图时，不再重复计算。\nconst histogramCache = new Map<string, HistogramData>()\n\nconst histogram = shallowRef<HistogramData | null>(null)\nconst hasError = ref(false)\nconst canvasRef = ref<HTMLCanvasElement | null>(null)\nconst themeVersion = ref(0)\n\n// 动画与渲染状态\nlet animationFrameId: number | null = null\nlet previousHistogram: HistogramData | null = null\nlet themeObserver: MutationObserver | null = null\n\n// 通过递增任务编号丢弃过期结果，避免用户快速切图时旧结果覆盖新图。\nlet currentJobId = 0\n\nfunction createBins(): number[] {\n  return Array.from({ length: 256 }, () => 0)\n}\n\nfunction computeHistogram(imageData: ImageData): HistogramData {\n  const red = createBins()\n  const green = createBins()\n  const blue = createBins()\n\n  const pixels = imageData.data\n\n  for (let index = 0; index < pixels.length; index += 4) {\n    const alpha = pixels[index + 3] ?? 0\n    if (alpha === 0) {\n      continue\n    }\n\n    const r = pixels[index] ?? 0\n    const g = pixels[index + 1] ?? 0\n    const b = pixels[index + 2] ?? 0\n\n    red[r] = (red[r] ?? 0) + 1\n    green[g] = (green[g] ?? 0) + 1\n    blue[b] = (blue[b] ?? 0) + 1\n  }\n\n  let maxValue = 1\n  for (let index = 0; index < 256; index += 1) {\n    maxValue = Math.max(maxValue, red[index] ?? 0, green[index] ?? 0, blue[index] ?? 0)\n  }\n\n  // 稍微放大一点 maxValue，让顶部留白，显得更优雅\n  maxValue = maxValue * 1.05\n\n  return { red, green, blue, maxValue }\n}\n\nfunction isDarkTheme(): boolean {\n  if (typeof document === 'undefined') {\n    return true\n  }\n\n  return document.documentElement.classList.contains('dark')\n}\n\nfunction getHistogramPalette() {\n  if (isDarkTheme()) {\n    return {\n      red: 'rgba(248, 113, 113, 1)', // red-400 — 粉红调\n      green: 'rgba(110, 231, 183, 1)', // emerald-300 — 冷青绿，避免荧光感\n      blue: 'rgba(96, 165, 250, 1)', // blue-400 — 天蓝调\n      compositeOperation: 'screen' as GlobalCompositeOperation,\n      fillAlpha: 0.7,\n    }\n  }\n\n  return {\n    red: 'rgba(236, 132, 136, 1)', // tuned for light theme neutral overlap\n    green: 'rgba(116, 196, 150, 1)', // reduce green cast after multiply stacking\n    blue: 'rgba(122, 164, 232, 1)', // keep cool balance without cyan overflow\n    compositeOperation: 'multiply' as GlobalCompositeOperation,\n    fillAlpha: 0.7,\n  }\n}\n\nfunction waitForAnimationFrame(): Promise<void> {\n  return new Promise((resolve) => window.requestAnimationFrame(() => resolve()))\n}\n\nfunction loadImage(url: string): Promise<HTMLImageElement> {\n  return new Promise((resolve, reject) => {\n    const image = new Image()\n    let settled = false\n\n    const cleanup = () => {\n      image.onload = null\n      image.onerror = null\n    }\n\n    image.decoding = 'async'\n    image.crossOrigin = 'anonymous'\n    image.onload = () => {\n      if (settled) {\n        return\n      }\n      settled = true\n      cleanup()\n      resolve(image)\n    }\n    image.onerror = () => {\n      if (settled) {\n        return\n      }\n      settled = true\n      cleanup()\n      reject(new Error(`Failed to load histogram image: ${url}`))\n    }\n    image.src = url\n\n    if (image.complete && image.naturalWidth > 0) {\n      settled = true\n      cleanup()\n      resolve(image)\n    }\n  })\n}\n\nasync function refreshHistogram() {\n  const cacheKey = props.cacheKey.trim()\n  const imageUrl = props.imageUrl.trim()\n  const jobId = ++currentJobId\n\n  if (!cacheKey || !imageUrl) {\n    histogram.value = null\n    hasError.value = false\n    return\n  }\n\n  const cached = histogramCache.get(cacheKey)\n  if (cached) {\n    histogram.value = cached\n    hasError.value = false\n    return\n  }\n\n  histogram.value = null\n  hasError.value = false\n\n  try {\n    // 先让详情面板完成本轮渲染，再开始读图像像素，避免切图瞬间抢占主线程。\n    await waitForAnimationFrame()\n    const image = await loadImage(imageUrl)\n\n    if (jobId !== currentJobId) {\n      return\n    }\n\n    const canvas = document.createElement('canvas')\n    canvas.width = image.naturalWidth || image.width\n    canvas.height = image.naturalHeight || image.height\n\n    const context = canvas.getContext('2d', { willReadFrequently: true })\n    if (!context || canvas.width <= 0 || canvas.height <= 0) {\n      throw new Error('Canvas context unavailable for histogram rendering')\n    }\n\n    // 这里直接基于已有缩略图统计，不再额外二次缩放，保持实现路径最短。\n    context.drawImage(image, 0, 0, canvas.width, canvas.height)\n    const imageData = context.getImageData(0, 0, canvas.width, canvas.height)\n\n    if (jobId !== currentJobId) {\n      return\n    }\n\n    const nextHistogram = computeHistogram(imageData)\n    histogramCache.set(cacheKey, nextHistogram)\n    histogram.value = nextHistogram\n  } catch (error) {\n    if (jobId !== currentJobId) {\n      return\n    }\n\n    console.error('Failed to compute asset histogram:', error)\n    histogram.value = null\n    hasError.value = true\n  }\n}\n\nfunction drawHistogram(canvas: HTMLCanvasElement, data: HistogramData) {\n  const ctx = canvas.getContext('2d')\n  if (!ctx) return\n\n  // 高分屏适配\n  const rect = canvas.getBoundingClientRect()\n  const { width, height } = rect\n  if (width === 0 || height === 0) return\n\n  const dpr = window.devicePixelRatio || 1\n\n  canvas.width = width * dpr\n  canvas.height = height * dpr\n  ctx.scale(dpr, dpr)\n  // 不再直接设置内联 style 的宽高，这会受到 v-show (display: none) 时的影响导致宽高变为 0px 无法恢复。\n  // 通过 CSS class (h-full w-full) 控制元素的渲染尺寸。\n\n  ctx.clearRect(0, 0, width, height)\n\n  const { maxValue } = data\n  if (maxValue <= 0) return\n  const normalizedMaxValue = Math.sqrt(maxValue)\n  if (normalizedMaxValue <= 0) return\n\n  const chartWidth = width\n  const chartHeight = height\n\n  const palette = getHistogramPalette()\n\n  // 使用原始 bins 直接绘制，并对纵轴做平方根压缩，避免尖峰把整张图压扁。\n  const drawArea = (bins: number[], color: string) => {\n    const getY = (value: number) => {\n      return chartHeight - (Math.sqrt(Math.max(0, value)) / normalizedMaxValue) * chartHeight\n    }\n\n    ctx.beginPath()\n    ctx.moveTo(0, chartHeight)\n\n    const step = chartWidth / (bins.length - 1)\n\n    // 起点\n    ctx.lineTo(0, getY(bins[0] ?? 0))\n\n    for (let i = 1; i < bins.length; i += 1) {\n      ctx.lineTo(i * step, getY(bins[i] ?? 0))\n    }\n\n    ctx.lineTo(chartWidth, chartHeight)\n    ctx.closePath()\n\n    const fillColor = color.replace(/[\\d.]+\\)$/, `${palette.fillAlpha})`)\n    ctx.fillStyle = fillColor\n    ctx.fill()\n  }\n\n  ctx.globalCompositeOperation = palette.compositeOperation\n  drawArea(data.red, palette.red)\n  drawArea(data.green, palette.green)\n  drawArea(data.blue, palette.blue)\n  ctx.globalCompositeOperation = 'source-over'\n}\n\nwatch(histogram, async (newData) => {\n  if (!newData || !canvasRef.value) return\n\n  await nextTick() // 等待 DOM 渲染完毕，获取到带有具体宽高（而非由于 v-show 或隐式隐藏导致 0px）的元素\n\n  const canvas = canvasRef.value\n  if (!canvas) return\n\n  if (animationFrameId) {\n    cancelAnimationFrame(animationFrameId)\n    animationFrameId = null\n  }\n\n  if (!previousHistogram) {\n    drawHistogram(canvas, newData)\n    previousHistogram = newData\n    return\n  }\n\n  const startAt = performance.now()\n  const prev = previousHistogram\n\n  // Spring animation parameters\n  const frequency = 8 // rad/s\n  const damping = 7\n  const maxMs = 1200\n  const restDelta = 0.001\n\n  const springProgress = (tSec: number) => {\n    const w = frequency\n    const d = damping\n    const exp = Math.exp(-d * tSec)\n    const value = 1 - exp * (Math.cos(w * tSec) + (d / w) * Math.sin(w * tSec))\n    return Math.max(0, Math.min(1, value))\n  }\n\n  const lerpArray = (from: number[], to: number[], p: number) =>\n    from.map((v, i) => v + ((to[i] ?? 0) - v) * p)\n\n  const frame = (now: number) => {\n    const elapsedMs = now - startAt\n    const tSec = elapsedMs / 1000\n    const eased = springProgress(tSec)\n\n    const interpolated: HistogramData = {\n      red: lerpArray(prev.red, newData.red, eased),\n      green: lerpArray(prev.green, newData.green, eased),\n      blue: lerpArray(prev.blue, newData.blue, eased),\n      maxValue: prev.maxValue + (newData.maxValue - prev.maxValue) * eased,\n    }\n\n    drawHistogram(canvas, interpolated)\n\n    const done = Math.abs(1 - eased) < restDelta || elapsedMs >= maxMs\n    if (!done) {\n      animationFrameId = requestAnimationFrame(frame)\n    } else {\n      previousHistogram = newData\n      animationFrameId = null\n      // 最后一帧确保准确\n      drawHistogram(canvas, newData)\n    }\n  }\n\n  animationFrameId = requestAnimationFrame(frame)\n})\n\nwatch(themeVersion, async () => {\n  if (!histogram.value || !canvasRef.value) return\n\n  if (animationFrameId) {\n    cancelAnimationFrame(animationFrameId)\n    animationFrameId = null\n  }\n\n  await nextTick()\n  drawHistogram(canvasRef.value, histogram.value)\n})\n\nwatch(\n  () => [props.cacheKey, props.imageUrl] as const,\n  () => void refreshHistogram(),\n  {\n    immediate: true,\n  }\n)\n\nonMounted(() => {\n  if (typeof document === 'undefined') {\n    return\n  }\n\n  themeObserver = new MutationObserver(() => {\n    themeVersion.value += 1\n  })\n  themeObserver.observe(document.documentElement, {\n    attributes: true,\n    attributeFilter: ['class', 'style'],\n  })\n})\n\nonBeforeUnmount(() => {\n  currentJobId += 1\n  if (animationFrameId) {\n    cancelAnimationFrame(animationFrameId)\n    animationFrameId = null\n  }\n  themeObserver?.disconnect()\n  themeObserver = null\n})\n</script>\n\n<template>\n  <div class=\"space-y-3\">\n    <div class=\"flex items-center justify-between gap-2\">\n      <h4 class=\"text-sm font-medium\">{{ t('gallery.details.histogram.title') }}</h4>\n    </div>\n\n    <div\n      class=\"group relative h-28 w-full overflow-hidden rounded-md border [border-color:inherit] bg-transparent shadow-none transition-all duration-300\"\n    >\n      <!-- Canvas 渲染层 -->\n      <canvas\n        ref=\"canvasRef\"\n        class=\"absolute inset-0 h-full w-full transition-opacity duration-500\"\n        :class=\"histogram && !hasError ? 'opacity-100' : 'opacity-0'\"\n      />\n\n      <div\n        v-if=\"hasError || !histogram\"\n        class=\"absolute inset-0 flex items-center justify-center px-4 text-center text-xs text-muted-foreground\"\n      >\n        {{\n          hasError\n            ? t('gallery.details.histogram.unavailable')\n            : t('gallery.details.histogram.empty')\n        }}\n      </div>\n    </div>\n  </div>\n</template>\n"
  },
  {
    "path": "web/src/features/gallery/components/asset/AssetListRow.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed, ref, watch } from 'vue'\nimport { Play } from 'lucide-vue-next'\nimport { formatFileSize } from '@/lib/utils'\nimport { useGalleryData } from '../../composables/useGalleryData'\nimport type { Asset } from '../../types'\n\ninterface AssetListRowProps {\n  asset: Asset\n  isSelected?: boolean\n  rowHeight: number\n  thumbnailSize: number\n  columnsTemplate: string\n}\n\nconst props = withDefaults(defineProps<AssetListRowProps>(), {\n  isSelected: false,\n})\n\nconst emit = defineEmits<{\n  click: [asset: Asset, event: MouseEvent]\n  'double-click': [asset: Asset, event: MouseEvent]\n  'context-menu': [asset: Asset, event: MouseEvent]\n  'drag-start': [asset: Asset, event: DragEvent]\n}>()\n\nconst { getAssetThumbnailUrl } = useGalleryData()\n\nconst imageError = ref(false)\n\nconst thumbnailUrl = computed(() => getAssetThumbnailUrl(props.asset))\nconst hasThumbnail = computed(() => thumbnailUrl.value.length > 0)\nconst isVideoAsset = computed(() => props.asset.type === 'video')\n// 文件类型标签：优先显示扩展名，其次 MIME 子类型，最后回退到 type 字段\nconst fileTypeLabel = computed(() => {\n  const extension = props.asset.extension?.replace(/^\\./, '').trim().toLowerCase()\n  if (extension) {\n    return extension\n  }\n\n  const mimeSubtype = props.asset.mimeType?.split('/')[1]?.trim().toLowerCase()\n  if (mimeSubtype) {\n    return mimeSubtype\n  }\n\n  return props.asset.type\n})\nconst resolutionLabel = computed(() => {\n  if (!props.asset.width || !props.asset.height) {\n    return '-'\n  }\n\n  return `${props.asset.width}×${props.asset.height}`\n})\nconst fileSizeLabel = computed(() => formatFileSize(props.asset.size))\n\n// thumbnailUrl 变化时（如切换资产）重置图片加载错误状态\nwatch(\n  thumbnailUrl,\n  () => {\n    imageError.value = false\n  },\n  { immediate: true }\n)\n\nfunction onImageError() {\n  imageError.value = true\n}\n\nfunction handleClick(event: MouseEvent) {\n  emit('click', props.asset, event)\n}\n\nfunction handleDoubleClick(event: MouseEvent) {\n  emit('double-click', props.asset, event)\n}\n\nfunction handleContextMenu(event: MouseEvent) {\n  emit('context-menu', props.asset, event)\n}\n\nfunction handleDragStart(event: DragEvent) {\n  emit('drag-start', props.asset, event)\n}\n</script>\n\n<template>\n  <div\n    data-asset-list-row\n    draggable=\"true\"\n    class=\"group grid w-full items-center gap-3 rounded-sm px-3 transition-colors select-none\"\n    :class=\"\n      props.isSelected ? 'bg-primary text-primary-foreground' : 'text-foreground hover:bg-muted/55'\n    \"\n    :style=\"{\n      gridTemplateColumns: props.columnsTemplate,\n      height: `${props.rowHeight}px`,\n    }\"\n    @click=\"handleClick\"\n    @dblclick=\"handleDoubleClick\"\n    @contextmenu=\"handleContextMenu\"\n    @dragstart=\"handleDragStart\"\n  >\n    <div class=\"flex items-center justify-center\">\n      <div\n        data-asset-thumbnail\n        class=\"relative overflow-hidden rounded-sm border border-border/50 bg-muted\"\n        :style=\"{\n          width: `${props.thumbnailSize}px`,\n          height: `${props.thumbnailSize}px`,\n          backgroundColor: props.asset.dominantColorHex || undefined,\n        }\"\n      >\n        <img\n          v-if=\"hasThumbnail && !imageError\"\n          :src=\"thumbnailUrl\"\n          :alt=\"asset.name\"\n          class=\"h-full w-full object-cover\"\n          @error=\"onImageError\"\n        />\n        <div v-else class=\"absolute inset-0 bg-white/20 dark:bg-black/20\" />\n        <div\n          v-if=\"isVideoAsset\"\n          class=\"absolute right-1 bottom-1 flex h-4 w-4 items-center justify-center rounded-full bg-black/60 text-white\"\n        >\n          <Play class=\"ml-[1px] h-2.5 w-2.5 fill-current\" />\n        </div>\n      </div>\n    </div>\n\n    <div class=\"truncate text-sm\" :title=\"asset.name\">\n      {{ asset.name }}\n    </div>\n    <div class=\"truncate text-sm opacity-85\">\n      {{ fileTypeLabel }}\n    </div>\n    <div class=\"truncate text-sm opacity-85\">\n      {{ resolutionLabel }}\n    </div>\n    <div class=\"truncate text-right text-sm opacity-85\">\n      {{ fileSizeLabel }}\n    </div>\n  </div>\n</template>\n"
  },
  {
    "path": "web/src/features/gallery/components/asset/AssetReviewControls.vue",
    "content": "<script setup lang=\"ts\">\nimport { ref } from 'vue'\nimport { Star, X } from 'lucide-vue-next'\nimport { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'\nimport { useI18n } from '@/composables/useI18n'\nimport type { ReviewFlag } from '../../types'\n\nconst props = defineProps<{\n  rating: number\n  reviewFlag: ReviewFlag\n  ratingIndeterminate?: boolean\n  flagIndeterminate?: boolean\n}>()\n\nconst emit = defineEmits<{\n  setRating: [rating: number]\n  clearRating: []\n  setFlag: [flag: ReviewFlag]\n  clearFlag: []\n}>()\n\nconst { t } = useI18n()\n\nconst hoverRating = ref(0)\n\nfunction onStarClick(star: number) {\n  if (!props.ratingIndeterminate && props.rating === star) {\n    emit('clearRating')\n  } else {\n    emit('setRating', star)\n  }\n}\n\nfunction onRejectedClick() {\n  if (!props.flagIndeterminate && props.reviewFlag === 'rejected') {\n    emit('clearFlag')\n  } else {\n    emit('setFlag', 'rejected')\n  }\n}\n\nconst STARS = [1, 2, 3, 4, 5] as const\n</script>\n\n<template>\n  <TooltipProvider>\n    <div class=\"flex items-center justify-between gap-3\">\n      <div class=\"flex items-center gap-0.5\">\n        <button\n          v-for=\"star in STARS\"\n          :key=\"star\"\n          type=\"button\"\n          class=\"rounded p-0.5 transition-colors\"\n          :title=\"`${star} ${t('gallery.details.review.starLabel')}`\"\n          @mouseenter=\"hoverRating = star\"\n          @mouseleave=\"hoverRating = 0\"\n          @click=\"onStarClick(star)\"\n        >\n          <Star\n            class=\"h-4 w-4 transition-colors\"\n            :class=\"\n              hoverRating > 0\n                ? star <= hoverRating\n                  ? 'fill-amber-400 text-amber-400'\n                  : 'text-muted-foreground/30'\n                : !ratingIndeterminate && star <= rating\n                  ? 'fill-amber-400 text-amber-400'\n                  : 'text-muted-foreground/30'\n            \"\n          />\n        </button>\n      </div>\n\n      <Tooltip>\n        <TooltipTrigger as-child>\n          <button\n            type=\"button\"\n            class=\"rounded p-1 transition-colors\"\n            :class=\"\n              !flagIndeterminate && reviewFlag === 'rejected'\n                ? 'text-rose-500'\n                : 'text-muted-foreground hover:text-foreground'\n            \"\n            @click=\"onRejectedClick\"\n          >\n            <X class=\"h-4 w-4\" />\n          </button>\n        </TooltipTrigger>\n        <TooltipContent>\n          {{\n            !flagIndeterminate && reviewFlag === 'rejected'\n              ? t('gallery.details.review.clearFlag')\n              : t('gallery.review.flag.rejected')\n          }}\n        </TooltipContent>\n      </Tooltip>\n    </div>\n  </TooltipProvider>\n</template>\n"
  },
  {
    "path": "web/src/features/gallery/components/asset/MediaStatusChips.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed } from 'vue'\nimport { Star, X } from 'lucide-vue-next'\nimport type { ReviewFlag } from '../../types'\n\ninterface MediaStatusChipsProps {\n  rating?: number\n  reviewFlag?: ReviewFlag\n  compact?: boolean\n}\n\nconst props = withDefaults(defineProps<MediaStatusChipsProps>(), {\n  rating: 0,\n  reviewFlag: 'none',\n  compact: false,\n})\n\nconst hasRating = computed(() => (props.rating ?? 0) > 0)\nconst isRejected = computed(() => props.reviewFlag === 'rejected')\n</script>\n\n<template>\n  <div class=\"pointer-events-none absolute inset-0\">\n    <div\n      v-if=\"hasRating\"\n      class=\"absolute flex items-center gap-1 rounded-md border text-white transition-opacity duration-150\"\n      :class=\"\n        compact\n          ? 'top-1 left-1 border-white/12 bg-black/45 px-1.5 py-0.5 text-[10px]'\n          : 'top-2 left-2 border-white/15 bg-black/45 px-2 py-1 text-[11px]'\n      \"\n    >\n      <Star\n        class=\"shrink-0 fill-current text-current\"\n        :class=\"compact ? 'h-2.5 w-2.5' : 'h-3 w-3'\"\n      />\n      <span class=\"font-medium\">{{ rating }}</span>\n    </div>\n\n    <!-- filmstrip：右下小方角标，仅 X -->\n    <div\n      v-if=\"isRejected && compact\"\n      class=\"absolute right-1 bottom-1 flex h-4 w-4 items-center justify-center rounded-sm border border-white/20 bg-black/50 text-current shadow-sm backdrop-blur-sm\"\n    >\n      <X class=\"h-3 w-3 stroke-[3] text-rose-400\" />\n    </div>\n\n    <!-- 主卡片：胶囊 + X + 文案 -->\n    <div\n      v-if=\"isRejected && !compact\"\n      class=\"absolute right-2 bottom-2 flex items-center gap-1 rounded-md border border-white/15 bg-black/50 px-2 py-1 text-[11px] text-white transition-opacity duration-150\"\n    >\n      <X class=\"h-3.5 w-3.5 shrink-0 text-rose-400\" />\n      <span class=\"font-medium\">弃置</span>\n    </div>\n  </div>\n</template>\n"
  },
  {
    "path": "web/src/features/gallery/components/dialogs/GalleryScanDialog.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed, ref, watch } from 'vue'\nimport { call } from '@/core/rpc'\nimport { isWebView } from '@/core/env'\nimport { useI18n } from '@/composables/useI18n'\nimport { useToast } from '@/composables/useToast'\nimport { useGalleryData } from '../../composables/useGalleryData'\nimport type { ScanAssetsParams, ScanIgnoreRule } from '../../types'\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from '@/components/ui/dialog'\nimport { Button } from '@/components/ui/button'\nimport { Input } from '@/components/ui/input'\nimport { Label } from '@/components/ui/label'\nimport { ScrollArea } from '@/components/ui/scroll-area'\nimport { Switch } from '@/components/ui/switch'\nimport { Textarea } from '@/components/ui/textarea'\nimport { ChevronDown, ChevronUp, Loader2, Plus, Trash2 } from 'lucide-vue-next'\n\ninterface Props {\n  open: boolean\n  preset?: Partial<ScanAssetsParams>\n}\n\ninterface FormIgnoreRule {\n  id: number\n  pattern: string\n  patternType: 'glob' | 'regex'\n  ruleType: 'exclude' | 'include'\n  description: string\n}\n\nconst props = defineProps<Props>()\nconst emit = defineEmits<{\n  'update:open': [value: boolean]\n}>()\n\n// 与后端 ScanCommon::default_supported_extensions 保持一致，避免 UI 默认与可扫范围脱节。\nconst defaultSupportedExtensions = [\n  '.jpg',\n  '.jpeg',\n  '.png',\n  '.bmp',\n  '.webp',\n  '.tiff',\n  '.tif',\n  '.mp4',\n  '.avi',\n  '.mov',\n  '.mkv',\n  '.wmv',\n  '.webm',\n]\n\nconst galleryData = useGalleryData()\nconst { toast } = useToast()\nconst { t } = useI18n()\n\nconst isSelectingScanDirectory = ref(false)\nconst isSubmittingScanTask = ref(false)\nconst showAdvancedOptions = ref(false)\nconst scanDirectory = ref('')\nconst generateThumbnails = ref(true)\nconst thumbnailShortEdge = ref(480)\nconst supportedExtensionsText = ref(defaultSupportedExtensions.join(', '))\nconst ignoreRules = ref<FormIgnoreRule[]>([])\nconst nextIgnoreRuleId = ref(1)\n\nconst canSubmitAddFolder = computed(() => {\n  return scanDirectory.value.trim().length > 0 && !isSubmittingScanTask.value\n})\n\nfunction toFormIgnoreRules(rules: ScanIgnoreRule[] | undefined): FormIgnoreRule[] {\n  if (!rules || rules.length === 0) {\n    return []\n  }\n\n  return rules.map((rule, index) => ({\n    id: index + 1,\n    pattern: rule.pattern,\n    patternType: rule.patternType ?? 'regex',\n    ruleType: rule.ruleType ?? 'exclude',\n    description: rule.description ?? '',\n  }))\n}\n\nfunction resetForm() {\n  scanDirectory.value = ''\n  generateThumbnails.value = true\n  thumbnailShortEdge.value = 480\n  supportedExtensionsText.value = defaultSupportedExtensions.join(', ')\n  ignoreRules.value = []\n  nextIgnoreRuleId.value = 1\n  showAdvancedOptions.value = false\n}\n\nfunction initializeFormFromPreset() {\n  resetForm()\n\n  if (props.preset?.directory) {\n    scanDirectory.value = props.preset.directory\n  }\n  if (props.preset?.generateThumbnails !== undefined) {\n    generateThumbnails.value = props.preset.generateThumbnails\n  }\n  if (props.preset?.thumbnailShortEdge !== undefined) {\n    thumbnailShortEdge.value = props.preset.thumbnailShortEdge\n  }\n  if (props.preset?.supportedExtensions && props.preset.supportedExtensions.length > 0) {\n    supportedExtensionsText.value = props.preset.supportedExtensions.join(', ')\n  }\n\n  ignoreRules.value = toFormIgnoreRules(props.preset?.ignoreRules)\n  nextIgnoreRuleId.value = ignoreRules.value.length + 1\n  showAdvancedOptions.value = false\n}\n\nwatch(\n  () => props.open,\n  (open) => {\n    if (open) {\n      initializeFormFromPreset()\n      return\n    }\n    if (!isSubmittingScanTask.value) {\n      resetForm()\n    }\n  }\n)\n\nfunction handleDialogOpenChange(open: boolean) {\n  if (!open && isSubmittingScanTask.value) {\n    return\n  }\n  emit('update:open', open)\n}\n\nfunction addIgnoreRule() {\n  ignoreRules.value.push({\n    id: nextIgnoreRuleId.value++,\n    pattern: '',\n    patternType: 'regex',\n    ruleType: 'exclude',\n    description: '',\n  })\n}\n\nfunction removeIgnoreRule(ruleId: number) {\n  ignoreRules.value = ignoreRules.value.filter((rule) => rule.id !== ruleId)\n}\n\nfunction parseSupportedExtensions(input: string): string[] | undefined {\n  const tokens = input.split(/[\\s,;，；\\n]+/).map((token) => token.trim())\n\n  const normalized: string[] = []\n  const seen = new Set<string>()\n\n  for (const token of tokens) {\n    if (!token) {\n      continue\n    }\n\n    const extension = (token.startsWith('.') ? token : `.${token}`).toLowerCase()\n    if (extension.length <= 1 || seen.has(extension)) {\n      continue\n    }\n\n    seen.add(extension)\n    normalized.push(extension)\n  }\n\n  return normalized.length > 0 ? normalized : undefined\n}\n\nfunction buildScanIgnoreRules(): ScanIgnoreRule[] | undefined {\n  const rules = ignoreRules.value.reduce<ScanIgnoreRule[]>((acc, rule) => {\n    const pattern = rule.pattern.trim()\n    if (!pattern) {\n      return acc\n    }\n\n    acc.push({\n      pattern,\n      patternType: rule.patternType,\n      ruleType: rule.ruleType,\n      description: rule.description.trim() || undefined,\n    })\n\n    return acc\n  }, [])\n\n  return rules.length > 0 ? rules : undefined\n}\n\nasync function handleSelectScanDirectory() {\n  isSelectingScanDirectory.value = true\n\n  try {\n    const parentWindowMode = isWebView() ? 1 : 2\n    const result = await call<{ path: string }>(\n      'dialog.openDirectory',\n      {\n        title: t('gallery.sidebar.scan.selectDialogTitle'),\n        parentWindowMode,\n      },\n      0\n    )\n\n    if (result.path) {\n      scanDirectory.value = result.path\n    }\n  } catch (error) {\n    const message = error instanceof Error ? error.message : String(error)\n    if (message.toLowerCase().includes('cancel')) {\n      return\n    }\n\n    toast.error(t('gallery.sidebar.scan.selectDirectoryFailed'), { description: message })\n  } finally {\n    isSelectingScanDirectory.value = false\n  }\n}\n\nasync function handleImportAlbum() {\n  const directory = scanDirectory.value.trim()\n  if (!directory) {\n    toast.error(t('gallery.sidebar.scan.selectDirectoryRequired'))\n    return\n  }\n\n  isSubmittingScanTask.value = true\n  const loadingToastId = toast.loading(t('gallery.sidebar.scan.submitting'))\n\n  try {\n    const scanParams: ScanAssetsParams = {\n      directory,\n      generateThumbnails: generateThumbnails.value,\n      thumbnailShortEdge: thumbnailShortEdge.value,\n      supportedExtensions: parseSupportedExtensions(supportedExtensionsText.value),\n      ignoreRules: buildScanIgnoreRules(),\n    }\n\n    const result = await galleryData.startScanAssets(scanParams)\n\n    toast.dismiss(loadingToastId)\n    toast.success(t('gallery.sidebar.scan.queuedTitle'), {\n      description: t('gallery.sidebar.scan.queuedDescription', {\n        taskId: result.taskId,\n      }),\n    })\n\n    emit('update:open', false)\n    resetForm()\n  } catch (error) {\n    toast.dismiss(loadingToastId)\n    const message = error instanceof Error ? error.message : String(error)\n    toast.error(t('gallery.sidebar.scan.failedTitle'), { description: message })\n  } finally {\n    isSubmittingScanTask.value = false\n  }\n}\n</script>\n\n<template>\n  <Dialog :open=\"open\" @update:open=\"handleDialogOpenChange\">\n    <DialogContent class=\"overflow-hidden p-0 sm:max-w-[720px]\" :show-close-button=\"false\">\n      <div class=\"flex h-full max-h-[85vh] flex-col\">\n        <DialogHeader class=\"px-6 pt-6 pb-3\">\n          <DialogTitle>{{ t('gallery.sidebar.scan.dialogTitle') }}</DialogTitle>\n          <DialogDescription>\n            {{ t('gallery.sidebar.scan.dialogDescription') }}\n          </DialogDescription>\n        </DialogHeader>\n\n        <ScrollArea class=\"min-h-0 flex-1 px-6\">\n          <div class=\"space-y-4 pb-4\">\n            <div class=\"space-y-2\">\n              <Label for=\"scan-directory\">{{ t('gallery.sidebar.scan.directoryLabel') }}</Label>\n              <div class=\"flex items-center gap-2\">\n                <Input\n                  id=\"scan-directory\"\n                  v-model=\"scanDirectory\"\n                  :placeholder=\"t('gallery.sidebar.scan.directoryPlaceholder')\"\n                  readonly\n                />\n                <Button\n                  variant=\"outline\"\n                  :disabled=\"isSelectingScanDirectory || isSubmittingScanTask\"\n                  @click=\"handleSelectScanDirectory\"\n                >\n                  <Loader2 v-if=\"isSelectingScanDirectory\" class=\"mr-2 h-4 w-4 animate-spin\" />\n                  {{\n                    isSelectingScanDirectory\n                      ? t('gallery.sidebar.scan.selectingDirectory')\n                      : t('gallery.sidebar.scan.selectDirectory')\n                  }}\n                </Button>\n              </div>\n            </div>\n\n            <button\n              type=\"button\"\n              class=\"flex w-full items-center justify-between rounded-md border px-3 py-2 text-left text-sm transition-colors hover:bg-accent/40\"\n              @click=\"showAdvancedOptions = !showAdvancedOptions\"\n            >\n              <span>{{ t('gallery.sidebar.scan.advancedOptions') }}</span>\n              <ChevronUp v-if=\"showAdvancedOptions\" class=\"h-4 w-4\" />\n              <ChevronDown v-else class=\"h-4 w-4\" />\n            </button>\n\n            <div v-if=\"showAdvancedOptions\" class=\"space-y-4 rounded-md border p-3\">\n              <div class=\"flex items-center justify-between rounded-md border p-3\">\n                <div class=\"space-y-1\">\n                  <Label>{{ t('gallery.sidebar.scan.generateThumbnails') }}</Label>\n                  <p class=\"text-xs text-muted-foreground\">\n                    {{ t('gallery.sidebar.scan.generateThumbnailsHint') }}\n                  </p>\n                </div>\n                <Switch\n                  :model-value=\"generateThumbnails\"\n                  @update:model-value=\"generateThumbnails = Boolean($event)\"\n                />\n              </div>\n\n              <div class=\"space-y-2\">\n                <Label for=\"thumbnail-short-edge\">{{\n                  t('gallery.sidebar.scan.thumbnailShortEdge')\n                }}</Label>\n                <Input\n                  id=\"thumbnail-short-edge\"\n                  v-model.number=\"thumbnailShortEdge\"\n                  type=\"number\"\n                  :min=\"64\"\n                  :max=\"4096\"\n                  :disabled=\"!generateThumbnails\"\n                />\n              </div>\n\n              <div class=\"space-y-2\">\n                <Label for=\"supported-extensions\">{{\n                  t('gallery.sidebar.scan.supportedExtensions')\n                }}</Label>\n                <Textarea\n                  id=\"supported-extensions\"\n                  v-model=\"supportedExtensionsText\"\n                  :rows=\"3\"\n                  placeholder=\".jpg, .jpeg, .png\"\n                />\n                <p class=\"text-xs text-muted-foreground\">\n                  {{ t('gallery.sidebar.scan.supportedExtensionsHint') }}\n                </p>\n              </div>\n\n              <div class=\"space-y-2\">\n                <div class=\"flex items-center justify-between\">\n                  <Label>{{ t('gallery.sidebar.scan.ignoreRules') }}</Label>\n                  <Button type=\"button\" variant=\"outline\" size=\"sm\" @click=\"addIgnoreRule\">\n                    <Plus class=\"mr-1 h-3 w-3\" />\n                    {{ t('gallery.sidebar.scan.addRule') }}\n                  </Button>\n                </div>\n\n                <div\n                  v-if=\"ignoreRules.length === 0\"\n                  class=\"rounded-md border border-dashed p-3 text-xs text-muted-foreground\"\n                >\n                  {{ t('gallery.sidebar.scan.noRules') }}\n                </div>\n\n                <div\n                  v-for=\"rule in ignoreRules\"\n                  :key=\"rule.id\"\n                  class=\"space-y-3 rounded-md border p-3\"\n                >\n                  <div class=\"flex items-start justify-between gap-2\">\n                    <div class=\"grid flex-1 gap-3 sm:grid-cols-2\">\n                      <div class=\"space-y-2 sm:col-span-2\">\n                        <Label>{{ t('gallery.sidebar.scan.rulePattern') }}</Label>\n                        <Input\n                          v-model=\"rule.pattern\"\n                          :placeholder=\"t('gallery.sidebar.scan.rulePatternPlaceholder')\"\n                        />\n                      </div>\n\n                      <div class=\"space-y-2\">\n                        <Label>{{ t('gallery.sidebar.scan.patternType') }}</Label>\n                        <select\n                          v-model=\"rule.patternType\"\n                          class=\"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50\"\n                        >\n                          <option value=\"regex\">\n                            {{ t('gallery.sidebar.scan.patternTypeRegex') }}\n                          </option>\n                          <option value=\"glob\">\n                            {{ t('gallery.sidebar.scan.patternTypeGlob') }}\n                          </option>\n                        </select>\n                      </div>\n\n                      <div class=\"space-y-2\">\n                        <Label>{{ t('gallery.sidebar.scan.ruleType') }}</Label>\n                        <select\n                          v-model=\"rule.ruleType\"\n                          class=\"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50\"\n                        >\n                          <option value=\"exclude\">\n                            {{ t('gallery.sidebar.scan.ruleTypeExclude') }}\n                          </option>\n                          <option value=\"include\">\n                            {{ t('gallery.sidebar.scan.ruleTypeInclude') }}\n                          </option>\n                        </select>\n                      </div>\n\n                      <div class=\"space-y-2 sm:col-span-2\">\n                        <Label>{{ t('gallery.sidebar.scan.ruleDescription') }}</Label>\n                        <Input\n                          v-model=\"rule.description\"\n                          :placeholder=\"t('gallery.sidebar.scan.ruleDescriptionPlaceholder')\"\n                        />\n                      </div>\n                    </div>\n\n                    <Button\n                      type=\"button\"\n                      variant=\"ghost\"\n                      size=\"icon\"\n                      class=\"h-8 w-8\"\n                      @click=\"removeIgnoreRule(rule.id)\"\n                    >\n                      <Trash2 class=\"h-4 w-4 text-destructive\" />\n                    </Button>\n                  </div>\n                </div>\n              </div>\n            </div>\n          </div>\n        </ScrollArea>\n\n        <DialogFooter class=\"shrink-0 border-t px-6 py-4\">\n          <Button\n            variant=\"outline\"\n            :disabled=\"isSubmittingScanTask\"\n            @click=\"handleDialogOpenChange(false)\"\n          >\n            {{ t('gallery.sidebar.scan.cancel') }}\n          </Button>\n          <Button :disabled=\"!canSubmitAddFolder\" @click=\"handleImportAlbum\">\n            <Loader2 v-if=\"isSubmittingScanTask\" class=\"mr-2 h-4 w-4 animate-spin\" />\n            {{\n              isSubmittingScanTask\n                ? t('gallery.sidebar.scan.submitting')\n                : t('gallery.sidebar.scan.submit')\n            }}\n          </Button>\n        </DialogFooter>\n      </div>\n    </DialogContent>\n  </Dialog>\n</template>\n"
  },
  {
    "path": "web/src/features/gallery/components/folders/FolderTreeItem.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed, ref } from 'vue'\nimport { FolderOpen, Pen, RefreshCw, Sparkles, Trash2 } from 'lucide-vue-next'\nimport { cn } from '@/lib/utils'\nimport {\n  ContextMenu,\n  ContextMenuContent,\n  ContextMenuItem,\n  ContextMenuSeparator,\n  ContextMenuTrigger,\n} from '@/components/ui/context-menu'\nimport {\n  AlertDialog,\n  AlertDialogAction,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n} from '@/components/ui/alert-dialog'\nimport { useI18n } from '@/composables/useI18n'\nimport { useSettingsStore } from '@/features/settings/store'\nimport TagInlineEditor from '../tags/TagInlineEditor.vue'\nimport { useGalleryStore } from '../../store'\nimport type { FolderTreeNode } from '../../types'\nimport {\n  hasGalleryAssetDragIds,\n  readGalleryAssetDragIds,\n} from '../../composables/useGalleryDragPayload'\n\ninterface Props {\n  folder: FolderTreeNode\n  selectedFolder: number | null\n  depth?: number\n}\n\nconst props = withDefaults(defineProps<Props>(), {\n  depth: 0,\n})\n\nconst emit = defineEmits<{\n  select: [folderId: number, folderName: string]\n  clearSelection: []\n  renameDisplayName: [folderId: number, displayName: string]\n  openInExplorer: [folderId: number]\n  removeWatch: [folderId: number]\n  rescanFolder: [folderId: number, folderName: string]\n  extractInfinityNikkiMetadata: [folderId: number, folderName: string]\n  dropAssetsToFolder: [folderId: number, assetIds: number[]]\n}>()\n\nconst { t } = useI18n()\n\nconst galleryStore = useGalleryStore()\nconst settingsStore = useSettingsStore()\nconst infinityNikkiEnabled = computed(\n  () => settingsStore.appSettings.extensions.infinityNikki.enable\n)\n\n// 展开状态放在 gallery store，递归节点重挂载后也能恢复，并可跨会话持久化。\nconst isExpanded = computed(() => galleryStore.isFolderExpanded(props.folder.id))\nconst isEditingDisplayName = ref(false)\nconst showRemoveDialog = ref(false)\nconst shouldPreventAutoFocus = ref(false)\nconst isDragOver = ref(false)\n\nconst isRootFolder = computed(\n  () => props.folder.parentId === undefined || props.folder.parentId === null\n)\n\n// 切换展开状态（独立点击箭头）\nfunction toggleExpand() {\n  galleryStore.toggleFolderExpanded(props.folder.id)\n}\n\n// 处理 item 点击\nfunction handleItemClick() {\n  const isCurrentlySelected = props.selectedFolder === props.folder.id\n  const hasChildren = props.folder.children && props.folder.children.length > 0\n\n  if (isCurrentlySelected && hasChildren) {\n    // 已选中 + 有子项 → 切换展开\n    galleryStore.toggleFolderExpanded(props.folder.id)\n  } else if (isCurrentlySelected) {\n    // 已选中 + 无子项 → 取消选择，回到未筛选状态\n    emit('clearSelection')\n  } else {\n    // 未选中 → 选中\n    emit('select', props.folder.id, props.folder.displayName || props.folder.name)\n  }\n}\n\nfunction startRenameDisplayName() {\n  isEditingDisplayName.value = true\n  shouldPreventAutoFocus.value = true\n}\n\nfunction handleRenameConfirm(newName: string) {\n  emit('renameDisplayName', props.folder.id, newName)\n  isEditingDisplayName.value = false\n}\n\nfunction handleRenameCancel() {\n  isEditingDisplayName.value = false\n}\n\nfunction handleOpenInExplorer() {\n  emit('openInExplorer', props.folder.id)\n}\n\nfunction handleExtractInfinityNikkiMetadata() {\n  emit(\n    'extractInfinityNikkiMetadata',\n    props.folder.id,\n    props.folder.displayName || props.folder.name\n  )\n}\n\nfunction handleRescanFolder() {\n  emit('rescanFolder', props.folder.id, props.folder.displayName || props.folder.name)\n}\n\nfunction requestRemoveWatch() {\n  showRemoveDialog.value = true\n}\n\nfunction confirmRemoveWatch() {\n  emit('removeWatch', props.folder.id)\n  showRemoveDialog.value = false\n}\n\nfunction handleContextMenuCloseAutoFocus(event: Event) {\n  if (shouldPreventAutoFocus.value) {\n    event.preventDefault()\n    shouldPreventAutoFocus.value = false\n  }\n}\n\nfunction handleDragEnter(event: DragEvent) {\n  if (!hasGalleryAssetDragIds(event)) {\n    return\n  }\n  event.preventDefault()\n  // 标记可放置态，用于侧边栏节点高亮反馈。\n  isDragOver.value = true\n}\n\nfunction handleDragOver(event: DragEvent) {\n  if (!hasGalleryAssetDragIds(event)) {\n    return\n  }\n  event.preventDefault()\n  if (event.dataTransfer) {\n    event.dataTransfer.dropEffect = 'move'\n  }\n  isDragOver.value = true\n}\n\nfunction handleDragLeave() {\n  isDragOver.value = false\n}\n\nfunction handleDrop(event: DragEvent) {\n  event.preventDefault()\n  isDragOver.value = false\n  const assetIds = readGalleryAssetDragIds(event)\n  if (assetIds.length === 0) {\n    return\n  }\n  emit('dropAssetsToFolder', props.folder.id, assetIds)\n}\n</script>\n\n<template>\n  <div>\n    <AlertDialog v-model:open=\"showRemoveDialog\">\n      <AlertDialogContent>\n        <AlertDialogHeader>\n          <AlertDialogTitle>\n            {{\n              t('gallery.sidebar.folders.removeWatch.confirmTitle', {\n                name: folder.displayName || folder.name,\n              })\n            }}\n          </AlertDialogTitle>\n          <AlertDialogDescription>\n            {{ t('gallery.sidebar.folders.removeWatch.confirmDescription') }}\n          </AlertDialogDescription>\n        </AlertDialogHeader>\n        <AlertDialogFooter>\n          <AlertDialogCancel>{{\n            t('gallery.sidebar.folders.removeWatch.cancel')\n          }}</AlertDialogCancel>\n          <AlertDialogAction @click=\"confirmRemoveWatch\">\n            {{ t('gallery.sidebar.folders.removeWatch.confirm') }}\n          </AlertDialogAction>\n        </AlertDialogFooter>\n      </AlertDialogContent>\n    </AlertDialog>\n\n    <div v-if=\"isEditingDisplayName\" class=\"px-2\" :style=\"{ paddingLeft: `${depth * 12}px` }\">\n      <TagInlineEditor\n        :initial-value=\"folder.displayName || folder.name\"\n        :placeholder=\"t('gallery.sidebar.folders.rename.placeholder')\"\n        @confirm=\"handleRenameConfirm\"\n        @cancel=\"handleRenameCancel\"\n      />\n    </div>\n\n    <ContextMenu v-else>\n      <ContextMenuTrigger as-child>\n        <button\n          type=\"button\"\n          :class=\"\n            cn(\n              'group relative flex h-8 w-full cursor-pointer items-center justify-between rounded-md border-0 bg-transparent px-0 text-left text-sm transition-colors duration-200 ease-out outline-none',\n              'focus-visible:ring-2 focus-visible:ring-sidebar-ring focus-visible:ring-offset-2',\n              isDragOver ? 'bg-primary/12 text-primary' : '',\n              selectedFolder === folder.id\n                ? 'bg-sidebar-accent font-medium text-primary hover:text-primary [&_svg]:text-primary'\n                : 'text-sidebar-foreground hover:bg-sidebar-hover hover:text-sidebar-accent-foreground'\n            )\n          \"\n          :style=\"{ paddingLeft: `${depth * 12 + 8}px` }\"\n          @click=\"handleItemClick\"\n          @dragenter=\"handleDragEnter\"\n          @dragover=\"handleDragOver\"\n          @dragleave=\"handleDragLeave\"\n          @drop=\"handleDrop\"\n        >\n          <!-- 左侧：图标 + 名称 -->\n          <div class=\"flex min-w-0 items-center gap-2\">\n            <!-- 文件夹图标 -->\n            <svg\n              xmlns=\"http://www.w3.org/2000/svg\"\n              width=\"14\"\n              height=\"14\"\n              viewBox=\"0 0 24 24\"\n              fill=\"none\"\n              stroke=\"currentColor\"\n              stroke-width=\"2\"\n              stroke-linecap=\"round\"\n              stroke-linejoin=\"round\"\n              class=\"flex-shrink-0\"\n            >\n              <path\n                d=\"M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z\"\n              />\n            </svg>\n\n            <!-- 文件夹名称 -->\n            <span class=\"font-tnum truncate text-sm\">\n              {{ folder.displayName || folder.name }}\n            </span>\n          </div>\n\n          <!-- 右侧：箭头 -->\n          <div\n            class=\"flex flex-shrink-0 items-center gap-2\"\n            v-if=\"folder.children && folder.children.length > 0\"\n          >\n            <span\n              class=\"-mr-0.5 flex-shrink-0 rounded-md p-1.5 hover:bg-sidebar-hover\"\n              @click.stop=\"toggleExpand\"\n            >\n              <svg\n                xmlns=\"http://www.w3.org/2000/svg\"\n                width=\"12\"\n                height=\"12\"\n                viewBox=\"0 0 24 24\"\n                fill=\"none\"\n                stroke=\"currentColor\"\n                stroke-width=\"2\"\n                stroke-linecap=\"round\"\n                stroke-linejoin=\"round\"\n                class=\"transition-transform\"\n                :class=\"{ 'rotate-90': isExpanded }\"\n              >\n                <path d=\"m9 18 6-6-6-6\" />\n              </svg>\n            </span>\n          </div>\n        </button>\n      </ContextMenuTrigger>\n\n      <ContextMenuContent @close-auto-focus=\"handleContextMenuCloseAutoFocus\">\n        <ContextMenuItem @click=\"startRenameDisplayName\">\n          <Pen />\n          {{ t('gallery.sidebar.folders.menu.renameDisplayName') }}\n        </ContextMenuItem>\n        <ContextMenuItem @click=\"handleOpenInExplorer\">\n          <FolderOpen />\n          {{ t('gallery.sidebar.folders.menu.openInExplorer') }}\n        </ContextMenuItem>\n        <ContextMenuItem @click=\"handleRescanFolder\">\n          <RefreshCw />\n          {{ t('gallery.sidebar.folders.menu.rescan') }}\n        </ContextMenuItem>\n        <ContextMenuItem v-if=\"infinityNikkiEnabled\" @click=\"handleExtractInfinityNikkiMetadata\">\n          <Sparkles />\n          {{ t('gallery.sidebar.folders.menu.extractInfinityNikkiMetadata') }}\n        </ContextMenuItem>\n        <template v-if=\"isRootFolder\">\n          <ContextMenuSeparator />\n          <ContextMenuItem variant=\"destructive\" @click=\"requestRemoveWatch\">\n            <Trash2 />\n            {{ t('gallery.sidebar.folders.menu.removeWatch') }}\n          </ContextMenuItem>\n        </template>\n      </ContextMenuContent>\n    </ContextMenu>\n\n    <!-- 递归渲染子文件夹 -->\n    <div v-if=\"isExpanded && folder.children && folder.children.length > 0\" class=\"space-y-1\">\n      <FolderTreeItem\n        v-for=\"child in folder.children\"\n        :key=\"child.id\"\n        :folder=\"child\"\n        :selected-folder=\"selectedFolder\"\n        :depth=\"depth + 1\"\n        @select=\"(folderId, folderName) => emit('select', folderId, folderName)\"\n        @clear-selection=\"() => emit('clearSelection')\"\n        @rename-display-name=\"\n          (folderId, displayName) => emit('renameDisplayName', folderId, displayName)\n        \"\n        @open-in-explorer=\"(folderId) => emit('openInExplorer', folderId)\"\n        @remove-watch=\"(folderId) => emit('removeWatch', folderId)\"\n        @rescan-folder=\"(folderId, folderName) => emit('rescanFolder', folderId, folderName)\"\n        @extract-infinity-nikki-metadata=\"\n          (folderId, folderName) => emit('extractInfinityNikkiMetadata', folderId, folderName)\n        \"\n        @drop-assets-to-folder=\"\n          (folderId, assetIds) => emit('dropAssetsToFolder', folderId, assetIds)\n        \"\n      />\n    </div>\n  </div>\n</template>\n"
  },
  {
    "path": "web/src/features/gallery/components/infinity_nikki/AssetInfinityNikkiDetails.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed, ref, watch } from 'vue'\nimport { Check, ChevronDown } from 'lucide-vue-next'\nimport { Button } from '@/components/ui/button'\nimport { Input } from '@/components/ui/input'\nimport { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'\nimport { Separator } from '@/components/ui/separator'\nimport { readClipboardText } from '@/core/clipboard'\nimport { useI18n } from '@/composables/useI18n'\nimport { useToast } from '@/composables/useToast'\nimport { getInfinityNikkiMetadataNames, setInfinityNikkiUserRecord } from '../../api'\nimport type {\n  InfinityNikkiDetails,\n  InfinityNikkiExtractedParams,\n  InfinityNikkiMetadataNames,\n  InfinityNikkiUserRecordCodeType,\n} from '../../types'\n\nconst props = defineProps<{\n  assetId: number\n  details: InfinityNikkiDetails\n}>()\n\nconst emit = defineEmits<{\n  updated: [details: InfinityNikkiDetails]\n}>()\n\nconst { t, locale } = useI18n()\nconst { toast } = useToast()\n\nconst codeTypeDraft = ref<InfinityNikkiUserRecordCodeType>('dye')\nconst codeValueDraft = ref('')\nconst isSavingUserRecord = ref(false)\nconst isCodeTypePopoverOpen = ref(false)\n\nconst extracted = computed(() => props.details.extracted)\nconst currentUserRecord = computed(() => props.details.userRecord)\nconst hasCodeValueDraft = computed(() => codeValueDraft.value.trim().length > 0)\nconst currentCodeTypeLabel = computed(() => getCodeTypeLabel(codeTypeDraft.value))\n// 当前面板实际使用的“已翻译名称”结果。\nconst metadataNames = ref<InfinityNikkiMetadataNames>({})\n// 面板级缓存：key=语言+filter/pose/light 三元组，value=映射结果。\nconst metadataNamesCache = new Map<string, InfinityNikkiMetadataNames>()\n\nfunction syncDraftFromProps() {\n  codeTypeDraft.value = currentUserRecord.value?.codeType ?? 'dye'\n  codeValueDraft.value = currentUserRecord.value?.codeValue ?? ''\n}\n\nwatch(\n  () => props.details,\n  () => {\n    syncDraftFromProps()\n  },\n  { deep: true, immediate: true }\n)\n\nfunction getCodeTypeLabel(codeType: InfinityNikkiUserRecordCodeType): string {\n  return codeType === 'home_building'\n    ? t('gallery.details.infinityNikki.codeType.homeBuilding')\n    : t('gallery.details.infinityNikki.codeType.dye')\n}\n\nfunction padTwoDigits(value: number): string {\n  return String(value).padStart(2, '0')\n}\n\nfunction formatGameTime(params: InfinityNikkiExtractedParams | undefined): string | null {\n  if (!params || params.timeHour === undefined || params.timeMin === undefined) return null\n  return `${padTwoDigits(params.timeHour)}:${padTwoDigits(params.timeMin)}`\n}\n\nfunction formatNumber(value: number | undefined, digits = 2): string | null {\n  if (value === undefined) return null\n  return Number(value)\n    .toFixed(digits)\n    .replace(/\\.?0+$/, '')\n}\n\nfunction copyWithExecCommand(text: string): boolean {\n  if (typeof document === 'undefined') {\n    return false\n  }\n\n  const textarea = document.createElement('textarea')\n  textarea.value = text\n  textarea.style.position = 'fixed'\n  textarea.style.opacity = '0'\n  textarea.style.pointerEvents = 'none'\n  document.body.appendChild(textarea)\n  textarea.focus()\n  textarea.select()\n\n  let success = false\n  try {\n    success = document.execCommand('copy')\n  } catch {\n    success = false\n  }\n\n  document.body.removeChild(textarea)\n  return success\n}\n\nasync function copyTextWithFallback(text: string): Promise<boolean> {\n  if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {\n    try {\n      await navigator.clipboard.writeText(text)\n      return true\n    } catch {\n      // fall through to execCommand\n    }\n  }\n  return copyWithExecCommand(text)\n}\n\nfunction resetUserRecordDraft() {\n  syncDraftFromProps()\n}\n\nasync function handleUserRecordCommit() {\n  if (isSavingUserRecord.value) {\n    return\n  }\n\n  const normalizedCodeValue = codeValueDraft.value.trim()\n  const nextCodeType = codeTypeDraft.value\n  const currentCodeType = currentUserRecord.value?.codeType ?? 'dye'\n  const currentCodeValue = (currentUserRecord.value?.codeValue ?? '').trim()\n  codeValueDraft.value = normalizedCodeValue\n\n  if (!currentUserRecord.value && !normalizedCodeValue) {\n    return\n  }\n\n  if (normalizedCodeValue === currentCodeValue && nextCodeType === currentCodeType) {\n    return\n  }\n\n  isSavingUserRecord.value = true\n\n  try {\n    await setInfinityNikkiUserRecord({\n      assetId: props.assetId,\n      codeType: nextCodeType,\n      codeValue: normalizedCodeValue || undefined,\n    })\n\n    emit('updated', {\n      extracted: props.details.extracted,\n      userRecord: normalizedCodeValue\n        ? {\n            codeType: nextCodeType,\n            codeValue: normalizedCodeValue,\n          }\n        : undefined,\n    })\n  } catch (error) {\n    resetUserRecordDraft()\n    const message = error instanceof Error ? error.message : String(error)\n    toast.error(t('gallery.details.infinityNikki.saveUserRecordFailed'), {\n      description: message,\n    })\n  } finally {\n    isSavingUserRecord.value = false\n  }\n}\n\nasync function handleCopyCameraParams(text: string) {\n  const success = await copyTextWithFallback(text)\n  if (success) {\n    toast.success(t('gallery.details.infinityNikki.copyCameraParamsSuccess'))\n    return\n  }\n  toast.error(t('gallery.details.infinityNikki.copyCameraParamsFailed'))\n}\n\nasync function handleCodeValueAction() {\n  if (hasCodeValueDraft.value) {\n    const success = await copyTextWithFallback(codeValueDraft.value.trim())\n    if (success) {\n      toast.success(t('gallery.details.infinityNikki.copyCodeValueSuccess'))\n      return\n    }\n    toast.error(t('gallery.details.infinityNikki.copyCodeValueFailed'))\n    return\n  }\n\n  let normalizedClipboardText = ''\n  try {\n    normalizedClipboardText = (await readClipboardText())?.trim() ?? ''\n  } catch {\n    normalizedClipboardText = ''\n  }\n\n  if (!normalizedClipboardText) {\n    toast.error(t('gallery.details.infinityNikki.pasteCodeValueFailed'))\n    return\n  }\n\n  codeValueDraft.value = normalizedClipboardText\n}\n\nasync function handleSelectCodeType(nextCodeType: InfinityNikkiUserRecordCodeType) {\n  isCodeTypePopoverOpen.value = false\n  if (codeTypeDraft.value === nextCodeType) {\n    return\n  }\n\n  codeTypeDraft.value = nextCodeType\n  if (hasCodeValueDraft.value || currentUserRecord.value) {\n    await handleUserRecordCommit()\n  }\n}\n\nfunction formatPercentage(value: number | undefined): string | null {\n  if (value === undefined) return null\n  return `${formatNumber(value, 1) ?? '0'}%`\n}\n\nfunction formatFocalLength(value: number | undefined): string | null {\n  const formatted = formatNumber(value)\n  return formatted ? `${formatted} mm` : null\n}\n\nfunction formatApertureValue(value: number | undefined): string | null {\n  const formatted = formatNumber(value, 1)\n  return formatted ? `f / ${formatted}` : null\n}\n\nfunction formatSignedNumber(value: number | undefined, digits = 2): string | null {\n  if (value === undefined) return null\n  const formatted = formatNumber(value, digits)\n  if (!formatted) return null\n  return value > 0 ? `+${formatted}` : formatted\n}\n\nfunction formatMetadataId(value: number | undefined): string | null {\n  if (value === undefined || value === null) return null\n  return String(value)\n}\n\nfunction formatPoseId(value: number | undefined): string | null {\n  if (value === undefined || value === null || value === 0) return null\n  return String(value)\n}\n\nfunction normalizeLocaleForMetadata(localeValue: string): 'zh-CN' | 'en-US' {\n  // 与后端约定统一只传两种 locale，避免出现多种别名导致缓存碎片。\n  return localeValue.startsWith('zh') ? 'zh-CN' : 'en-US'\n}\n\nfunction buildMetadataCacheKey(params: {\n  filterId?: number\n  poseId?: number\n  lightId?: number\n  locale: 'zh-CN' | 'en-US'\n}): string {\n  return `${params.locale}|${params.filterId ?? ''}|${params.poseId ?? ''}|${params.lightId ?? ''}`\n}\n\nfunction formatMetadataDisplay(idText: string | null, name: string | undefined): string | null {\n  if (!idText) return null\n  // 面向普通用户时，命中字典仅展示语义名称；未命中才回退到原始 ID。\n  if (!name) return idText\n  return name\n}\n\nfunction formatMetadataTitle(idText: string | null, name: string | undefined): string | undefined {\n  if (!idText) return undefined\n  // 命中字典时将 ID 放到 title，保留排查能力但不干扰主视觉。\n  if (name) return `ID: ${idText}`\n  return idText\n}\n\nasync function refreshMetadataNames() {\n  const filterId = extracted.value?.filterId\n  const poseId = extracted.value?.poseId\n  const lightId = extracted.value?.lightId\n\n  if (filterId === undefined && poseId === undefined && lightId === undefined) {\n    // 当前资产没有相关参数时清空展示，避免沿用上一张图的结果。\n    metadataNames.value = {}\n    return\n  }\n\n  const normalizedLocale = normalizeLocaleForMetadata(locale.value)\n  const cacheKey = buildMetadataCacheKey({\n    filterId,\n    poseId,\n    lightId,\n    locale: normalizedLocale,\n  })\n\n  const cached = metadataNamesCache.get(cacheKey)\n  if (cached) {\n    // 组件级缓存：同一语言+同一组 ID 直接复用，避免重复 RPC。\n    metadataNames.value = cached\n    return\n  }\n\n  const result = await getInfinityNikkiMetadataNames({\n    filterId,\n    poseId,\n    lightId,\n    locale: normalizedLocale,\n  })\n  // 即使 result 为空对象也会缓存，避免失败场景下短时间重复请求。\n  metadataNamesCache.set(cacheKey, result)\n  metadataNames.value = result\n}\n\nwatch(\n  () => [\n    extracted.value?.filterId,\n    extracted.value?.poseId,\n    extracted.value?.lightId,\n    locale.value,\n  ],\n  () => {\n    // 当资产切换或语言切换时刷新映射，保证展示始终与当前上下文一致。\n    void refreshMetadataNames()\n  },\n  { immediate: true }\n)\n\nfunction formatLocation(params: InfinityNikkiExtractedParams | undefined): string | null {\n  if (!params) return null\n  const x = formatNumber(params.nikkiLocX, 2)\n  const y = formatNumber(params.nikkiLocY, 2)\n  if (!x || !y) return null\n\n  const z = formatNumber(params.nikkiLocZ, 2)\n  return z ? `(${x}, ${y}, ${z})` : `(${x}, ${y})`\n}\n</script>\n\n<template>\n  <Separator />\n\n  <div class=\"space-y-3\">\n    <h4 class=\"text-sm font-medium\">{{ t('gallery.details.infinityNikki.title') }}</h4>\n\n    <div class=\"space-y-2 text-xs\">\n      <div class=\"flex items-center justify-between gap-2\">\n        <div class=\"flex min-w-0 items-center gap-1 text-muted-foreground\">\n          <span>{{ currentCodeTypeLabel }}</span>\n          <Popover v-model:open=\"isCodeTypePopoverOpen\">\n            <PopoverTrigger as-child>\n              <Button variant=\"ghost\" size=\"icon\" class=\"h-5 w-5 text-muted-foreground\">\n                <ChevronDown class=\"h-3 w-3\" />\n              </Button>\n            </PopoverTrigger>\n            <PopoverContent align=\"start\" class=\"w-36 p-1\">\n              <button\n                type=\"button\"\n                class=\"flex w-full items-center justify-between rounded px-2 py-1.5 text-left text-xs transition-colors hover:bg-accent\"\n                @click=\"void handleSelectCodeType('dye')\"\n              >\n                <span>{{ t('gallery.details.infinityNikki.codeType.dye') }}</span>\n                <Check v-if=\"codeTypeDraft === 'dye'\" class=\"h-3.5 w-3.5\" />\n              </button>\n              <button\n                type=\"button\"\n                class=\"flex w-full items-center justify-between rounded px-2 py-1.5 text-left text-xs transition-colors hover:bg-accent\"\n                @click=\"void handleSelectCodeType('home_building')\"\n              >\n                <span>{{ t('gallery.details.infinityNikki.codeType.homeBuilding') }}</span>\n                <Check v-if=\"codeTypeDraft === 'home_building'\" class=\"h-3.5 w-3.5\" />\n              </button>\n            </PopoverContent>\n          </Popover>\n        </div>\n\n        <div class=\"flex max-w-54 min-w-0 flex-1 items-center gap-2\">\n          <Input\n            v-model=\"codeValueDraft\"\n            :disabled=\"isSavingUserRecord\"\n            class=\"h-6 min-w-0 flex-1 px-2 text-xs md:text-xs\"\n            @blur=\"handleUserRecordCommit\"\n            @keydown.enter.prevent=\"handleUserRecordCommit\"\n            @keydown.esc.prevent=\"resetUserRecordDraft\"\n          />\n          <Button\n            variant=\"outline\"\n            size=\"sm\"\n            class=\"h-6 px-2 text-xs\"\n            :disabled=\"isSavingUserRecord\"\n            @click=\"handleCodeValueAction\"\n          >\n            {{\n              hasCodeValueDraft\n                ? t('gallery.details.infinityNikki.copyCodeValue')\n                : t('gallery.details.infinityNikki.pasteCodeValue')\n            }}\n          </Button>\n        </div>\n      </div>\n\n      <template v-if=\"extracted\">\n        <div v-if=\"formatGameTime(extracted)\" class=\"flex justify-between gap-2\">\n          <span class=\"shrink-0 whitespace-nowrap text-muted-foreground\">{{\n            t('gallery.details.infinityNikki.gameTime')\n          }}</span>\n          <span class=\"font-mono\">{{ formatGameTime(extracted) }}</span>\n        </div>\n\n        <div v-if=\"extracted.cameraParams\" class=\"flex items-center justify-between gap-2\">\n          <span class=\"shrink-0 whitespace-nowrap text-muted-foreground\">{{\n            t('gallery.details.infinityNikki.cameraParams')\n          }}</span>\n          <Button\n            variant=\"outline\"\n            size=\"sm\"\n            class=\"h-6 px-2 text-xs\"\n            @click=\"handleCopyCameraParams(extracted.cameraParams)\"\n          >\n            {{ t('gallery.details.infinityNikki.copyCameraParams') }}\n          </Button>\n        </div>\n\n        <div\n          v-if=\"formatFocalLength(extracted.cameraFocalLength)\"\n          class=\"flex justify-between gap-2\"\n        >\n          <span class=\"shrink-0 whitespace-nowrap text-muted-foreground\">{{\n            t('gallery.details.infinityNikki.cameraFocalLength')\n          }}</span>\n          <span class=\"font-mono\">{{ formatFocalLength(extracted.cameraFocalLength) }}</span>\n        </div>\n        <div v-if=\"formatApertureValue(extracted.apertureValue)\" class=\"flex justify-between gap-2\">\n          <span class=\"shrink-0 whitespace-nowrap text-muted-foreground\">{{\n            t('gallery.details.infinityNikki.apertureValue')\n          }}</span>\n          <span class=\"font-mono\">{{ formatApertureValue(extracted.apertureValue) }}</span>\n        </div>\n\n        <div v-if=\"formatSignedNumber(extracted.rotation, 2)\" class=\"flex justify-between gap-2\">\n          <span class=\"shrink-0 whitespace-nowrap text-muted-foreground\">{{\n            t('gallery.details.infinityNikki.rotation')\n          }}</span>\n          <span class=\"font-mono\">{{ formatSignedNumber(extracted.rotation, 2) }}</span>\n        </div>\n        <div\n          v-if=\"formatPercentage(extracted.vignetteIntensity)\"\n          class=\"flex justify-between gap-2\"\n        >\n          <span class=\"shrink-0 whitespace-nowrap text-muted-foreground\">{{\n            t('gallery.details.infinityNikki.vignetteIntensity')\n          }}</span>\n          <span class=\"font-mono\">{{ formatPercentage(extracted.vignetteIntensity) }}</span>\n        </div>\n        <div v-if=\"formatPercentage(extracted.bloomIntensity)\" class=\"flex justify-between gap-2\">\n          <span class=\"shrink-0 whitespace-nowrap text-muted-foreground\">{{\n            t('gallery.details.infinityNikki.bloomIntensity')\n          }}</span>\n          <span class=\"font-mono\">{{ formatPercentage(extracted.bloomIntensity) }}</span>\n        </div>\n        <div\n          v-if=\"formatSignedNumber(extracted.bloomThreshold, 2)\"\n          class=\"flex justify-between gap-2\"\n        >\n          <span class=\"shrink-0 whitespace-nowrap text-muted-foreground\">{{\n            t('gallery.details.infinityNikki.bloomThreshold')\n          }}</span>\n          <span class=\"font-mono\">{{ formatSignedNumber(extracted.bloomThreshold, 2) }}</span>\n        </div>\n        <div v-if=\"formatPercentage(extracted.brightness)\" class=\"flex justify-between gap-2\">\n          <span class=\"shrink-0 whitespace-nowrap text-muted-foreground\">{{\n            t('gallery.details.infinityNikki.brightness')\n          }}</span>\n          <span class=\"font-mono\">{{ formatPercentage(extracted.brightness) }}</span>\n        </div>\n        <div v-if=\"formatSignedNumber(extracted.exposure, 2)\" class=\"flex justify-between gap-2\">\n          <span class=\"shrink-0 whitespace-nowrap text-muted-foreground\">{{\n            t('gallery.details.infinityNikki.exposure')\n          }}</span>\n          <span class=\"font-mono\">{{ formatSignedNumber(extracted.exposure, 2) }}</span>\n        </div>\n        <div v-if=\"formatPercentage(extracted.contrast)\" class=\"flex justify-between gap-2\">\n          <span class=\"shrink-0 whitespace-nowrap text-muted-foreground\">{{\n            t('gallery.details.infinityNikki.contrast')\n          }}</span>\n          <span class=\"font-mono\">{{ formatPercentage(extracted.contrast) }}</span>\n        </div>\n        <div v-if=\"formatSignedNumber(extracted.saturation, 2)\" class=\"flex justify-between gap-2\">\n          <span class=\"shrink-0 whitespace-nowrap text-muted-foreground\">{{\n            t('gallery.details.infinityNikki.saturation')\n          }}</span>\n          <span class=\"font-mono\">{{ formatSignedNumber(extracted.saturation, 2) }}</span>\n        </div>\n        <div v-if=\"formatSignedNumber(extracted.vibrance, 2)\" class=\"flex justify-between gap-2\">\n          <span class=\"shrink-0 whitespace-nowrap text-muted-foreground\">{{\n            t('gallery.details.infinityNikki.vibrance')\n          }}</span>\n          <span class=\"font-mono\">{{ formatSignedNumber(extracted.vibrance, 2) }}</span>\n        </div>\n        <div v-if=\"formatSignedNumber(extracted.highlights, 2)\" class=\"flex justify-between gap-2\">\n          <span class=\"shrink-0 whitespace-nowrap text-muted-foreground\">{{\n            t('gallery.details.infinityNikki.highlights')\n          }}</span>\n          <span class=\"font-mono\">{{ formatSignedNumber(extracted.highlights, 2) }}</span>\n        </div>\n        <div v-if=\"formatSignedNumber(extracted.shadow, 2)\" class=\"flex justify-between gap-2\">\n          <span class=\"shrink-0 whitespace-nowrap text-muted-foreground\">{{\n            t('gallery.details.infinityNikki.shadow')\n          }}</span>\n          <span class=\"font-mono\">{{ formatSignedNumber(extracted.shadow, 2) }}</span>\n        </div>\n        <div v-if=\"formatPoseId(extracted.poseId)\" class=\"flex justify-between gap-2\">\n          <span class=\"shrink-0 whitespace-nowrap text-muted-foreground\">{{\n            t('gallery.details.infinityNikki.poseId')\n          }}</span>\n          <span\n            class=\"max-w-32 truncate\"\n            :class=\"{ 'font-mono': !metadataNames.poseName }\"\n            :title=\"formatMetadataTitle(formatPoseId(extracted.poseId), metadataNames.poseName)\"\n          >\n            {{ formatMetadataDisplay(formatPoseId(extracted.poseId), metadataNames.poseName) }}\n          </span>\n        </div>\n        <div v-if=\"formatMetadataId(extracted.lightId)\" class=\"flex justify-between gap-2\">\n          <span class=\"shrink-0 whitespace-nowrap text-muted-foreground\">{{\n            t('gallery.details.infinityNikki.lightId')\n          }}</span>\n          <span\n            class=\"max-w-32 truncate\"\n            :class=\"{ 'font-mono': !metadataNames.lightName }\"\n            :title=\"\n              formatMetadataTitle(formatMetadataId(extracted.lightId), metadataNames.lightName)\n            \"\n          >\n            {{\n              formatMetadataDisplay(formatMetadataId(extracted.lightId), metadataNames.lightName)\n            }}\n          </span>\n        </div>\n        <div v-if=\"formatPercentage(extracted.lightStrength)\" class=\"flex justify-between gap-2\">\n          <span class=\"shrink-0 whitespace-nowrap text-muted-foreground\">{{\n            t('gallery.details.infinityNikki.lightStrength')\n          }}</span>\n          <span class=\"font-mono\">{{ formatPercentage(extracted.lightStrength) }}</span>\n        </div>\n\n        <div v-if=\"formatLocation(extracted)\" class=\"flex justify-between gap-2\">\n          <span class=\"shrink-0 whitespace-nowrap text-muted-foreground\">{{\n            t('gallery.details.infinityNikki.nikkiLocation')\n          }}</span>\n          <span class=\"truncate\" :title=\"formatLocation(extracted) ?? undefined\">{{\n            formatLocation(extracted)\n          }}</span>\n        </div>\n        <div v-if=\"formatMetadataId(extracted.filterId)\" class=\"flex justify-between gap-2\">\n          <span class=\"shrink-0 whitespace-nowrap text-muted-foreground\">{{\n            t('gallery.details.infinityNikki.filterId')\n          }}</span>\n          <span\n            class=\"max-w-32 truncate\"\n            :class=\"{ 'font-mono': !metadataNames.filterName }\"\n            :title=\"\n              formatMetadataTitle(formatMetadataId(extracted.filterId), metadataNames.filterName)\n            \"\n          >\n            {{\n              formatMetadataDisplay(formatMetadataId(extracted.filterId), metadataNames.filterName)\n            }}\n          </span>\n        </div>\n        <div v-if=\"formatPercentage(extracted.filterStrength)\" class=\"flex justify-between gap-2\">\n          <span class=\"shrink-0 whitespace-nowrap text-muted-foreground\">{{\n            t('gallery.details.infinityNikki.filterStrength')\n          }}</span>\n          <span class=\"font-mono\">{{ formatPercentage(extracted.filterStrength) }}</span>\n        </div>\n        <div v-if=\"extracted.nikkiHidden !== undefined\" class=\"flex justify-between gap-2\">\n          <span class=\"shrink-0 whitespace-nowrap text-muted-foreground\">{{\n            t('gallery.details.infinityNikki.nikkiHidden')\n          }}</span>\n          <span>{{ extracted.nikkiHidden ? t('common.yes') : t('common.no') }}</span>\n        </div>\n        <div v-if=\"extracted.vertical !== undefined\" class=\"flex justify-between gap-2\">\n          <span class=\"shrink-0 whitespace-nowrap text-muted-foreground\">{{\n            t('gallery.details.infinityNikki.vertical')\n          }}</span>\n          <span>{{ extracted.vertical ? t('common.yes') : t('common.no') }}</span>\n        </div>\n      </template>\n    </div>\n  </div>\n</template>\n"
  },
  {
    "path": "web/src/features/gallery/components/infinity_nikki/InfinityNikkiGuidePanel.vue",
    "content": "<script setup lang=\"ts\">\nimport { ref } from 'vue'\nimport { Button } from '@/components/ui/button'\nimport { ScanText, FolderSymlink, Sparkles } from 'lucide-vue-next'\nimport { useI18n } from '@/composables/useI18n'\nimport { useToast } from '@/composables/useToast'\nimport { useSettingsStore } from '@/features/settings/store'\nimport type { AppSettings } from '@/features/settings/types'\n\nconst settingsStore = useSettingsStore()\nconst { t } = useI18n()\nconst { toast } = useToast()\n\ntype Step = 1 | 2 | 3\ntype Patch = Partial<AppSettings['extensions']['infinityNikki']>\n\nconst step = ref<Step>(1)\nconst wantsMetadata = ref(false)\nconst wantsHardlinks = ref(false)\nconst isSubmitting = ref(false)\n\nasync function persist(patch: Patch) {\n  await settingsStore.updateSettings({\n    extensions: {\n      infinityNikki: {\n        ...settingsStore.appSettings.extensions.infinityNikki,\n        galleryGuideSeen: true,\n        ...patch,\n      },\n    },\n  })\n}\n\n// 步骤 1：记录选择，前进到步骤 2\nfunction selectMetadata(enable: boolean) {\n  wantsMetadata.value = enable\n  step.value = 2\n}\n\n// 步骤 2：记录选择，前进到步骤 3\nfunction selectHardlinks(enable: boolean) {\n  wantsHardlinks.value = enable\n  step.value = 3\n}\n\n// 步骤 3：保存前两步的选择结果\nasync function applySelections() {\n  if (isSubmitting.value) return\n  isSubmitting.value = true\n  try {\n    await persist({\n      allowOnlinePhotoMetadataExtract: wantsMetadata.value,\n      manageScreenshotHardlinks: wantsHardlinks.value,\n    })\n\n    if (wantsMetadata.value && wantsHardlinks.value) {\n      toast.success(t('gallery.guide.infinityNikki.recommendedTaskStartedTitle'), {\n        description: t('gallery.guide.infinityNikki.recommendedTaskStartedDescription'),\n      })\n    } else if (wantsMetadata.value) {\n      toast.success(t('gallery.guide.infinityNikki.metadataTaskStartedTitle'), {\n        description: t('gallery.guide.infinityNikki.metadataTaskStartedDescription'),\n      })\n    } else if (wantsHardlinks.value) {\n      toast.success(t('gallery.guide.infinityNikki.hardlinksTaskStartedTitle'), {\n        description: t('gallery.guide.infinityNikki.hardlinksTaskStartedDescription'),\n      })\n    }\n  } catch (error) {\n    const message = error instanceof Error ? error.message : String(error)\n    toast.error(t('gallery.guide.infinityNikki.actionFailedTitle'), { description: message })\n  } finally {\n    isSubmitting.value = false\n  }\n}\n\n// Footer 统一路由到当前步骤的处理函数\nfunction handleEnable() {\n  if (step.value === 1) {\n    selectMetadata(true)\n  } else if (step.value === 2) {\n    selectHardlinks(true)\n  } else {\n    void applySelections()\n  }\n}\n\nfunction handleSkip() {\n  if (step.value === 1) {\n    selectMetadata(false)\n  } else {\n    selectHardlinks(false)\n  }\n}\n\nfunction handlePrevious() {\n  if (isSubmitting.value || step.value === 1) return\n  step.value = step.value === 3 ? 2 : 1\n}\n</script>\n\n<template>\n  <div class=\"flex h-full w-full items-center justify-center overflow-y-auto p-6\">\n    <!-- 卡片主体 -->\n    <div class=\"surface-top flex w-full max-w-2xl flex-col rounded-md border shadow-sm\">\n      <!-- Header：说明来源 + 步骤进度 -->\n      <div class=\"flex shrink-0 items-center justify-between px-6 py-4\">\n        <div class=\"flex items-center gap-3\">\n          <div\n            class=\"flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary\"\n          >\n            <Sparkles class=\"size-4\" />\n          </div>\n          <div>\n            <p class=\"text-sm leading-tight font-semibold text-foreground\">\n              {{ t('gallery.guide.infinityNikki.header.title') }}\n            </p>\n            <p class=\"text-xs text-muted-foreground\">\n              {{ t('gallery.guide.infinityNikki.header.subtitle') }}\n            </p>\n          </div>\n        </div>\n\n        <!-- 步骤指示器：当前步骤圆点加宽 -->\n        <div class=\"flex items-center gap-1.5\">\n          <div\n            class=\"h-1.5 rounded-full transition-all duration-300\"\n            :class=\"step === 1 ? 'w-6 bg-primary' : 'w-2.5 bg-primary/30'\"\n          />\n          <div\n            class=\"h-1.5 rounded-full transition-all duration-300\"\n            :class=\"step === 2 ? 'w-6 bg-primary' : 'w-2.5 bg-muted-foreground/20'\"\n          />\n          <div\n            class=\"h-1.5 rounded-full transition-all duration-300\"\n            :class=\"step === 3 ? 'w-6 bg-primary' : 'w-2.5 bg-muted-foreground/20'\"\n          />\n        </div>\n      </div>\n\n      <!-- Body：横向双栏 —— 左侧大图标，右侧文字内容 -->\n      <div class=\"flex min-h-[248px] flex-1 items-center gap-8 px-6 py-6\">\n        <!-- 左栏：大图标，随步骤切换淡入淡出 -->\n        <div class=\"flex w-24 shrink-0 items-center justify-center\">\n          <Transition name=\"guide-icon\" mode=\"out-in\">\n            <div\n              :key=\"step\"\n              class=\"flex size-20 items-center justify-center rounded-2xl bg-primary/10 text-primary\"\n            >\n              <ScanText v-if=\"step === 1\" class=\"size-10\" />\n              <FolderSymlink v-else-if=\"step === 2\" class=\"size-10\" />\n              <Sparkles v-else class=\"size-10\" />\n            </div>\n          </Transition>\n        </div>\n\n        <!-- 右栏：步骤内容，切换时左右滑动 -->\n        <div class=\"flex-1 overflow-hidden\">\n          <Transition name=\"guide-step\" mode=\"out-in\">\n            <!-- 步骤 1：照片元数据解析 -->\n            <div v-if=\"step === 1\" key=\"step-1\" class=\"space-y-2\">\n              <h2 class=\"text-base font-semibold text-foreground\">\n                {{ t('gallery.guide.infinityNikki.step1.title') }}\n              </h2>\n              <p class=\"text-sm leading-relaxed text-muted-foreground\">\n                {{ t('gallery.guide.infinityNikki.metadataDescription') }}\n              </p>\n              <p class=\"text-xs leading-relaxed text-muted-foreground\">\n                <span>\n                  {{ t('gallery.guide.infinityNikki.credit') }}\n                  <a\n                    href=\"https://NUAN5.PRO\"\n                    target=\"_blank\"\n                    rel=\"noopener noreferrer\"\n                    class=\"text-green-500 transition-colors hover:text-green-600 dark:text-green-400 dark:hover:text-green-300\"\n                  >\n                    {{ t('gallery.guide.infinityNikki.creditLink') }}\n                  </a>\n                  {{ t('gallery.guide.infinityNikki.creditPowered') }}\n                </span>\n              </p>\n            </div>\n\n            <!-- 步骤 2：硬链接优化 -->\n            <div v-else-if=\"step === 2\" key=\"step-2\" class=\"space-y-3\">\n              <h2 class=\"text-base font-semibold text-foreground\">\n                {{ t('gallery.guide.infinityNikki.step2.title') }}\n              </h2>\n              <p class=\"text-sm leading-relaxed text-muted-foreground\">\n                {{ t('gallery.guide.infinityNikki.hardlinksDescription') }}\n              </p>\n              <!-- 细节说明直接展示，不再折叠 -->\n              <p\n                class=\"rounded-lg bg-muted/50 px-3 py-2.5 text-xs leading-relaxed whitespace-pre-wrap text-muted-foreground\"\n              >\n                {{ t('gallery.guide.infinityNikki.hardlinksDetailsContent') }}\n              </p>\n            </div>\n\n            <!-- 步骤 3：执行前提醒 -->\n            <div v-else key=\"step-3\" class=\"space-y-3\">\n              <h2 class=\"text-base font-semibold text-foreground\">\n                {{ t('gallery.guide.infinityNikki.step3.title') }}\n              </h2>\n              <p class=\"text-sm leading-relaxed text-muted-foreground\">\n                {{ t('gallery.guide.infinityNikki.step3.description') }}\n              </p>\n              <p\n                class=\"rounded-lg bg-muted/50 px-3 py-2.5 text-xs leading-relaxed text-muted-foreground\"\n              >\n                {{ t('gallery.guide.infinityNikki.step3.timeCostNotice') }}\n              </p>\n            </div>\n          </Transition>\n        </div>\n      </div>\n\n      <!-- Footer：操作按钮 -->\n      <div class=\"flex shrink-0 items-center justify-between gap-3 border-t px-6 py-4\">\n        <div>\n          <Button\n            v-if=\"step !== 1\"\n            variant=\"ghost\"\n            :disabled=\"isSubmitting\"\n            @click=\"handlePrevious\"\n          >\n            {{ t('onboarding.actions.previous') }}\n          </Button>\n        </div>\n\n        <div v-if=\"step !== 3\" class=\"flex items-center gap-3\">\n          <Button variant=\"ghost\" :disabled=\"isSubmitting\" @click=\"handleSkip\">\n            {{ t('gallery.guide.infinityNikki.actions.skip') }}\n          </Button>\n          <Button :disabled=\"isSubmitting\" @click=\"handleEnable\">\n            {{ t('gallery.guide.infinityNikki.actions.enable') }}\n          </Button>\n        </div>\n        <div v-else class=\"flex items-center gap-3\">\n          <Button :disabled=\"isSubmitting\" @click=\"handleEnable\">\n            {{ t('gallery.guide.infinityNikki.actions.confirmAndApply') }}\n          </Button>\n        </div>\n      </div>\n    </div>\n  </div>\n</template>\n\n<style scoped>\n/* 右栏内容：向左滑出，从右滑入 */\n.guide-step-enter-active,\n.guide-step-leave-active {\n  transition:\n    opacity 0.18s ease,\n    transform 0.18s ease;\n}\n\n.guide-step-enter-from {\n  opacity: 0;\n  transform: translateX(14px);\n}\n\n.guide-step-leave-to {\n  opacity: 0;\n  transform: translateX(-14px);\n}\n\n/* 左栏图标：简单淡入淡出 */\n.guide-icon-enter-active,\n.guide-icon-leave-active {\n  transition: opacity 0.15s ease;\n}\n\n.guide-icon-enter-from,\n.guide-icon-leave-to {\n  opacity: 0;\n}\n</style>\n"
  },
  {
    "path": "web/src/features/gallery/components/infinity_nikki/InfinityNikkiMetadataExtractDialog.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed, ref, watch } from 'vue'\nimport { useI18n } from '@/composables/useI18n'\nimport { useToast } from '@/composables/useToast'\nimport {\n  INFINITY_NIKKI_LAST_UID_STORAGE_KEY,\n  startExtractInfinityNikkiPhotoParamsForFolder,\n} from '@/extensions/infinity_nikki'\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from '@/components/ui/dialog'\nimport { Button } from '@/components/ui/button'\nimport { Input } from '@/components/ui/input'\nimport { Label } from '@/components/ui/label'\nimport { Switch } from '@/components/ui/switch'\nimport { Loader2 } from 'lucide-vue-next'\n\ninterface Props {\n  open: boolean\n  folderId: number | null\n  folderName: string\n  initialUid?: string\n}\n\nconst props = defineProps<Props>()\nconst emit = defineEmits<{\n  'update:open': [value: boolean]\n}>()\n\nconst { t } = useI18n()\nconst { toast } = useToast()\n\nconst uid = ref('')\nconst onlyMissing = ref(false)\nconst isSubmitting = ref(false)\n\nconst isUidValid = computed(() => /^\\d+$/.test(uid.value.trim()))\nconst canSubmit = computed(() => props.folderId !== null && isUidValid.value && !isSubmitting.value)\n\nfunction resetForm() {\n  uid.value = ''\n  onlyMissing.value = false\n}\n\nwatch(\n  () => props.open,\n  (open) => {\n    if (open) {\n      uid.value = props.initialUid?.trim() ?? ''\n      return\n    }\n    if (!open && !isSubmitting.value) {\n      resetForm()\n    }\n  }\n)\n\nfunction handleOpenChange(open: boolean) {\n  if (!open && isSubmitting.value) {\n    return\n  }\n  emit('update:open', open)\n}\n\nasync function handleSubmit() {\n  if (props.folderId === null) {\n    return\n  }\n\n  const trimmedUid = uid.value.trim()\n  if (!/^\\d+$/.test(trimmedUid)) {\n    toast.error(t('gallery.infinityNikki.extractDialog.invalidUidTitle'), {\n      description: t('gallery.infinityNikki.extractDialog.invalidUidDescription'),\n    })\n    return\n  }\n\n  localStorage.setItem(INFINITY_NIKKI_LAST_UID_STORAGE_KEY, trimmedUid)\n  isSubmitting.value = true\n  const loadingToastId = toast.loading(t('gallery.infinityNikki.extractDialog.submitting'))\n\n  try {\n    const taskId = await startExtractInfinityNikkiPhotoParamsForFolder({\n      folderId: props.folderId,\n      uid: trimmedUid,\n      onlyMissing: onlyMissing.value,\n    })\n\n    toast.dismiss(loadingToastId)\n    toast.success(t('gallery.infinityNikki.extractDialog.successTitle'), {\n      description: t('gallery.infinityNikki.extractDialog.successDescription', {\n        taskId,\n      }),\n    })\n\n    emit('update:open', false)\n    resetForm()\n  } catch (error) {\n    toast.dismiss(loadingToastId)\n    const message = error instanceof Error ? error.message : String(error)\n    toast.error(t('gallery.infinityNikki.extractDialog.failedTitle'), {\n      description: message,\n    })\n  } finally {\n    isSubmitting.value = false\n  }\n}\n</script>\n\n<template>\n  <Dialog :open=\"open\" @update:open=\"handleOpenChange\">\n    <DialogContent class=\"sm:max-w-[460px]\" :show-close-button=\"false\">\n      <DialogHeader>\n        <DialogTitle>{{ t('gallery.infinityNikki.extractDialog.title') }}</DialogTitle>\n        <DialogDescription>\n          {{\n            t('gallery.infinityNikki.extractDialog.description', {\n              folderName,\n            })\n          }}\n        </DialogDescription>\n      </DialogHeader>\n\n      <div class=\"space-y-4 py-2\">\n        <div class=\"space-y-2\">\n          <Label for=\"infinity-nikki-folder-uid\">\n            {{ t('gallery.infinityNikki.extractDialog.uidLabel') }}\n          </Label>\n          <Input\n            id=\"infinity-nikki-folder-uid\"\n            v-model=\"uid\"\n            :placeholder=\"t('gallery.infinityNikki.extractDialog.uidPlaceholder')\"\n            inputmode=\"numeric\"\n            autocomplete=\"off\"\n          />\n          <p class=\"text-xs text-muted-foreground\">\n            {{ t('gallery.infinityNikki.extractDialog.uidHint') }}\n          </p>\n        </div>\n\n        <div class=\"flex items-center justify-between gap-4 rounded-md border p-3\">\n          <div class=\"space-y-1\">\n            <p class=\"text-sm font-medium text-foreground\">\n              {{ t('gallery.infinityNikki.extractDialog.onlyMissingLabel') }}\n            </p>\n            <p class=\"text-xs text-muted-foreground\">\n              {{ t('gallery.infinityNikki.extractDialog.onlyMissingHint') }}\n            </p>\n          </div>\n          <Switch v-model:model-value=\"onlyMissing\" :disabled=\"isSubmitting\" />\n        </div>\n      </div>\n\n      <DialogFooter>\n        <Button variant=\"outline\" :disabled=\"isSubmitting\" @click=\"handleOpenChange(false)\">\n          {{ t('gallery.infinityNikki.extractDialog.cancel') }}\n        </Button>\n        <Button :disabled=\"!canSubmit\" @click=\"handleSubmit\">\n          <Loader2 v-if=\"isSubmitting\" class=\"mr-2 h-4 w-4 animate-spin\" />\n          {{\n            isSubmitting\n              ? t('gallery.infinityNikki.extractDialog.submitting')\n              : t('gallery.infinityNikki.extractDialog.confirm')\n          }}\n        </Button>\n      </DialogFooter>\n    </DialogContent>\n  </Dialog>\n</template>\n"
  },
  {
    "path": "web/src/features/gallery/components/lightbox/GalleryLightbox.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed, ref } from 'vue'\nimport { useEventListener, useThrottleFn } from '@vueuse/core'\nimport { Maximize } from 'lucide-vue-next'\nimport { useI18n } from '@/composables/useI18n'\nimport { useGalleryAssetActions, useGalleryLightbox, useGallerySelection } from '../../composables'\nimport { useGalleryStore } from '../../store'\nimport { computeLightboxHeroRect, prepareReverseHero } from '../../composables/useHeroTransition'\nimport { galleryApi } from '../../api'\nimport GalleryAssetContextMenuContent from '../menus/GalleryAssetContextMenuContent.vue'\nimport LightboxFilmstrip from './LightboxFilmstrip.vue'\nimport LightboxImage from './LightboxImage.vue'\nimport LightboxVideo from './LightboxVideo.vue'\nimport LightboxToolbar from './LightboxToolbar.vue'\nimport {\n  ContextMenu,\n  ContextMenuContent,\n  ContextMenuItem,\n  ContextMenuSeparator,\n  ContextMenuTrigger,\n} from '@/components/ui/context-menu'\n\n/** 与反向 hero、surface 淡出时长（约 220ms）对齐，并留出双 rAF 余量 */\nconst CLOSE_AFTER_REVERSE_HERO_MS = 260\n/** 无飞回动画时，与工具栏/内容区 leave ~180ms 对齐 */\nconst CLOSE_AFTER_NO_HERO_MS = 180\n\ntype LightboxImageExposed = {\n  showFitMode: () => Promise<void>\n  showActualSize: () => Promise<void>\n  zoomIn: () => Promise<void>\n  zoomOut: () => Promise<void>\n}\n\ntype GalleryContentRef = {\n  scrollToIndex: (index: number) => void\n  getCardRect: (index: number) => DOMRect | null\n} | null\n\nconst props = defineProps<{\n  galleryContentRef: GalleryContentRef\n}>()\n\nconst emit = defineEmits<{\n  requestReverseHero: []\n}>()\n\nconst store = useGalleryStore()\nconst lightbox = useGalleryLightbox()\nconst gallerySelection = useGallerySelection()\nconst assetActions = useGalleryAssetActions()\nconst { t } = useI18n()\nconst lightboxImageRef = ref<LightboxImageExposed | null>(null)\nconst lightboxRootRef = ref<HTMLElement | null>(null)\n\nconst isImmersive = computed(() => store.lightbox.isImmersive)\nconst isClosing = computed(() => store.lightbox.isClosing)\nconst showFilmstrip = computed(() => store.lightbox.showFilmstrip)\nconst fitMode = computed(() => store.lightbox.fitMode)\nconst currentAsset = computed(() => {\n  const currentIndex = store.selection.activeIndex\n  if (currentIndex === undefined) {\n    return null\n  }\n\n  return store.getAssetsInRange(currentIndex, currentIndex)[0]\n})\n// 缩放、适屏、1:1 都是静态图查看语义；视频在灯箱里保持原生播放器行为。\nconst isZoomableAsset = computed(() => currentAsset.value?.type !== 'video')\nconst lightboxRootClass = computed(() => {\n  const immersive = isImmersive.value\n  const closing = store.lightbox.isClosing\n  let cls = immersive\n    ? 'surface-bottom fixed inset-0 z-[100] flex overflow-hidden shadow-2xl'\n    : 'absolute inset-0 z-10 flex h-full w-full overflow-hidden'\n  if (immersive && closing) {\n    cls += ' pointer-events-none opacity-0 transition-opacity duration-[280ms] ease-out'\n  }\n  return cls\n})\n\nconst throttledPrevious = useThrottleFn(() => {\n  if (store.lightbox.isOpen) {\n    lightbox.goToPrevious()\n  }\n}, 200)\n\nconst throttledNext = useThrottleFn(() => {\n  if (store.lightbox.isOpen) {\n    lightbox.goToNext()\n  }\n}, 200)\n\nconst throttledZoomIn = useThrottleFn(() => {\n  if (store.lightbox.isOpen) {\n    void lightboxImageRef.value?.zoomIn()\n  }\n}, 60)\n\nconst throttledZoomOut = useThrottleFn(() => {\n  if (store.lightbox.isOpen) {\n    void lightboxImageRef.value?.zoomOut()\n  }\n}, 60)\n\nfunction isEditableTarget(target: EventTarget | null): boolean {\n  if (!(target instanceof HTMLElement)) {\n    return false\n  }\n\n  return target.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)\n}\n\nfunction enterImmersive() {\n  if (isImmersive.value) {\n    return\n  }\n  lightbox.setImmersive(true)\n}\n\nfunction exitImmersive() {\n  if (!isImmersive.value) {\n    return\n  }\n  lightbox.setImmersive(false)\n}\n\nfunction handleClose() {\n  if (store.lightbox.isClosing) return\n  store.setLightboxClosing(true)\n\n  let didReverseHero = false\n  // gallery 在打开时用 opacity 隐藏但仍可布局，可直接同步读取 cardRect\n  const activeIndex = store.selection.activeIndex\n  if (activeIndex !== undefined) {\n    const galleryContent = props.galleryContentRef\n    if (galleryContent) {\n      const cardRect = galleryContent.getCardRect(activeIndex)\n      const asset = store.getAssetsInRange(activeIndex, activeIndex)[0]\n      const containerRect = lightboxRootRef.value?.getBoundingClientRect()\n      if (cardRect && asset && containerRect) {\n        const fromRect = computeLightboxHeroRect(\n          containerRect,\n          asset.width ?? 1,\n          asset.height ?? 1,\n          store.lightbox.showFilmstrip\n        )\n        prepareReverseHero(fromRect, cardRect, galleryApi.getAssetThumbnailUrl(asset))\n        emit('requestReverseHero')\n        didReverseHero = true\n      }\n    }\n  }\n\n  const delay = didReverseHero ? CLOSE_AFTER_REVERSE_HERO_MS : CLOSE_AFTER_NO_HERO_MS\n  window.setTimeout(() => {\n    lightbox.closeLightbox()\n  }, delay)\n}\n\nfunction handleToolbarFit() {\n  if (!isZoomableAsset.value) {\n    return\n  }\n  void lightboxImageRef.value?.showFitMode()\n}\n\nfunction handleToolbarActual() {\n  if (!isZoomableAsset.value) {\n    return\n  }\n  void lightboxImageRef.value?.showActualSize()\n}\n\nfunction handleToolbarZoomIn() {\n  if (!isZoomableAsset.value) {\n    return\n  }\n  void lightboxImageRef.value?.zoomIn()\n}\n\nfunction handleToolbarZoomOut() {\n  if (!isZoomableAsset.value) {\n    return\n  }\n  void lightboxImageRef.value?.zoomOut()\n}\n\nfunction handleToolbarToggleFilmstrip() {\n  lightbox.toggleFilmstrip()\n}\n\nfunction handleToolbarToggleImmersive() {\n  if (isImmersive.value) {\n    exitImmersive()\n  } else {\n    enterImmersive()\n  }\n}\n\nfunction handleImageContextMenu(event: MouseEvent) {\n  const asset = currentAsset.value\n  const currentIndex = store.selection.activeIndex\n  if (!asset || currentIndex === undefined) {\n    return\n  }\n\n  void gallerySelection.handleAssetContextMenu(asset, event, currentIndex)\n}\n\nfunction handleMediaWheel(event: WheelEvent) {\n  if (!store.lightbox.isOpen || !currentAsset.value || event.deltaY === 0) {\n    return\n  }\n\n  if (isZoomableAsset.value && (event.ctrlKey || fitMode.value === 'actual')) {\n    return\n  }\n\n  event.preventDefault()\n\n  if (event.deltaY > 0) {\n    lightbox.goToNext()\n  } else {\n    lightbox.goToPrevious()\n  }\n}\n\nfunction handleKeydown(event: KeyboardEvent) {\n  if (!store.lightbox.isOpen || isEditableTarget(event.target)) {\n    return\n  }\n\n  switch (event.key) {\n    case 'ArrowLeft':\n      event.preventDefault()\n      throttledPrevious()\n      return\n    case 'ArrowRight':\n      event.preventDefault()\n      throttledNext()\n      return\n    case 'Escape':\n      event.preventDefault()\n      if (isImmersive.value) {\n        exitImmersive()\n        return\n      }\n      handleClose()\n      return\n    case 'f':\n    case 'F':\n      event.preventDefault()\n      handleToolbarToggleImmersive()\n      return\n    case 'Tab':\n      event.preventDefault()\n      handleToolbarToggleFilmstrip()\n      return\n    case '0':\n      event.preventDefault()\n      // 让 0~5 与 Lightroom 的审片习惯保持一致；缩放切换改由 Z 负责。\n      void assetActions.clearSelectedAssetsRating()\n      return\n    case '1':\n      event.preventDefault()\n      void assetActions.setSelectedAssetsRating(1)\n      return\n    case '2':\n      event.preventDefault()\n      void assetActions.setSelectedAssetsRating(2)\n      return\n    case '3':\n      event.preventDefault()\n      void assetActions.setSelectedAssetsRating(3)\n      return\n    case '4':\n      event.preventDefault()\n      void assetActions.setSelectedAssetsRating(4)\n      return\n    case '5':\n      event.preventDefault()\n      void assetActions.setSelectedAssetsRating(5)\n      return\n    case 'z':\n    case 'Z':\n      event.preventDefault()\n      if (isZoomableAsset.value) {\n        lightbox.toggleFitActual()\n      }\n      return\n    case 'x':\n    case 'X':\n      event.preventDefault()\n      if (currentAsset.value?.reviewFlag === 'rejected') {\n        void assetActions.clearSelectedAssetsRejected()\n      } else {\n        void assetActions.setSelectedAssetsRejected()\n      }\n      return\n    case '=':\n    case '+':\n      event.preventDefault()\n      if (isZoomableAsset.value) {\n        throttledZoomIn()\n      }\n      return\n    case '-':\n    case '_':\n      event.preventDefault()\n      if (isZoomableAsset.value) {\n        throttledZoomOut()\n      }\n      return\n    default:\n      if (event.code === 'NumpadAdd' && isZoomableAsset.value) {\n        event.preventDefault()\n        throttledZoomIn()\n      } else if (event.code === 'NumpadSubtract' && isZoomableAsset.value) {\n        event.preventDefault()\n        throttledZoomOut()\n      }\n  }\n}\n\nuseEventListener(window, 'keydown', handleKeydown)\n</script>\n\n<template>\n  <Teleport to=\"body\" :disabled=\"!isImmersive\">\n    <div\n      ref=\"lightboxRootRef\"\n      class=\"lightbox-container\"\n      :class=\"lightboxRootClass\"\n      style=\"--surface-opacity-scale: 0.96\"\n      @click.self=\"handleClose\"\n    >\n      <div class=\"flex h-full min-h-0 w-full flex-col\">\n        <Transition\n          appear\n          enter-active-class=\"transition-opacity duration-[200ms] ease-out\"\n          enter-from-class=\"opacity-0\"\n          enter-to-class=\"opacity-100\"\n          leave-active-class=\"transition-opacity duration-[160ms] ease-in\"\n          leave-from-class=\"opacity-100\"\n          leave-to-class=\"opacity-0\"\n        >\n          <LightboxToolbar\n            v-if=\"!isClosing\"\n            @back=\"handleClose\"\n            @fit=\"handleToolbarFit\"\n            @actual=\"handleToolbarActual\"\n            @zoom-in=\"handleToolbarZoomIn\"\n            @zoom-out=\"handleToolbarZoomOut\"\n            @toggle-filmstrip=\"handleToolbarToggleFilmstrip\"\n            @toggle-immersive=\"handleToolbarToggleImmersive\"\n          />\n        </Transition>\n\n        <ContextMenu v-if=\"currentAsset\">\n          <ContextMenuTrigger as-child>\n            <div\n              class=\"min-h-0 flex-1 transition-opacity duration-[180ms]\"\n              :class=\"isClosing ? 'opacity-0' : 'opacity-100'\"\n              @contextmenu=\"handleImageContextMenu\"\n              @wheel=\"handleMediaWheel\"\n            >\n              <LightboxImage v-if=\"isZoomableAsset\" ref=\"lightboxImageRef\" />\n              <LightboxVideo v-else @previous=\"throttledPrevious\" @next=\"throttledNext\" />\n            </div>\n          </ContextMenuTrigger>\n          <ContextMenuContent class=\"w-56\">\n            <template v-if=\"isZoomableAsset\">\n              <GalleryAssetContextMenuContent />\n              <ContextMenuSeparator />\n              <ContextMenuItem inset @click=\"handleToolbarFit\">\n                {{ t('gallery.lightbox.contextMenu.fit') }}\n              </ContextMenuItem>\n              <ContextMenuItem @click=\"handleToolbarActual\">\n                <Maximize class=\"size-4 text-muted-foreground\" />\n                {{ t('gallery.lightbox.contextMenu.actual') }}\n              </ContextMenuItem>\n              <ContextMenuItem inset @click=\"handleToolbarZoomIn\">\n                {{ t('gallery.lightbox.contextMenu.zoomIn') }}\n              </ContextMenuItem>\n              <ContextMenuItem inset @click=\"handleToolbarZoomOut\">\n                {{ t('gallery.lightbox.contextMenu.zoomOut') }}\n              </ContextMenuItem>\n              <ContextMenuSeparator />\n            </template>\n            <ContextMenuItem inset @click=\"handleToolbarToggleFilmstrip\">\n              {{\n                showFilmstrip\n                  ? t('gallery.lightbox.contextMenu.hideFilmstrip')\n                  : t('gallery.lightbox.contextMenu.showFilmstrip')\n              }}\n            </ContextMenuItem>\n            <ContextMenuItem inset @click=\"handleToolbarToggleImmersive\">\n              {{\n                isImmersive\n                  ? t('gallery.lightbox.contextMenu.exitImmersive')\n                  : t('gallery.lightbox.contextMenu.immersive')\n              }}\n            </ContextMenuItem>\n          </ContextMenuContent>\n        </ContextMenu>\n        <div\n          v-else\n          class=\"min-h-0 flex-1 transition-opacity duration-[180ms]\"\n          :class=\"isClosing ? 'opacity-0' : 'opacity-100'\"\n        >\n          <LightboxImage ref=\"lightboxImageRef\" />\n        </div>\n\n        <Transition\n          appear\n          enter-active-class=\"transition-all duration-300\"\n          enter-from-class=\"translate-y-full opacity-0\"\n          enter-to-class=\"translate-y-0 opacity-100\"\n          leave-active-class=\"transition-all duration-300\"\n          leave-from-class=\"translate-y-0 opacity-100\"\n          leave-to-class=\"translate-y-full opacity-0\"\n        >\n          <LightboxFilmstrip v-if=\"showFilmstrip && !isClosing\" />\n        </Transition>\n      </div>\n    </div>\n  </Teleport>\n</template>\n"
  },
  {
    "path": "web/src/features/gallery/components/lightbox/LightboxFilmstrip.vue",
    "content": "<script setup lang=\"ts\">\nimport { ref, computed, watch, nextTick, onMounted } from 'vue'\nimport { useVirtualizer } from '@tanstack/vue-virtual'\nimport { Play } from 'lucide-vue-next'\nimport { galleryApi } from '../../api'\nimport { useGalleryContextMenu, useGalleryData, useGallerySelection } from '../../composables'\nimport { useGalleryStore } from '../../store'\nimport ScrollArea from '@/components/ui/scroll-area/ScrollArea.vue'\nimport ScrollBar from '@/components/ui/scroll-area/ScrollBar.vue'\nimport MediaStatusChips from '../asset/MediaStatusChips.vue'\n\nconst store = useGalleryStore()\nconst gallerySelection = useGallerySelection()\nconst galleryData = useGalleryData()\nconst galleryContextMenu = useGalleryContextMenu()\n\nconst scrollAreaRef = ref<InstanceType<typeof ScrollArea> | null>(null)\nconst filmstripRef = ref<HTMLElement | null>(null)\nconst loadingPages = ref<Set<number>>(new Set())\n\nconst THUMBNAIL_SIZE = 64\nconst THUMBNAIL_GAP = 12\n\nconst totalCount = computed(() => store.totalCount)\nconst currentIndex = computed(() => store.selection.activeIndex ?? 0)\nconst selectedIds = computed(() => store.selection.selectedIds)\nconst perPage = computed(() => store.perPage)\n\n// 胶片条使用横向虚拟列表，只渲染可见缩略图，避免灯箱场景下全量节点开销。\nconst virtualizer = useVirtualizer({\n  get count() {\n    return totalCount.value\n  },\n  getScrollElement: () => filmstripRef.value,\n  estimateSize: () => THUMBNAIL_SIZE + THUMBNAIL_GAP,\n  horizontal: true,\n  scrollPaddingEnd: 24,\n  scrollPaddingStart: 6,\n  overscan: 5,\n})\n\nwatch(scrollAreaRef, (newRef) => {\n  if (newRef) {\n    // ScrollArea 的真实横向滚动容器在挂载后才可用。\n    filmstripRef.value = newRef.viewportElement\n  }\n})\n\nfunction getAssetAtIndex(index: number) {\n  return store.getAssetsInRange(index, index)[0]\n}\n\nconst virtualItems = computed(() => virtualizer.value.getVirtualItems())\n\nconst filmstripItems = computed(() => {\n  // 把模板里重复读取的派生字段前置到 computed，降低渲染阶段重复计算。\n  return virtualItems.value.map((item) => {\n    const asset = getAssetAtIndex(item.index)\n    const isSelected = asset ? selectedIds.value.has(asset.id) : false\n    return {\n      ...item,\n      asset,\n      thumbnailUrl: asset ? galleryApi.getAssetThumbnailUrl(asset) : '',\n      isSelected,\n      isVideo: asset?.type === 'video',\n      isCurrent: item.index === currentIndex.value,\n      rating: asset?.rating ?? 0,\n      reviewFlag: asset?.reviewFlag ?? 'none',\n    }\n  })\n})\n\nasync function loadMissingData(\n  items: ReturnType<typeof virtualizer.value.getVirtualItems>\n): Promise<void> {\n  if (items.length === 0) {\n    return\n  }\n\n  const visibleIndexes = items.map((item) => item.index)\n  const neededPages = new Set(visibleIndexes.map((idx) => Math.floor(idx / perPage.value) + 1))\n  const loadPromises: Promise<void>[] = []\n\n  neededPages.forEach((pageNum) => {\n    if (!store.isPageLoaded(pageNum) && !loadingPages.value.has(pageNum)) {\n      // 可见区按页懒加载，避免滚动触发重复请求同一页。\n      loadingPages.value.add(pageNum)\n      loadPromises.push(\n        galleryData.loadPage(pageNum).finally(() => {\n          loadingPages.value.delete(pageNum)\n        })\n      )\n    }\n  })\n\n  if (loadPromises.length > 0) {\n    await Promise.all(loadPromises)\n  }\n}\n\nwatch(\n  () => ({\n    items: virtualizer.value.getVirtualItems(),\n    totalCount: totalCount.value,\n  }),\n  async ({ items }) => {\n    await loadMissingData(items)\n  }\n)\n\nonMounted(() => {\n  // 首次进入灯箱时让当前 active 资产尽量位于胶片条可视区域中心。\n  nextTick(() => {\n    virtualizer.value.scrollToIndex(currentIndex.value, {\n      align: 'center',\n    })\n  })\n})\n\nwatch(currentIndex, (newIndex) => {\n  // 灯箱切图时保持胶片条跟随当前索引。\n  nextTick(() => {\n    virtualizer.value.scrollToIndex(newIndex, {\n      align: 'auto',\n    })\n  })\n})\n\nfunction handleThumbnailClick(index: number, event: MouseEvent) {\n  const asset = getAssetAtIndex(index)\n  if (!asset) {\n    return\n  }\n\n  void gallerySelection.handleAssetClick(asset, event, index)\n}\n\nfunction handleThumbnailContextMenu(index: number, event: MouseEvent) {\n  const asset = getAssetAtIndex(index)\n  if (!asset) {\n    return\n  }\n\n  event.preventDefault()\n  event.stopPropagation()\n  // 先同步 selection 语义，再打开共享右键菜单，和主视图保持一致。\n  void gallerySelection.handleAssetContextMenu(asset, event, index).then(() => {\n    galleryContextMenu.openForAsset({ asset, event, index, sourceView: 'filmstrip' })\n  })\n}\n\nfunction handleWheel(event: WheelEvent) {\n  if (filmstripRef.value) {\n    // 统一把纵向滚轮映射为横向滚动，提升鼠标滚轮浏览胶片条的效率。\n    filmstripRef.value.scrollLeft += event.deltaY\n  }\n}\n</script>\n\n<template>\n  <div class=\"flex flex-col\">\n    <ScrollArea ref=\"scrollAreaRef\" class=\"filmstrip-scroller\" @wheel.prevent=\"handleWheel\">\n      <div class=\"px-4 py-3\">\n        <div\n          :style=\"{\n            width: `${virtualizer.getTotalSize()}px`,\n            position: 'relative',\n          }\"\n        >\n          <div\n            v-for=\"item in filmstripItems\"\n            :key=\"item.index\"\n            :data-index=\"item.index\"\n            :style=\"{\n              position: 'absolute',\n              top: 0,\n              left: 0,\n              width: `${THUMBNAIL_SIZE}px`,\n              height: `${THUMBNAIL_SIZE}px`,\n              transform: `translateX(${item.start}px)`,\n            }\"\n          >\n            <div\n              v-if=\"item.asset\"\n              class=\"filmstrip-thumbnail group relative h-full w-full cursor-pointer overflow-hidden rounded transition-all duration-200 select-none\"\n              :class=\"{\n                'scale-110 ring-2 ring-primary ring-offset-4': item.isCurrent,\n                'bg-foreground/20': item.isSelected,\n              }\"\n              @click=\"handleThumbnailClick(item.index, $event)\"\n              @contextmenu=\"handleThumbnailContextMenu(item.index, $event)\"\n              @selectstart.prevent\n            >\n              <img\n                :src=\"item.thumbnailUrl\"\n                :alt=\"item.asset.name\"\n                class=\"h-full w-full object-contain object-center\"\n                draggable=\"false\"\n                @dragstart.prevent\n              />\n\n              <div\n                class=\"absolute inset-0 bg-transparent transition-all group-hover:bg-foreground/10\"\n                :class=\"{\n                  'bg-foreground/12': item.isSelected,\n                  'bg-foreground/18': item.isCurrent,\n                }\"\n              />\n\n              <div\n                v-if=\"item.isVideo\"\n                class=\"absolute inset-x-0 bottom-0 flex items-end justify-start bg-gradient-to-t from-black/55 via-black/10 to-transparent p-1.5\"\n              >\n                <div\n                  class=\"flex h-5 w-5 items-center justify-center rounded-full border border-white/20 bg-black/60 text-white shadow-sm\"\n                >\n                  <Play class=\"ml-px h-2.5 w-2.5 fill-current\" />\n                </div>\n              </div>\n\n              <MediaStatusChips :rating=\"item.rating\" :review-flag=\"item.reviewFlag\" compact />\n\n              <div\n                v-if=\"item.isSelected\"\n                class=\"absolute top-1 right-1 rounded-full bg-primary p-0.5 text-primary-foreground\"\n              >\n                <svg class=\"h-3 w-3\" fill=\"currentColor\" viewBox=\"0 0 20 20\">\n                  <path\n                    fill-rule=\"evenodd\"\n                    d=\"M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z\"\n                    clip-rule=\"evenodd\"\n                  />\n                </svg>\n              </div>\n            </div>\n\n            <div v-else class=\"h-full w-full animate-pulse rounded bg-muted\" />\n          </div>\n        </div>\n      </div>\n      <ScrollBar orientation=\"horizontal\" />\n    </ScrollArea>\n  </div>\n</template>\n\n<style scoped>\n.filmstrip-scroller {\n  height: 100px;\n}\n\n.filmstrip-thumbnail {\n  user-select: none;\n  -webkit-user-select: none;\n  transition:\n    transform 0.2s ease,\n    box-shadow 0.2s ease;\n}\n</style>\n"
  },
  {
    "path": "web/src/features/gallery/components/lightbox/LightboxImage.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed, nextTick, ref, watch } from 'vue'\nimport { useElementSize } from '@vueuse/core'\nimport { galleryApi } from '../../api'\nimport { useGalleryData, useGalleryLightbox } from '../../composables'\nimport { useGalleryStore } from '../../store'\nimport { useI18n } from '@/composables/useI18n'\nimport { heroAnimating } from '../../composables/useHeroTransition'\n\nconst VIEWPORT_PADDING = 32\nconst ZOOM_STEP = 1.1\n// 缩放吸附容差：实际缩放比例接近 fitScale 的 1.02 倍以内时，自动吸附回适合模式\nconst FIT_MODE_SNAP_RATIO = 1.02\nconst DRAG_THRESHOLD = 4\nconst MIN_ACTUAL_ZOOM = 0.05\nconst MAX_ACTUAL_ZOOM = 5\n\ninterface ZoomAnchor {\n  pointerX: number\n  pointerY: number\n  imageX: number\n  imageY: number\n}\n\nconst { t } = useI18n()\nconst store = useGalleryStore()\nconst lightbox = useGalleryLightbox()\nconst galleryData = useGalleryData()\n\nconst imageError = ref(false)\nconst originalLoaded = ref(false)\nconst autoRecovering = ref(false)\nconst switchingFrame = ref(false)\nconst previousFrame = ref<{\n  id: number\n  name: string\n  thumbnailUrl: string\n} | null>(null)\nconst viewportRef = ref<HTMLElement | null>(null)\nconst stageRef = ref<HTMLElement | null>(null)\nconst activePointerId = ref<number | null>(null)\nconst dragStartX = ref(0)\nconst dragStartY = ref(0)\nconst dragStartScrollLeft = ref(0)\nconst dragStartScrollTop = ref(0)\nconst dragMoved = ref(false)\n// 拖拽结束后屏蔽紧随而来的 click 事件，防止误触切换缩放模式\nconst suppressClick = ref(false)\nlet suppressClickResetTimer: number | null = null\n\nconst { width, height } = useElementSize(viewportRef)\n\nconst availableWidth = computed(() => width.value)\nconst availableHeight = computed(() => height.value)\nconst viewportInnerWidth = computed(() => Math.max(availableWidth.value - VIEWPORT_PADDING * 2, 1))\nconst viewportInnerHeight = computed(() =>\n  Math.max(availableHeight.value - VIEWPORT_PADDING * 2, 1)\n)\n\nconst currentAsset = computed(() => {\n  const currentIdx = store.selection.activeIndex\n  if (currentIdx === undefined) {\n    return null\n  }\n\n  return store.getAssetsInRange(currentIdx, currentIdx)[0]\n})\n\nconst thumbnailUrl = computed(() => {\n  if (!currentAsset.value) return ''\n  return galleryApi.getAssetThumbnailUrl(currentAsset.value)\n})\n\nconst originalUrl = computed(() => {\n  if (!currentAsset.value) return ''\n  return galleryData.getAssetUrl(currentAsset.value)\n})\n\nconst canGoToPrevious = computed(() => (store.selection.activeIndex ?? 0) > 0)\nconst canGoToNext = computed(() => (store.selection.activeIndex ?? 0) < store.totalCount - 1)\nconst fitMode = computed(() => store.lightbox.fitMode)\nconst actualZoom = computed(() => store.lightbox.zoom)\n\nconst imageWidth = computed(() => currentAsset.value?.width || 0)\nconst imageHeight = computed(() => currentAsset.value?.height || 0)\nconst hasImageDimensions = computed(() => imageWidth.value > 0 && imageHeight.value > 0)\n\nconst fitScale = computed(() => {\n  if (\n    !hasImageDimensions.value ||\n    viewportInnerWidth.value <= 0 ||\n    viewportInnerHeight.value <= 0\n  ) {\n    return 1\n  }\n\n  return Math.min(\n    viewportInnerWidth.value / imageWidth.value,\n    viewportInnerHeight.value / imageHeight.value,\n    1\n  )\n})\n\nconst displayScale = computed(() => {\n  if (fitMode.value === 'contain') {\n    return fitScale.value\n  }\n\n  return actualZoom.value\n})\n\nconst renderWidth = computed(() => {\n  if (!hasImageDimensions.value) {\n    return Math.max(viewportInnerWidth.value, 1)\n  }\n\n  return Math.max(imageWidth.value * displayScale.value, 1)\n})\n\nconst renderHeight = computed(() => {\n  if (!hasImageDimensions.value) {\n    return Math.max(viewportInnerHeight.value, 1)\n  }\n\n  return Math.max(imageHeight.value * displayScale.value, 1)\n})\n\nconst canvasWidth = computed(\n  () => Math.max(renderWidth.value, viewportInnerWidth.value) + VIEWPORT_PADDING * 2\n)\nconst canvasHeight = computed(\n  () => Math.max(renderHeight.value, viewportInnerHeight.value) + VIEWPORT_PADDING * 2\n)\n\nconst canvasStyle = computed(() => ({\n  width: `${canvasWidth.value}px`,\n  height: `${canvasHeight.value}px`,\n  padding: `${VIEWPORT_PADDING}px`,\n}))\n\nconst isPannable = computed(\n  () =>\n    fitMode.value === 'actual' &&\n    (renderWidth.value > viewportInnerWidth.value || renderHeight.value > viewportInnerHeight.value)\n)\n\nconst isDragging = computed(() => activePointerId.value !== null)\n\nconst stageCursor = computed(() => {\n  if (!currentAsset.value || imageError.value) {\n    return 'default'\n  }\n\n  if (fitMode.value === 'contain') {\n    return 'zoom-in'\n  }\n\n  if (isPannable.value) {\n    return isDragging.value ? 'grabbing' : 'grab'\n  }\n\n  return 'zoom-out'\n})\n\nconst stageStyle = computed(() => ({\n  width: `${renderWidth.value}px`,\n  height: `${renderHeight.value}px`,\n  cursor: stageCursor.value,\n  touchAction: isPannable.value ? 'none' : 'auto',\n}))\n\nconst zoomIndicator = computed(() => {\n  if (fitMode.value === 'contain') {\n    return t('gallery.lightbox.image.fitIndicator', { percent: Math.round(fitScale.value * 100) })\n  }\n\n  return `${Math.round(actualZoom.value * 100)}%`\n})\n\nfunction finishFrameSwitch() {\n  if (!switchingFrame.value) {\n    return\n  }\n\n  switchingFrame.value = false\n  previousFrame.value = null\n}\n\nwatch(\n  () => currentAsset.value,\n  async (newAsset, oldAsset) => {\n    if (oldAsset && newAsset && oldAsset.id !== newAsset.id && !imageError.value) {\n      previousFrame.value = {\n        id: oldAsset.id,\n        name: oldAsset.name,\n        thumbnailUrl: galleryApi.getAssetThumbnailUrl(oldAsset),\n      }\n      switchingFrame.value = true\n    } else if (!newAsset || !oldAsset) {\n      previousFrame.value = null\n      switchingFrame.value = false\n    }\n\n    originalLoaded.value = false\n    imageError.value = false\n    resetPointerState()\n    suppressClick.value = false\n\n    await nextTick()\n    syncViewportPosition()\n  },\n  { immediate: true, flush: 'post' }\n)\n\nwatch(\n  [availableWidth, availableHeight],\n  async () => {\n    await nextTick()\n    if (fitMode.value === 'contain') {\n      syncViewportPosition()\n      return\n    }\n\n    clampViewportScroll()\n  },\n  { flush: 'post' }\n)\n\nfunction clamp(value: number, min: number, max: number): number {\n  return Math.min(Math.max(value, min), max)\n}\n\nfunction clampActualZoom(zoom: number): number {\n  return clamp(zoom, MIN_ACTUAL_ZOOM, MAX_ACTUAL_ZOOM)\n}\n\nfunction clampViewportScroll() {\n  const viewport = viewportRef.value\n  if (!viewport) return\n\n  viewport.scrollLeft = clamp(\n    viewport.scrollLeft,\n    0,\n    Math.max(viewport.scrollWidth - viewport.clientWidth, 0)\n  )\n  viewport.scrollTop = clamp(\n    viewport.scrollTop,\n    0,\n    Math.max(viewport.scrollHeight - viewport.clientHeight, 0)\n  )\n}\n\nfunction setViewportScroll(left: number, top: number) {\n  const viewport = viewportRef.value\n  if (!viewport) return\n\n  viewport.scrollLeft = clamp(left, 0, Math.max(viewport.scrollWidth - viewport.clientWidth, 0))\n  viewport.scrollTop = clamp(top, 0, Math.max(viewport.scrollHeight - viewport.clientHeight, 0))\n}\n\nfunction getCurrentScale(): number {\n  return fitMode.value === 'contain' ? fitScale.value : actualZoom.value\n}\n\nfunction syncViewportPosition() {\n  const viewport = viewportRef.value\n  if (!viewport) return\n\n  if (fitMode.value === 'contain') {\n    viewport.scrollLeft = 0\n    viewport.scrollTop = 0\n    return\n  }\n\n  setViewportScroll(\n    Math.max((canvasWidth.value - availableWidth.value) / 2, 0),\n    Math.max((canvasHeight.value - availableHeight.value) / 2, 0)\n  )\n}\n\nfunction getViewportCenterClientPoint() {\n  const viewport = viewportRef.value\n  if (!viewport) return null\n\n  const rect = viewport.getBoundingClientRect()\n  return {\n    clientX: rect.left + rect.width / 2,\n    clientY: rect.top + rect.height / 2,\n  }\n}\n\n/**\n * 计算以 (clientX, clientY) 为锚点的缩放锚信息。\n * 缩放后调用 restoreZoomAnchor 可使该像素点在视口中的位置保持不变，\n * 实现「以鼠标/视口中心为原点」的平滑缩放体验。\n */\nfunction getZoomAnchor(clientX: number, clientY: number, scale: number): ZoomAnchor | null {\n  const viewport = viewportRef.value\n  const stage = stageRef.value\n  if (!viewport || !stage || scale <= 0 || !hasImageDimensions.value) {\n    return null\n  }\n\n  const viewportRect = viewport.getBoundingClientRect()\n  const stageRect = stage.getBoundingClientRect()\n  const pointerX = clamp(clientX - viewportRect.left, 0, viewportRect.width)\n  const pointerY = clamp(clientY - viewportRect.top, 0, viewportRect.height)\n  const stageOffsetLeft = viewport.scrollLeft + (stageRect.left - viewportRect.left)\n  const stageOffsetTop = viewport.scrollTop + (stageRect.top - viewportRect.top)\n\n  return {\n    pointerX,\n    pointerY,\n    imageX: clamp((viewport.scrollLeft + pointerX - stageOffsetLeft) / scale, 0, imageWidth.value),\n    imageY: clamp((viewport.scrollTop + pointerY - stageOffsetTop) / scale, 0, imageHeight.value),\n  }\n}\n\nasync function restoreZoomAnchor(anchor: ZoomAnchor, scale: number) {\n  await nextTick()\n\n  const viewport = viewportRef.value\n  const stage = stageRef.value\n  if (!viewport || !stage) {\n    return\n  }\n\n  const viewportRect = viewport.getBoundingClientRect()\n  const stageRect = stage.getBoundingClientRect()\n  const stageOffsetLeft = viewport.scrollLeft + (stageRect.left - viewportRect.left)\n  const stageOffsetTop = viewport.scrollTop + (stageRect.top - viewportRect.top)\n\n  setViewportScroll(\n    stageOffsetLeft + anchor.imageX * scale - anchor.pointerX,\n    stageOffsetTop + anchor.imageY * scale - anchor.pointerY\n  )\n}\n\nasync function showFitMode() {\n  lightbox.showFitMode()\n  await nextTick()\n  syncViewportPosition()\n}\n\nasync function zoomToScaleAtPoint(\n  targetScale: number,\n  clientX: number,\n  clientY: number,\n  options: { snapToFit?: boolean } = {}\n) {\n  if (!currentAsset.value || imageError.value || !hasImageDimensions.value) {\n    return\n  }\n\n  const snapToFit = options.snapToFit ?? true\n  const clampedScale = clampActualZoom(targetScale)\n\n  if (snapToFit && clampedScale <= fitScale.value * FIT_MODE_SNAP_RATIO) {\n    await showFitMode()\n    return\n  }\n\n  const anchor = getZoomAnchor(clientX, clientY, getCurrentScale())\n  lightbox.setActualZoom(clampedScale)\n\n  if (!anchor) {\n    await nextTick()\n    syncViewportPosition()\n    return\n  }\n\n  await restoreZoomAnchor(anchor, clampedScale)\n}\n\nasync function zoomToScaleAtCenter(targetScale: number, options: { snapToFit?: boolean } = {}) {\n  const center = getViewportCenterClientPoint()\n  if (!center) {\n    if ((options.snapToFit ?? true) && targetScale <= fitScale.value * FIT_MODE_SNAP_RATIO) {\n      await showFitMode()\n      return\n    }\n\n    lightbox.setActualZoom(clampActualZoom(targetScale))\n    await nextTick()\n    syncViewportPosition()\n    return\n  }\n\n  await zoomToScaleAtPoint(targetScale, center.clientX, center.clientY, options)\n}\n\nasync function showActualSizeAtPoint(clientX: number, clientY: number) {\n  await zoomToScaleAtPoint(1, clientX, clientY, { snapToFit: false })\n}\n\nasync function showActualSize() {\n  await zoomToScaleAtCenter(1, { snapToFit: false })\n}\n\nasync function zoomIn() {\n  await zoomToScaleAtCenter(getCurrentScale() * ZOOM_STEP)\n}\n\nasync function zoomOut() {\n  await zoomToScaleAtCenter(getCurrentScale() / ZOOM_STEP)\n}\n\nfunction scheduleSuppressClickReset() {\n  if (suppressClickResetTimer !== null) {\n    window.clearTimeout(suppressClickResetTimer)\n  }\n\n  suppressClickResetTimer = window.setTimeout(() => {\n    suppressClick.value = false\n    suppressClickResetTimer = null\n  }, 0)\n}\n\nfunction resetPointerState(pointerId?: number) {\n  if (pointerId !== undefined && stageRef.value?.hasPointerCapture(pointerId)) {\n    stageRef.value.releasePointerCapture(pointerId)\n  }\n\n  activePointerId.value = null\n  dragMoved.value = false\n}\n\nfunction handleOriginalLoad() {\n  originalLoaded.value = true\n  finishFrameSwitch()\n}\n\nfunction handleThumbnailLoad() {\n  finishFrameSwitch()\n}\n\nfunction isRootMappedOriginalUrl(url: string): boolean {\n  return /^https:\\/\\/r-\\d+\\.test\\//i.test(url)\n}\n\nasync function tryAutoRecoverByReload() {\n  if (autoRecovering.value) {\n    return\n  }\n\n  const asset = currentAsset.value\n  if (!asset) {\n    return\n  }\n\n  if (!isRootMappedOriginalUrl(originalUrl.value)) {\n    return\n  }\n\n  const currentUrl = new URL(window.location.href)\n  if (currentUrl.searchParams.get('lbRetry') === '1') {\n    return\n  }\n\n  const reachability = await galleryApi.checkAssetReachable(asset.id)\n  if (!reachability.exists || !reachability.readable) {\n    return\n  }\n\n  autoRecovering.value = true\n  currentUrl.searchParams.set('lbAssetId', String(asset.id))\n  currentUrl.searchParams.set('lbFolderId', store.filter.folderId ?? 'all')\n  currentUrl.searchParams.set('lbRetry', '1')\n  window.location.replace(currentUrl.toString())\n}\n\nfunction handleImageError() {\n  imageError.value = true\n  finishFrameSwitch()\n\n  void tryAutoRecoverByReload().catch((error) => {\n    console.warn('Failed to recover lightbox image:', error)\n  })\n}\n\nfunction handlePrevious() {\n  lightbox.goToPrevious()\n}\n\nfunction handleNext() {\n  lightbox.goToNext()\n}\n\nasync function handleStageClick(event: MouseEvent) {\n  if (suppressClick.value) {\n    suppressClick.value = false\n    return\n  }\n\n  if (!currentAsset.value || imageError.value) {\n    return\n  }\n\n  if (fitMode.value === 'contain') {\n    await showActualSizeAtPoint(event.clientX, event.clientY)\n    return\n  }\n\n  await showFitMode()\n}\n\nfunction handleStagePointerDown(event: PointerEvent) {\n  if (event.button !== 0 || !isPannable.value || !viewportRef.value || !stageRef.value) {\n    return\n  }\n\n  activePointerId.value = event.pointerId\n  dragStartX.value = event.clientX\n  dragStartY.value = event.clientY\n  dragStartScrollLeft.value = viewportRef.value.scrollLeft\n  dragStartScrollTop.value = viewportRef.value.scrollTop\n  dragMoved.value = false\n  suppressClick.value = false\n  stageRef.value.setPointerCapture(event.pointerId)\n  event.preventDefault()\n}\n\nfunction handleStagePointerMove(event: PointerEvent) {\n  if (activePointerId.value !== event.pointerId || !viewportRef.value) {\n    return\n  }\n\n  const deltaX = event.clientX - dragStartX.value\n  const deltaY = event.clientY - dragStartY.value\n\n  if (!dragMoved.value && Math.hypot(deltaX, deltaY) >= DRAG_THRESHOLD) {\n    dragMoved.value = true\n  }\n\n  setViewportScroll(dragStartScrollLeft.value - deltaX, dragStartScrollTop.value - deltaY)\n}\n\nfunction handleStagePointerUp(event: PointerEvent) {\n  if (activePointerId.value !== event.pointerId) {\n    return\n  }\n\n  if (dragMoved.value) {\n    suppressClick.value = true\n    scheduleSuppressClickReset()\n  }\n\n  resetPointerState(event.pointerId)\n}\n\nfunction handleStagePointerCancel(event: PointerEvent) {\n  if (activePointerId.value !== event.pointerId) {\n    return\n  }\n\n  if (dragMoved.value) {\n    suppressClick.value = true\n    scheduleSuppressClickReset()\n  }\n\n  resetPointerState(event.pointerId)\n}\n\nfunction handleStageLostPointerCapture(event: PointerEvent) {\n  if (activePointerId.value === event.pointerId) {\n    resetPointerState()\n  }\n}\n\nfunction handleViewportWheel(event: WheelEvent) {\n  if (!currentAsset.value || imageError.value) {\n    return\n  }\n\n  if (!event.ctrlKey && fitMode.value !== 'actual') {\n    return\n  }\n\n  event.preventDefault()\n  event.stopPropagation()\n\n  if (event.deltaY === 0 || !hasImageDimensions.value || fitScale.value <= 0) {\n    return\n  }\n\n  const zoomFactor = event.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP\n  void zoomToScaleAtPoint(getCurrentScale() * zoomFactor, event.clientX, event.clientY)\n}\n\ndefineExpose({\n  showFitMode,\n  showActualSize,\n  zoomIn,\n  zoomOut,\n})\n</script>\n\n<template>\n  <div class=\"relative h-full w-full\">\n    <button\n      v-if=\"canGoToPrevious\"\n      class=\"surface-top absolute top-1/2 left-4 z-10 inline-flex h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full text-foreground transition-all\"\n      @click=\"handlePrevious\"\n      :title=\"t('gallery.lightbox.image.previousTitle')\"\n    >\n      <svg class=\"h-6 w-6\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n        <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M15 19l-7-7 7-7\" />\n      </svg>\n    </button>\n\n    <div\n      ref=\"viewportRef\"\n      class=\"lightbox-viewport h-full w-full overflow-auto\"\n      :style=\"heroAnimating ? { visibility: 'hidden' } : {}\"\n      @wheel=\"handleViewportWheel\"\n    >\n      <div class=\"box-border grid min-h-full min-w-full\" :style=\"canvasStyle\">\n        <div\n          v-if=\"currentAsset && !imageError\"\n          :key=\"currentAsset.id\"\n          ref=\"stageRef\"\n          class=\"relative col-start-1 row-start-1 self-center justify-self-center select-none\"\n          :style=\"stageStyle\"\n          :title=\"zoomIndicator\"\n          @click=\"handleStageClick\"\n          @pointerdown=\"handleStagePointerDown\"\n          @pointermove=\"handleStagePointerMove\"\n          @pointerup=\"handleStagePointerUp\"\n          @pointercancel=\"handleStagePointerCancel\"\n          @lostpointercapture=\"handleStageLostPointerCapture\"\n        >\n          <img\n            v-if=\"switchingFrame && previousFrame\"\n            :src=\"previousFrame.thumbnailUrl\"\n            :alt=\"previousFrame.name\"\n            class=\"absolute inset-0 h-full w-full object-contain select-none\"\n            draggable=\"false\"\n            @dragstart.prevent\n          />\n\n          <img\n            :src=\"thumbnailUrl\"\n            :alt=\"currentAsset.name\"\n            class=\"absolute inset-0 h-full w-full object-contain select-none\"\n            draggable=\"false\"\n            @dragstart.prevent\n            @load=\"handleThumbnailLoad\"\n          />\n\n          <img\n            :src=\"originalUrl\"\n            :alt=\"currentAsset.name\"\n            :style=\"{\n              opacity: originalLoaded ? 1 : 0,\n            }\"\n            class=\"absolute inset-0 h-full w-full object-contain transition-opacity duration-200 select-none\"\n            draggable=\"false\"\n            @dragstart.prevent\n            @load=\"handleOriginalLoad\"\n            @error=\"handleImageError\"\n          />\n        </div>\n\n        <div\n          v-else-if=\"imageError\"\n          class=\"col-start-1 row-start-1 flex min-h-full min-w-full flex-col items-center justify-center text-muted-foreground\"\n        >\n          <svg class=\"mb-4 h-16 w-16\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n            <path\n              stroke-linecap=\"round\"\n              stroke-linejoin=\"round\"\n              stroke-width=\"2\"\n              d=\"M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z\"\n            />\n          </svg>\n          <p class=\"text-lg\">{{ t('gallery.lightbox.image.loadFailed') }}</p>\n          <p class=\"mt-2 text-sm text-muted-foreground/70\">{{ currentAsset?.name }}</p>\n        </div>\n      </div>\n    </div>\n\n    <button\n      v-if=\"canGoToNext\"\n      class=\"surface-top absolute top-1/2 right-4 z-10 inline-flex h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full text-foreground transition-all\"\n      @click=\"handleNext\"\n      :title=\"t('gallery.lightbox.image.nextTitle')\"\n    >\n      <svg class=\"h-6 w-6\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n        <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M9 5l7 7-7 7\" />\n      </svg>\n    </button>\n  </div>\n</template>\n\n<style scoped>\n.lightbox-viewport {\n  scrollbar-width: none;\n  -ms-overflow-style: none;\n}\n\n.lightbox-viewport::-webkit-scrollbar {\n  display: none;\n}\n</style>\n"
  },
  {
    "path": "web/src/features/gallery/components/lightbox/LightboxToolbar.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed } from 'vue'\nimport { useI18n } from '@/composables/useI18n'\nimport { useGalleryStore } from '../../store'\nimport { cn } from '@/lib/utils'\nimport { Flag } from 'lucide-vue-next'\nimport { Button } from '@/components/ui/button'\nimport { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'\nimport ReviewFilterPopover from '../tags/ReviewFilterPopover.vue'\n\nconst ACTUAL_SIZE_EPSILON = 0.001\n\nconst emit = defineEmits<{\n  back: []\n  fit: []\n  actual: []\n  zoomIn: []\n  zoomOut: []\n  toggleFilmstrip: []\n  toggleImmersive: []\n}>()\n\nconst { t } = useI18n()\nconst store = useGalleryStore()\n\nconst currentIndex = computed(() => store.selection.activeIndex ?? 0)\nconst totalCount = computed(() => store.totalCount)\nconst selectedCount = computed(() => store.selection.selectedIds.size)\nconst showFilmstrip = computed(() => store.lightbox.showFilmstrip)\nconst isImmersive = computed(() => store.lightbox.isImmersive)\nconst currentAsset = computed(() => {\n  const currentIndex = store.selection.activeIndex\n  if (currentIndex === undefined) {\n    return null\n  }\n\n  return store.getAssetsInRange(currentIndex, currentIndex)[0] ?? null\n})\n// 视频使用原生 controls，不适用灯箱图片的适屏/缩放语义。\nconst supportsZoom = computed(() => currentAsset.value?.type !== 'video')\nconst isFitMode = computed(() => store.lightbox.fitMode === 'contain')\nconst isActualSize = computed(\n  () =>\n    store.lightbox.fitMode === 'actual' && Math.abs(store.lightbox.zoom - 1) <= ACTUAL_SIZE_EPSILON\n)\nconst lightboxMode = computed(() => {\n  if (currentAsset.value?.type === 'video') {\n    return t('gallery.toolbar.filter.type.video')\n  }\n\n  if (isFitMode.value) {\n    return t('gallery.lightbox.toolbar.fit')\n  }\n\n  return `${Math.round(store.lightbox.zoom * 100)}%`\n})\n\nconst hasReviewFilter = computed(\n  () => store.filter.rating !== undefined || store.filter.reviewFlag !== undefined\n)\n\nconst toggleActiveClass =\n  'bg-sidebar-accent font-medium text-primary hover:text-primary [&_svg]:text-primary'\n</script>\n\n<template>\n  <div class=\"flex items-center justify-between px-4 py-3\">\n    <div class=\"flex min-w-0 items-center gap-3 text-foreground\">\n      <Button\n        variant=\"sidebarGhost\"\n        size=\"icon\"\n        class=\"shrink-0\"\n        @click=\"emit('back')\"\n        :title=\"t('gallery.lightbox.toolbar.backTitle')\"\n      >\n        <svg class=\"size-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n          <path\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\"\n            stroke-width=\"2\"\n            d=\"M15 19l-7-7 7-7\"\n          />\n        </svg>\n      </Button>\n\n      <div class=\"flex min-w-0 items-center gap-3\">\n        <span class=\"shrink-0 text-sm font-medium\">{{ currentIndex + 1 }} / {{ totalCount }}</span>\n        <span class=\"truncate text-xs text-muted-foreground\">{{ lightboxMode }}</span>\n        <span v-if=\"selectedCount > 0\" class=\"shrink-0 text-xs text-primary\">\n          {{ t('gallery.lightbox.toolbar.selected') }} {{ selectedCount }}\n        </span>\n      </div>\n    </div>\n\n    <div class=\"flex items-center gap-2\">\n      <div class=\"mr-2 flex items-center gap-1\">\n        <Button\n          variant=\"sidebarGhost\"\n          class=\"h-9 px-3 text-xs\"\n          :disabled=\"!supportsZoom\"\n          :class=\"\n            cn(\n              !supportsZoom && 'cursor-not-allowed',\n              supportsZoom && isFitMode && toggleActiveClass\n            )\n          \"\n          @click=\"emit('fit')\"\n          :title=\"t('gallery.lightbox.toolbar.fitTitle')\"\n        >\n          {{ t('gallery.lightbox.toolbar.fit') }}\n        </Button>\n\n        <Button\n          variant=\"sidebarGhost\"\n          class=\"h-9 px-3 text-xs\"\n          :disabled=\"!supportsZoom\"\n          :class=\"\n            cn(\n              !supportsZoom && 'cursor-not-allowed',\n              supportsZoom && isActualSize && toggleActiveClass\n            )\n          \"\n          @click=\"emit('actual')\"\n          :title=\"t('gallery.lightbox.toolbar.actualTitle')\"\n        >\n          100%\n        </Button>\n\n        <Button\n          variant=\"sidebarGhost\"\n          size=\"icon\"\n          :disabled=\"!supportsZoom\"\n          :class=\"!supportsZoom && 'cursor-not-allowed'\"\n          @click=\"emit('zoomOut')\"\n          :title=\"t('gallery.lightbox.toolbar.zoomOutTitle')\"\n        >\n          <svg class=\"size-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M20 12H4\" />\n          </svg>\n        </Button>\n\n        <Button\n          variant=\"sidebarGhost\"\n          size=\"icon\"\n          :disabled=\"!supportsZoom\"\n          :class=\"!supportsZoom && 'cursor-not-allowed'\"\n          @click=\"emit('zoomIn')\"\n          :title=\"t('gallery.lightbox.toolbar.zoomInTitle')\"\n        >\n          <svg class=\"size-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n            <path\n              stroke-linecap=\"round\"\n              stroke-linejoin=\"round\"\n              stroke-width=\"2\"\n              d=\"M12 4v16m8-8H4\"\n            />\n          </svg>\n        </Button>\n      </div>\n\n      <!-- 评分与标记筛选 -->\n      <Popover>\n        <PopoverTrigger as-child>\n          <Button\n            variant=\"sidebarGhost\"\n            size=\"icon\"\n            :class=\"hasReviewFilter ? 'text-primary' : ''\"\n            :title=\"t('gallery.toolbar.filter.review.tooltip')\"\n          >\n            <Flag class=\"size-5\" />\n          </Button>\n        </PopoverTrigger>\n        <PopoverContent align=\"end\" class=\"w-56 p-3\">\n          <ReviewFilterPopover\n            :rating=\"store.filter.rating\"\n            :review-flag=\"store.filter.reviewFlag\"\n            @update:rating=\"(v) => store.setFilter({ rating: v })\"\n            @update:review-flag=\"(v) => store.setFilter({ reviewFlag: v })\"\n          />\n        </PopoverContent>\n      </Popover>\n\n      <Button\n        variant=\"sidebarGhost\"\n        size=\"icon\"\n        @click=\"emit('toggleFilmstrip')\"\n        :title=\"\n          showFilmstrip\n            ? t('gallery.lightbox.toolbar.filmstripHideTitle')\n            : t('gallery.lightbox.toolbar.filmstripShowTitle')\n        \"\n      >\n        <svg\n          v-if=\"showFilmstrip\"\n          class=\"size-5\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          viewBox=\"0 0 24 24\"\n        >\n          <path\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\"\n            stroke-width=\"2\"\n            d=\"M19 9l-7 7-7-7\"\n          />\n        </svg>\n        <svg v-else class=\"size-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n          <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M5 15l7-7 7 7\" />\n        </svg>\n      </Button>\n\n      <Button\n        variant=\"sidebarGhost\"\n        size=\"icon\"\n        @click=\"emit('toggleImmersive')\"\n        :title=\"\n          isImmersive\n            ? t('gallery.lightbox.toolbar.exitImmersiveTitle')\n            : t('gallery.lightbox.toolbar.immersiveTitle')\n        \"\n      >\n        <svg\n          v-if=\"isImmersive\"\n          class=\"size-5\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          viewBox=\"0 0 24 24\"\n        >\n          <path\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\"\n            stroke-width=\"2\"\n            d=\"M8 3H5a2 2 0 00-2 2v3m18 0V5a2 2 0 00-2-2h-3m0 18h3a2 2 0 002-2v-3M3 16v3a2 2 0 002 2h3m-1-7l4-4m0 0h-3m3 0v3m-8 1l4-4m0 0v3m0-3H8\"\n          />\n        </svg>\n        <svg v-else class=\"size-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n          <path\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\"\n            stroke-width=\"2\"\n            d=\"M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4\"\n          />\n        </svg>\n      </Button>\n    </div>\n  </div>\n</template>\n"
  },
  {
    "path": "web/src/features/gallery/components/lightbox/LightboxVideo.vue",
    "content": "<script setup lang=\"ts\">\n/**\n * 灯箱内视频：原片走 galleryApi.getAssetUrl（后端 Range），poster 为图库 WebP 缩略图。\n * :key=\"currentAsset.id\" 在切条目时强制重建 <video>，避免沿用上一段的 currentTime/缓冲。\n */\nimport { computed } from 'vue'\nimport { galleryApi } from '../../api'\nimport { useGalleryStore } from '../../store'\nimport { useI18n } from '@/composables/useI18n'\nimport { heroAnimating } from '../../composables/useHeroTransition'\n\nconst emit = defineEmits<{\n  previous: []\n  next: []\n}>()\n\nconst store = useGalleryStore()\nconst { t } = useI18n()\n\nconst currentAsset = computed(() => {\n  const currentIdx = store.selection.activeIndex\n  if (currentIdx === undefined) {\n    return null\n  }\n\n  return store.getAssetsInRange(currentIdx, currentIdx)[0] ?? null\n})\n\nconst assetUrl = computed(() => {\n  if (!currentAsset.value) {\n    return ''\n  }\n\n  return galleryApi.getAssetUrl(currentAsset.value)\n})\n\nconst posterUrl = computed(() => {\n  if (!currentAsset.value) {\n    return ''\n  }\n\n  return galleryApi.getAssetThumbnailUrl(currentAsset.value)\n})\n\nconst canGoToPrevious = computed(() => (store.selection.activeIndex ?? 0) > 0)\nconst canGoToNext = computed(() => (store.selection.activeIndex ?? 0) < store.totalCount - 1)\n\nfunction handlePrevious() {\n  emit('previous')\n}\n\nfunction handleNext() {\n  emit('next')\n}\n</script>\n\n<template>\n  <div class=\"relative flex h-full w-full items-center justify-center\">\n    <button\n      v-if=\"canGoToPrevious\"\n      class=\"surface-top absolute top-1/2 left-4 z-10 inline-flex h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full text-foreground transition-all\"\n      @click=\"handlePrevious\"\n      :title=\"t('gallery.lightbox.image.previousTitle')\"\n    >\n      <svg class=\"h-6 w-6\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n        <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M15 19l-7-7 7-7\" />\n      </svg>\n    </button>\n\n    <div\n      class=\"flex h-full w-full items-center justify-center p-8\"\n      :style=\"heroAnimating ? { visibility: 'hidden' } : {}\"\n    >\n      <video\n        v-if=\"currentAsset\"\n        :key=\"currentAsset.id\"\n        :src=\"assetUrl\"\n        :poster=\"posterUrl\"\n        :aria-label=\"currentAsset.name\"\n        class=\"max-h-full max-w-full rounded-lg shadow-2xl\"\n        autoplay\n        controls\n        playsinline\n        preload=\"metadata\"\n      />\n    </div>\n\n    <button\n      v-if=\"canGoToNext\"\n      class=\"surface-top absolute top-1/2 right-4 z-10 inline-flex h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full text-foreground transition-all\"\n      @click=\"handleNext\"\n      :title=\"t('gallery.lightbox.image.nextTitle')\"\n    >\n      <svg class=\"h-6 w-6\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n        <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M9 5l7 7-7 7\" />\n      </svg>\n    </button>\n  </div>\n</template>\n"
  },
  {
    "path": "web/src/features/gallery/components/menus/GalleryAssetContextMenuContent.vue",
    "content": "<script setup lang=\"ts\">\nimport { useI18n } from '@/composables/useI18n'\nimport { Copy, Eraser, ExternalLink, Flag, FolderOpen, Star, Trash2, X } from 'lucide-vue-next'\nimport {\n  ContextMenuItem,\n  ContextMenuSeparator,\n  ContextMenuShortcut,\n  ContextMenuSub,\n  ContextMenuSubContent,\n  ContextMenuSubTrigger,\n} from '@/components/ui/context-menu'\nimport { useGalleryAssetActions } from '../../composables'\n\nconst { t } = useI18n()\nconst assetActions = useGalleryAssetActions()\nconst ratingOptions = [1, 2, 3, 4, 5] as const\n</script>\n\n<template>\n  <ContextMenuItem\n    :disabled=\"!assetActions.isSingleSelection\"\n    @click=\"assetActions.handleOpenAssetDefault\"\n  >\n    <ExternalLink />\n    {{ t('gallery.contextMenu.openDefaultApp.label') }}\n  </ContextMenuItem>\n  <ContextMenuItem\n    :disabled=\"!assetActions.isSingleSelection\"\n    @click=\"assetActions.handleRevealAssetInExplorer\"\n  >\n    <FolderOpen />\n    {{ t('gallery.contextMenu.revealInExplorer.label') }}\n  </ContextMenuItem>\n  <ContextMenuSeparator />\n  <ContextMenuItem\n    :disabled=\"!assetActions.hasSelection\"\n    @click=\"assetActions.handleCopyAssetsToClipboard\"\n  >\n    <Copy />\n    {{ t('gallery.contextMenu.copyFiles.label') }}\n  </ContextMenuItem>\n  <ContextMenuSeparator />\n  <ContextMenuSub>\n    <ContextMenuSubTrigger>\n      <Star />\n      {{ t('gallery.contextMenu.review.rating.label') }}\n    </ContextMenuSubTrigger>\n    <ContextMenuSubContent class=\"w-40\">\n      <ContextMenuItem\n        v-for=\"rating in ratingOptions\"\n        :key=\"rating\"\n        @click=\"assetActions.setSelectedAssetsRating(rating)\"\n      >\n        <span class=\"flex items-center gap-0.5\">\n          <Star v-for=\"index in rating\" :key=\"`${rating}-${index}`\" class=\"fill-current\" />\n        </span>\n        <ContextMenuShortcut>{{ rating }}</ContextMenuShortcut>\n      </ContextMenuItem>\n      <ContextMenuSeparator />\n      <ContextMenuItem @click=\"assetActions.clearSelectedAssetsRating\">\n        <Eraser />\n        {{ t('gallery.contextMenu.review.rating.clear') }}\n        <ContextMenuShortcut>0</ContextMenuShortcut>\n      </ContextMenuItem>\n    </ContextMenuSubContent>\n  </ContextMenuSub>\n  <ContextMenuSub>\n    <ContextMenuSubTrigger>\n      <Flag />\n      {{ t('gallery.contextMenu.review.flag.label') }}\n    </ContextMenuSubTrigger>\n    <ContextMenuSubContent class=\"w-40\">\n      <ContextMenuItem @click=\"assetActions.setSelectedAssetsRejected()\">\n        <X />\n        {{ t('gallery.review.flag.rejected') }}\n        <ContextMenuShortcut>X</ContextMenuShortcut>\n      </ContextMenuItem>\n      <ContextMenuSeparator />\n      <ContextMenuItem @click=\"assetActions.clearSelectedAssetsRejected\">\n        <Eraser />\n        {{ t('gallery.contextMenu.review.flag.clear') }}\n      </ContextMenuItem>\n    </ContextMenuSubContent>\n  </ContextMenuSub>\n  <ContextMenuSeparator />\n  <ContextMenuItem variant=\"destructive\" @click=\"assetActions.handleMoveAssetsToTrash\">\n    <Trash2 />\n    {{ t('gallery.contextMenu.moveToTrash.label') }}\n  </ContextMenuItem>\n</template>\n"
  },
  {
    "path": "web/src/features/gallery/components/menus/GalleryAssetDropdownMenuContent.vue",
    "content": "<script setup lang=\"ts\">\nimport { useI18n } from '@/composables/useI18n'\nimport { Copy, Eraser, ExternalLink, Flag, FolderOpen, Star, Trash2, X } from 'lucide-vue-next'\nimport {\n  DropdownMenuItem,\n  DropdownMenuSeparator,\n  DropdownMenuShortcut,\n  DropdownMenuSub,\n  DropdownMenuSubContent,\n  DropdownMenuSubTrigger,\n} from '@/components/ui/dropdown-menu'\nimport { useGalleryAssetActions } from '../../composables'\n\nconst { t } = useI18n()\nconst assetActions = useGalleryAssetActions()\nconst ratingOptions = [1, 2, 3, 4, 5] as const\n</script>\n\n<template>\n  <DropdownMenuItem\n    :disabled=\"!assetActions.isSingleSelection\"\n    @click=\"assetActions.handleOpenAssetDefault\"\n  >\n    <ExternalLink />\n    {{ t('gallery.contextMenu.openDefaultApp.label') }}\n  </DropdownMenuItem>\n  <DropdownMenuItem\n    :disabled=\"!assetActions.isSingleSelection\"\n    @click=\"assetActions.handleRevealAssetInExplorer\"\n  >\n    <FolderOpen />\n    {{ t('gallery.contextMenu.revealInExplorer.label') }}\n  </DropdownMenuItem>\n  <DropdownMenuSeparator />\n  <DropdownMenuItem\n    :disabled=\"!assetActions.hasSelection\"\n    @click=\"assetActions.handleCopyAssetsToClipboard\"\n  >\n    <Copy />\n    {{ t('gallery.contextMenu.copyFiles.label') }}\n  </DropdownMenuItem>\n  <DropdownMenuSeparator />\n  <DropdownMenuSub>\n    <DropdownMenuSubTrigger>\n      <Star />\n      {{ t('gallery.contextMenu.review.rating.label') }}\n    </DropdownMenuSubTrigger>\n    <DropdownMenuSubContent class=\"w-40\">\n      <DropdownMenuItem\n        v-for=\"rating in ratingOptions\"\n        :key=\"rating\"\n        @click=\"assetActions.setSelectedAssetsRating(rating)\"\n      >\n        <span class=\"flex items-center gap-0.5 text-muted-foreground\">\n          <Star v-for=\"index in rating\" :key=\"`${rating}-${index}`\" class=\"fill-current\" />\n        </span>\n        <DropdownMenuShortcut>{{ rating }}</DropdownMenuShortcut>\n      </DropdownMenuItem>\n      <DropdownMenuSeparator />\n      <DropdownMenuItem @click=\"assetActions.clearSelectedAssetsRating\">\n        <Eraser />\n        {{ t('gallery.contextMenu.review.rating.clear') }}\n        <DropdownMenuShortcut>0</DropdownMenuShortcut>\n      </DropdownMenuItem>\n    </DropdownMenuSubContent>\n  </DropdownMenuSub>\n  <DropdownMenuSub>\n    <DropdownMenuSubTrigger>\n      <Flag />\n      {{ t('gallery.contextMenu.review.flag.label') }}\n    </DropdownMenuSubTrigger>\n    <DropdownMenuSubContent class=\"w-40\">\n      <DropdownMenuItem @click=\"assetActions.setSelectedAssetsRejected()\">\n        <X />\n        {{ t('gallery.review.flag.rejected') }}\n        <DropdownMenuShortcut>X</DropdownMenuShortcut>\n      </DropdownMenuItem>\n      <DropdownMenuSeparator />\n      <DropdownMenuItem @click=\"assetActions.clearSelectedAssetsRejected\">\n        <Eraser />\n        {{ t('gallery.contextMenu.review.flag.clear') }}\n      </DropdownMenuItem>\n    </DropdownMenuSubContent>\n  </DropdownMenuSub>\n  <DropdownMenuSeparator />\n  <DropdownMenuItem variant=\"destructive\" @click=\"assetActions.handleMoveAssetsToTrash\">\n    <Trash2 />\n    {{ t('gallery.contextMenu.moveToTrash.label') }}\n  </DropdownMenuItem>\n</template>\n"
  },
  {
    "path": "web/src/features/gallery/components/menus/GallerySharedContextMenu.vue",
    "content": "<script setup lang=\"ts\">\nimport { nextTick, watch } from 'vue'\nimport { useGalleryContextMenu } from '../../composables/useGalleryContextMenu'\nimport GalleryAssetDropdownMenuContent from './GalleryAssetDropdownMenuContent.vue'\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuTrigger,\n} from '@/components/ui/dropdown-menu'\n\nconst contextMenu = useGalleryContextMenu()\n\nwatch(\n  () => contextMenu.state.requestToken,\n  async (token) => {\n    if (token <= 0) {\n      return\n    }\n\n    // 等待锚点位移先提交到 DOM，再以受控方式打开菜单，避免定位闪动。\n    await nextTick()\n    contextMenu.setOpen(true)\n  }\n)\n</script>\n\n<template>\n  <div>\n    <DropdownMenu\n      :open=\"contextMenu.state.isOpen\"\n      :modal=\"false\"\n      @update:open=\"contextMenu.setOpen\"\n    >\n      <DropdownMenuTrigger as-child>\n        <div\n          class=\"pointer-events-none fixed h-px w-px opacity-0\"\n          :style=\"{\n            left: `${contextMenu.state.anchorX}px`,\n            top: `${contextMenu.state.anchorY}px`,\n          }\"\n        />\n      </DropdownMenuTrigger>\n      <DropdownMenuContent\n        side=\"bottom\"\n        align=\"start\"\n        :side-offset=\"0\"\n        :align-offset=\"0\"\n        @contextmenu.prevent.stop\n        @escape-key-down=\"contextMenu.setOpen(false)\"\n        @pointer-down-outside=\"contextMenu.setOpen(false)\"\n      >\n        <GalleryAssetDropdownMenuContent />\n      </DropdownMenuContent>\n    </DropdownMenu>\n  </div>\n</template>\n"
  },
  {
    "path": "web/src/features/gallery/components/shell/GalleryContent.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed, ref } from 'vue'\nimport { useGalleryStore } from '../../store'\nimport GridView from '../viewer/GridView.vue'\nimport ListView from '../viewer/ListView.vue'\nimport MasonryView from '../viewer/MasonryView.vue'\nimport AdaptiveView from '../viewer/AdaptiveView.vue'\nimport GallerySharedContextMenu from '../menus/GallerySharedContextMenu.vue'\n\nconst store = useGalleryStore()\nconst viewMode = computed(() => store.viewConfig.mode)\n\ninterface GalleryViewExposed {\n  scrollToIndex: (index: number) => void\n  getCardRect: (index: number) => DOMRect | null\n}\n\nconst gridViewRef = ref<GalleryViewExposed | null>(null)\nconst listViewRef = ref<GalleryViewExposed | null>(null)\nconst masonryViewRef = ref<GalleryViewExposed | null>(null)\nconst adaptiveViewRef = ref<GalleryViewExposed | null>(null)\n\nfunction scrollToIndex(index: number) {\n  if (viewMode.value === 'grid') gridViewRef.value?.scrollToIndex(index)\n  else if (viewMode.value === 'list') listViewRef.value?.scrollToIndex(index)\n  else if (viewMode.value === 'masonry') masonryViewRef.value?.scrollToIndex(index)\n  else adaptiveViewRef.value?.scrollToIndex(index)\n}\n\nfunction getCardRect(index: number): DOMRect | null {\n  if (viewMode.value === 'grid') return gridViewRef.value?.getCardRect(index) ?? null\n  if (viewMode.value === 'list') return listViewRef.value?.getCardRect(index) ?? null\n  if (viewMode.value === 'masonry') return masonryViewRef.value?.getCardRect(index) ?? null\n  return adaptiveViewRef.value?.getCardRect(index) ?? null\n}\n\ndefineExpose({ scrollToIndex, getCardRect })\n</script>\n\n<template>\n  <div class=\"h-full w-full\">\n    <GridView v-if=\"viewMode === 'grid'\" ref=\"gridViewRef\" />\n    <ListView v-else-if=\"viewMode === 'list'\" ref=\"listViewRef\" />\n    <MasonryView v-else-if=\"viewMode === 'masonry'\" ref=\"masonryViewRef\" />\n    <AdaptiveView v-else ref=\"adaptiveViewRef\" />\n    <GallerySharedContextMenu />\n  </div>\n</template>\n"
  },
  {
    "path": "web/src/features/gallery/components/shell/GalleryDetails.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed, ref, watch } from 'vue'\nimport { Button } from '@/components/ui/button'\nimport { Input } from '@/components/ui/input'\nimport { Separator } from '@/components/ui/separator'\nimport { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'\nimport { ScrollArea } from '@/components/ui/scroll-area'\nimport { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'\nimport { rgbToHex } from '@/components/ui/color-picker/colorUtils'\nimport { useI18n } from '@/composables/useI18n'\nimport { useToast } from '@/composables/useToast'\nimport { useSettingsStore } from '@/features/settings/store'\nimport { useGalleryStore } from '../../store'\nimport { useGalleryData } from '../../composables/useGalleryData'\nimport { useGalleryAssetActions } from '../../composables'\nimport {\n  getAssetMainColors,\n  getAssetTags,\n  removeTagsFromAsset,\n  addTagsToAsset,\n  getInfinityNikkiDetails,\n  updateAssetDescription,\n} from '../../api'\nimport AssetDetailsContent from '../asset/AssetDetailsContent.vue'\nimport AssetInfinityNikkiDetails from '../infinity_nikki/AssetInfinityNikkiDetails.vue'\nimport AssetHistogram from '../asset/AssetHistogram.vue'\nimport AssetReviewControls from '../asset/AssetReviewControls.vue'\nimport TagSelectorPopover from '../tags/TagSelectorPopover.vue'\nimport type { Asset, AssetMainColor, InfinityNikkiDetails, Tag } from '../../types'\n\nconst store = useGalleryStore()\nconst settingsStore = useSettingsStore()\nconst { t } = useI18n()\nconst { toast } = useToast()\nconst assetActions = useGalleryAssetActions()\n\n// 获取详情面板焦点\nconst detailsFocus = computed(() => store.detailsPanel)\n\n// 根据焦点直接获取对象引用\nconst currentFolder = computed(() => {\n  return detailsFocus.value.type === 'folder' ? detailsFocus.value.folder : null\n})\nconst isRootFolderSummary = computed(() => currentFolder.value?.id === -1)\nconst rootFolderCount = computed(() => store.folders.length)\nconst rootFolderAssetTotalCount = computed(() => store.foldersAssetTotalCount)\n\nconst activeAsset = computed(() => {\n  return detailsFocus.value.type === 'asset' ? detailsFocus.value.asset : null\n})\n\n// 使用gallery数据composable\nconst { getAssetThumbnailUrl, getAssetUrl } = useGalleryData()\n\nconst selectedCount = computed(() => store.selectedCount)\n\nfunction findLoadedAssetById(id: number): Asset | null {\n  for (const pageAssets of store.paginatedAssets.values()) {\n    const found = pageAssets.find((asset) => asset.id === id)\n    if (found) {\n      return found\n    }\n  }\n  return null\n}\n\nconst batchActiveAsset = computed(() => {\n  if (detailsFocus.value.type !== 'batch') return null\n  if (store.selection.selectedIds.size === 0) return null\n\n  const activeIndex = store.selection.activeIndex\n  if (activeIndex !== undefined) {\n    const [currentAsset] = store.getAssetsInRange(activeIndex, activeIndex)\n    if (currentAsset && store.selection.selectedIds.has(currentAsset.id)) {\n      return currentAsset\n    }\n  }\n\n  for (const id of store.selection.selectedIds) {\n    const asset = findLoadedAssetById(id)\n    if (asset) {\n      return asset\n    }\n  }\n\n  return null\n})\n\n// 计算缩略图URL\nconst thumbnailUrl = computed(() => {\n  if (!activeAsset.value) return ''\n  return getAssetThumbnailUrl(activeAsset.value)\n})\n\nconst assetUrl = computed(() => {\n  if (!activeAsset.value) return ''\n  return getAssetUrl(activeAsset.value)\n})\n\nconst batchThumbnailUrl = computed(() => {\n  if (!batchActiveAsset.value) return ''\n  return getAssetThumbnailUrl(batchActiveAsset.value)\n})\n\nconst batchAssetUrl = computed(() => {\n  if (!batchActiveAsset.value) return ''\n  return getAssetUrl(batchActiveAsset.value)\n})\n\nconst assetHistogramCacheKey = computed(() => {\n  if (!activeAsset.value) return ''\n  return activeAsset.value.hash ?? `${activeAsset.value.id}:${thumbnailUrl.value}`\n})\n\nconst shouldShowAssetHistogram = computed(() => {\n  if (!activeAsset.value) {\n    return false\n  }\n\n  return (\n    (activeAsset.value.type === 'photo' || activeAsset.value.type === 'live_photo') &&\n    thumbnailUrl.value.length > 0\n  )\n})\n\nconst infinityNikkiEnabled = computed(\n  () => settingsStore.appSettings.extensions.infinityNikki.enable\n)\n\n// 资产标签状态\nconst assetTags = ref<Tag[]>([])\nconst assetMainColors = ref<AssetMainColor[]>([])\nconst infinityNikkiDetails = ref<InfinityNikkiDetails | null>(null)\nconst hasMainColors = computed(() => assetMainColors.value.length > 0)\n\n// 当前标签（详情面板焦点为 tag 时）\nconst currentTag = computed(() => {\n  return detailsFocus.value.type === 'tag' ? detailsFocus.value.tag : null\n})\nconst isRootTagSummary = computed(() => currentTag.value?.id === -1)\nconst rootTagCount = computed(() => store.tags.length)\nconst rootTagAssetTotalCount = computed(() => store.tagsAssetTotalCount)\nconst assetDescriptionDraft = ref('')\nconst isSavingAssetDescription = ref(false)\n\n// 监听 activeAsset 变化，加载详情数据\nwatch(\n  [activeAsset, infinityNikkiEnabled],\n  async ([asset, nikkiEnabled]) => {\n    if (asset) {\n      try {\n        const [tags, mainColors, details] = await Promise.all([\n          getAssetTags(asset.id),\n          getAssetMainColors(asset.id),\n          nikkiEnabled ? getInfinityNikkiDetails(asset.id) : Promise.resolve(null),\n        ])\n\n        assetTags.value = tags\n        assetMainColors.value = mainColors\n        infinityNikkiDetails.value = details\n      } catch (error) {\n        console.error('Failed to load asset details:', error)\n        assetTags.value = []\n        assetMainColors.value = []\n        infinityNikkiDetails.value = null\n      }\n    } else {\n      assetTags.value = []\n      assetMainColors.value = []\n      infinityNikkiDetails.value = null\n    }\n  },\n  { immediate: true }\n)\n\nwatch(\n  () => activeAsset.value?.id,\n  () => {\n    assetDescriptionDraft.value = activeAsset.value?.description ?? ''\n  },\n  { immediate: true }\n)\n\nasync function reloadActiveAssetTags() {\n  if (!activeAsset.value) {\n    assetTags.value = []\n    return\n  }\n\n  assetTags.value = await getAssetTags(activeAsset.value.id)\n}\n\n// Popover 状态\nconst showTagSelector = ref(false)\n\n// 移除标签\nasync function handleRemoveTag(tagId: number) {\n  if (!activeAsset.value) return\n\n  try {\n    await removeTagsFromAsset({\n      assetId: activeAsset.value.id,\n      tagIds: [tagId],\n    })\n\n    await reloadActiveAssetTags()\n  } catch (error) {\n    console.error('Failed to remove tag:', error)\n  }\n}\n\n// 切换标签\nasync function handleToggleTag(tagId: number) {\n  if (!activeAsset.value) return\n\n  const hasTag = assetTags.value.some((tag) => tag.id === tagId)\n\n  try {\n    if (hasTag) {\n      await removeTagsFromAsset({\n        assetId: activeAsset.value.id,\n        tagIds: [tagId],\n      })\n    } else {\n      await addTagsToAsset({\n        assetId: activeAsset.value.id,\n        tagIds: [tagId],\n      })\n    }\n\n    await reloadActiveAssetTags()\n  } catch (error) {\n    console.error('Failed to toggle tag:', error)\n  }\n}\n\nasync function handleSetRating(rating: number) {\n  await assetActions.setSelectedAssetsRating(rating)\n}\n\nasync function handleClearRating() {\n  await assetActions.clearSelectedAssetsRating()\n}\n\nasync function handleSetRejected() {\n  await assetActions.setSelectedAssetsRejected()\n}\n\nasync function handleClearRejected() {\n  await assetActions.clearSelectedAssetsRejected()\n}\n\nfunction resetAssetDescriptionDraft() {\n  assetDescriptionDraft.value = activeAsset.value?.description ?? ''\n}\n\nasync function handleAssetDescriptionCommit() {\n  if (!activeAsset.value || isSavingAssetDescription.value) {\n    return\n  }\n\n  const normalizedDescription = assetDescriptionDraft.value.trim()\n  const currentDescription = (activeAsset.value.description ?? '').trim()\n  assetDescriptionDraft.value = normalizedDescription\n\n  if (normalizedDescription === currentDescription) {\n    return\n  }\n\n  isSavingAssetDescription.value = true\n\n  try {\n    await updateAssetDescription({\n      assetId: activeAsset.value.id,\n      description: normalizedDescription || undefined,\n    })\n\n    store.patchAssetDescription(activeAsset.value.id, normalizedDescription || undefined)\n  } catch (error) {\n    resetAssetDescriptionDraft()\n    const message = error instanceof Error ? error.message : String(error)\n    toast.error(t('gallery.details.asset.updateDescriptionFailed'), {\n      description: message,\n    })\n  } finally {\n    isSavingAssetDescription.value = false\n  }\n}\n\nfunction handleInfinityNikkiDetailsUpdated(details: InfinityNikkiDetails) {\n  infinityNikkiDetails.value = details\n}\n\nfunction copyWithExecCommand(text: string): boolean {\n  if (typeof document === 'undefined') {\n    return false\n  }\n\n  const textarea = document.createElement('textarea')\n  textarea.value = text\n  textarea.style.position = 'fixed'\n  textarea.style.opacity = '0'\n  textarea.style.pointerEvents = 'none'\n  document.body.appendChild(textarea)\n  textarea.focus()\n  textarea.select()\n\n  let success = false\n  try {\n    success = document.execCommand('copy')\n  } catch {\n    success = false\n  }\n\n  document.body.removeChild(textarea)\n  return success\n}\n\nfunction formatNumber(value: number | undefined, digits = 2): string | null {\n  if (value === undefined) return null\n  return Number(value)\n    .toFixed(digits)\n    .replace(/\\.?0+$/, '')\n}\n\nfunction formatPercentage(value: number | undefined): string | null {\n  if (value === undefined) return null\n  return `${formatNumber(value * 100, 1) ?? '0'}%`\n}\n\nfunction getColorHex(color: AssetMainColor): string {\n  return rgbToHex(color.r, color.g, color.b)\n}\n\nasync function handleCopyColorHex(color: AssetMainColor) {\n  const hex = getColorHex(color)\n\n  let success = false\n  if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {\n    try {\n      await navigator.clipboard.writeText(hex)\n      success = true\n    } catch {\n      success = false\n    }\n  }\n\n  if (!success) {\n    success = copyWithExecCommand(hex)\n  }\n\n  if (success) {\n    toast.success(t('gallery.details.colors.copySuccess', { hex }))\n    return\n  }\n\n  toast.error(t('gallery.details.colors.copyFailed'))\n}\n</script>\n\n<template>\n  <ScrollArea class=\"h-full\">\n    <div class=\"p-4\">\n      <!-- 文件夹详情 -->\n      <div v-if=\"detailsFocus.type === 'folder' && currentFolder\" class=\"space-y-4\">\n        <div class=\"flex items-center justify-between\">\n          <h3 class=\"font-medium\">{{ t('gallery.details.title') }}</h3>\n        </div>\n\n        <div v-if=\"isRootFolderSummary\">\n          <h4 class=\"mb-2 text-sm font-medium\">\n            {{ t('gallery.details.rootFolderSummary.title') }}\n          </h4>\n          <div class=\"space-y-2 text-xs\">\n            <div class=\"flex justify-between gap-2\">\n              <span class=\"text-muted-foreground\">{{\n                t('gallery.details.rootFolderSummary.folderCount')\n              }}</span>\n              <span>{{ rootFolderCount }}</span>\n            </div>\n            <div class=\"flex justify-between gap-2\">\n              <span class=\"text-muted-foreground\">{{\n                t('gallery.details.rootFolderSummary.assetCount')\n              }}</span>\n              <span>{{ rootFolderAssetTotalCount }}</span>\n            </div>\n          </div>\n        </div>\n\n        <!-- 文件夹信息 -->\n        <div v-else>\n          <h4 class=\"mb-2 text-sm font-medium\">{{ t('gallery.details.folderInfo') }}</h4>\n          <div class=\"space-y-2 text-xs\">\n            <div class=\"flex justify-between gap-2\">\n              <span class=\"text-muted-foreground\">{{\n                t('gallery.details.folderDisplayName')\n              }}</span>\n              <span\n                class=\"truncate font-medium\"\n                :title=\"currentFolder.displayName || currentFolder.name\"\n              >\n                {{ currentFolder.displayName || currentFolder.name }}\n              </span>\n            </div>\n            <div class=\"flex justify-between gap-2\">\n              <span class=\"text-muted-foreground\">{{ t('gallery.details.folderName') }}</span>\n              <span class=\"truncate font-mono\" :title=\"currentFolder.name\">{{\n                currentFolder.name\n              }}</span>\n            </div>\n            <div class=\"flex flex-col gap-1\">\n              <span class=\"text-muted-foreground\">{{ t('gallery.details.fullPath') }}</span>\n              <p class=\"rounded bg-muted/50 p-2 font-mono text-xs break-all\">\n                {{ currentFolder.path }}\n              </p>\n            </div>\n            <div class=\"flex justify-between gap-2\">\n              <span class=\"text-muted-foreground\">{{ t('gallery.details.assetCount') }}</span>\n              <span>{{ t('gallery.details.itemCount', { count: currentFolder.assetCount }) }}</span>\n            </div>\n          </div>\n        </div>\n      </div>\n\n      <!-- 资产详情 -->\n      <div v-else-if=\"detailsFocus.type === 'asset' && activeAsset\" class=\"space-y-4\">\n        <AssetDetailsContent\n          :asset=\"activeAsset\"\n          :thumbnail-url=\"thumbnailUrl\"\n          :asset-url=\"assetUrl\"\n        >\n          <template #after-preview>\n            <div v-if=\"hasMainColors\">\n              <TooltipProvider>\n                <div class=\"flex flex-wrap justify-center gap-2\">\n                  <Tooltip v-for=\"(color, index) in assetMainColors\" :key=\"`${index}-${color.r}`\">\n                    <TooltipTrigger as-child>\n                      <button\n                        type=\"button\"\n                        class=\"h-5 w-5 shrink-0 rounded-sm border border-border/80 shadow-sm transition-transform hover:scale-[1.04]\"\n                        :style=\"{ backgroundColor: getColorHex(color) }\"\n                        @click=\"handleCopyColorHex(color)\"\n                      />\n                    </TooltipTrigger>\n                    <TooltipContent>\n                      <p class=\"font-mono text-xs\">{{ getColorHex(color) }}</p>\n                      <p class=\"text-xs text-muted-foreground\">\n                        {{ formatPercentage(color.weight) }}\n                      </p>\n                    </TooltipContent>\n                  </Tooltip>\n                </div>\n              </TooltipProvider>\n            </div>\n          </template>\n\n          <template #description>\n            <Input\n              v-model=\"assetDescriptionDraft\"\n              :disabled=\"isSavingAssetDescription\"\n              :placeholder=\"t('gallery.details.asset.descriptionPlaceholder')\"\n              class=\"h-6 px-2 text-xs md:text-xs\"\n              @blur=\"handleAssetDescriptionCommit\"\n              @keydown.enter.prevent=\"handleAssetDescriptionCommit\"\n              @keydown.esc.prevent=\"resetAssetDescriptionDraft\"\n            />\n          </template>\n\n          <template #before-info>\n            <div class=\"space-y-3\">\n              <div class=\"space-y-2\">\n                <h4 class=\"text-sm font-medium\">{{ t('gallery.details.review.title') }}</h4>\n                <AssetReviewControls\n                  :rating=\"activeAsset.rating\"\n                  :review-flag=\"activeAsset.reviewFlag\"\n                  @set-rating=\"handleSetRating\"\n                  @clear-rating=\"handleClearRating\"\n                  @set-flag=\"handleSetRejected\"\n                  @clear-flag=\"handleClearRejected\"\n                />\n              </div>\n\n              <div>\n                <div class=\"mb-2 flex items-center justify-between\">\n                  <h4 class=\"text-sm font-medium\">{{ t('gallery.details.tags.title') }}</h4>\n                  <Popover v-model:open=\"showTagSelector\">\n                    <PopoverTrigger as-child>\n                      <Button variant=\"ghost\" size=\"sm\" class=\"h-6 gap-1 px-2 text-xs\">\n                        <svg\n                          xmlns=\"http://www.w3.org/2000/svg\"\n                          width=\"12\"\n                          height=\"12\"\n                          viewBox=\"0 0 24 24\"\n                          fill=\"none\"\n                          stroke=\"currentColor\"\n                          stroke-width=\"2\"\n                          stroke-linecap=\"round\"\n                          stroke-linejoin=\"round\"\n                        >\n                          <path d=\"M5 12h14\" />\n                          <path d=\"M12 5v14\" />\n                        </svg>\n                        {{ t('gallery.details.tags.add') }}\n                      </Button>\n                    </PopoverTrigger>\n                    <PopoverContent align=\"end\" class=\"p-0\">\n                      <TagSelectorPopover\n                        :tags=\"store.tags\"\n                        :selected-tag-ids=\"assetTags.map((t) => t.id)\"\n                        @toggle=\"handleToggleTag\"\n                      />\n                    </PopoverContent>\n                  </Popover>\n                </div>\n\n                <div v-if=\"assetTags.length > 0\" class=\"flex flex-wrap gap-1.5\">\n                  <span\n                    v-for=\"tag in assetTags\"\n                    :key=\"tag.id\"\n                    class=\"group inline-flex items-center gap-1 rounded bg-primary/10 px-2 py-1 text-xs text-primary transition-colors hover:bg-primary/20\"\n                  >\n                    <span>{{ tag.name }}</span>\n                    <button\n                      class=\"flex h-3 w-3 items-center justify-center rounded-full opacity-60 transition-opacity hover:bg-primary/30 hover:opacity-100\"\n                      @click=\"handleRemoveTag(tag.id)\"\n                    >\n                      <svg\n                        xmlns=\"http://www.w3.org/2000/svg\"\n                        width=\"10\"\n                        height=\"10\"\n                        viewBox=\"0 0 24 24\"\n                        fill=\"none\"\n                        stroke=\"currentColor\"\n                        stroke-width=\"2\"\n                        stroke-linecap=\"round\"\n                        stroke-linejoin=\"round\"\n                      >\n                        <path d=\"M18 6 6 18\" />\n                        <path d=\"m6 6 12 12\" />\n                      </svg>\n                    </button>\n                  </span>\n                </div>\n                <div v-else class=\"text-xs text-muted-foreground\">\n                  {{ t('gallery.details.tags.empty') }}\n                </div>\n              </div>\n\n              <Separator />\n            </div>\n          </template>\n\n          <template #after-info>\n            <AssetInfinityNikkiDetails\n              v-if=\"infinityNikkiEnabled && infinityNikkiDetails && activeAsset\"\n              :asset-id=\"activeAsset.id\"\n              :details=\"infinityNikkiDetails\"\n              @updated=\"handleInfinityNikkiDetailsUpdated\"\n            />\n\n            <template v-if=\"shouldShowAssetHistogram\">\n              <Separator />\n\n              <AssetHistogram :cache-key=\"assetHistogramCacheKey\" :image-url=\"thumbnailUrl\" />\n            </template>\n          </template>\n        </AssetDetailsContent>\n      </div>\n\n      <!-- 标签详情 -->\n      <div v-else-if=\"detailsFocus.type === 'tag' && currentTag\" class=\"space-y-4\">\n        <h3 class=\"font-medium\">{{ t('gallery.details.title') }}</h3>\n\n        <div v-if=\"isRootTagSummary\">\n          <h4 class=\"mb-2 text-sm font-medium\">{{ t('gallery.details.rootTagSummary.title') }}</h4>\n          <div class=\"space-y-2 text-xs\">\n            <div class=\"flex justify-between gap-2\">\n              <span class=\"text-muted-foreground\">{{\n                t('gallery.details.rootTagSummary.tagCount')\n              }}</span>\n              <span>{{ rootTagCount }}</span>\n            </div>\n            <div class=\"flex justify-between gap-2\">\n              <span class=\"text-muted-foreground\">{{\n                t('gallery.details.rootTagSummary.assetCount')\n              }}</span>\n              <span>{{ rootTagAssetTotalCount }}</span>\n            </div>\n          </div>\n        </div>\n\n        <!-- 标签信息 -->\n        <div v-else>\n          <h4 class=\"mb-2 text-sm font-medium\">{{ t('gallery.details.tagInfo') }}</h4>\n          <div class=\"space-y-2 text-xs\">\n            <div class=\"flex justify-between gap-2\">\n              <span class=\"text-muted-foreground\">{{ t('gallery.details.tagName') }}</span>\n              <span class=\"truncate font-medium\" :title=\"currentTag.name\">\n                {{ currentTag.name }}\n              </span>\n            </div>\n            <div v-if=\"currentTag.parentId\" class=\"flex justify-between gap-2\">\n              <span class=\"text-muted-foreground\">{{ t('gallery.details.parentTagId') }}</span>\n              <span>{{ currentTag.parentId }}</span>\n            </div>\n            <div class=\"flex justify-between gap-2\">\n              <span class=\"text-muted-foreground\">{{ t('gallery.details.assetCount') }}</span>\n              <span>{{ t('gallery.details.itemCount', { count: currentTag.assetCount }) }}</span>\n            </div>\n            <div class=\"flex justify-between gap-2\">\n              <span class=\"text-muted-foreground\">{{ t('gallery.details.sortOrder') }}</span>\n              <span>{{ currentTag.sortOrder }}</span>\n            </div>\n          </div>\n        </div>\n      </div>\n\n      <!-- 批量操作 -->\n      <div v-else-if=\"detailsFocus.type === 'batch'\" class=\"space-y-4\">\n        <h3 class=\"font-medium\">{{ t('gallery.details.batch.title') }}</h3>\n\n        <div class=\"text-sm text-muted-foreground\">\n          {{ t('gallery.details.batch.selectedCount', { count: selectedCount }) }}\n        </div>\n\n        <div class=\"space-y-2\">\n          <h4 class=\"text-sm font-medium\">{{ t('gallery.details.review.title') }}</h4>\n          <AssetReviewControls\n            :rating=\"batchActiveAsset?.rating ?? 0\"\n            :review-flag=\"batchActiveAsset?.reviewFlag ?? 'none'\"\n            :rating-indeterminate=\"true\"\n            @set-rating=\"handleSetRating\"\n            @clear-rating=\"handleClearRating\"\n            @set-flag=\"handleSetRejected\"\n            @clear-flag=\"handleClearRejected\"\n          />\n        </div>\n\n        <template v-if=\"batchActiveAsset\">\n          <Separator />\n\n          <h4 class=\"text-sm font-medium\">{{ t('gallery.details.batch.currentFocus') }}</h4>\n          <AssetDetailsContent\n            :asset=\"batchActiveAsset\"\n            :thumbnail-url=\"batchThumbnailUrl\"\n            :asset-url=\"batchAssetUrl\"\n          />\n        </template>\n        <div v-else class=\"text-xs text-muted-foreground\">\n          {{ t('gallery.details.batch.focusUnavailable') }}\n        </div>\n\n        <Separator />\n\n        <div class=\"space-y-2\">\n          <p class=\"text-sm font-medium\">{{ t('gallery.details.batch.helpTitle') }}</p>\n          <div class=\"text-xs text-muted-foreground\">\n            {{ t('gallery.details.batch.reviewHint') }}\n          </div>\n        </div>\n      </div>\n\n      <!-- 空状态 -->\n      <div v-else class=\"flex h-[calc(100vh-74px)] items-center justify-center\">\n        <div class=\"text-center text-muted-foreground\">\n          <svg\n            xmlns=\"http://www.w3.org/2000/svg\"\n            width=\"48\"\n            height=\"48\"\n            viewBox=\"0 0 24 24\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            stroke-width=\"2\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\"\n            class=\"mx-auto mb-4 opacity-50\"\n          >\n            <rect width=\"18\" height=\"18\" x=\"3\" y=\"3\" rx=\"2\" ry=\"2\" />\n            <circle cx=\"9\" cy=\"9\" r=\"2\" />\n            <path d=\"m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21\" />\n          </svg>\n          <p class=\"text-sm\">{{ t('gallery.details.empty') }}</p>\n        </div>\n      </div>\n    </div>\n  </ScrollArea>\n</template>\n"
  },
  {
    "path": "web/src/features/gallery/components/shell/GalleryScrollbarRail.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed, onMounted, onUnmounted, ref } from 'vue'\nimport { useThrottleFn } from '@vueuse/core'\n\n// 通用滚动轨道的数据模型。\n// 轨道本身不关心“这是月份还是别的业务标记”，只关心内容坐标与展示文案。\nexport interface GalleryScrollbarMarker {\n  id: string\n  contentOffset: number\n  label?: string\n}\n\nexport interface GalleryScrollbarLabel {\n  id: string\n  text: string\n  contentOffset: number\n}\n\nconst props = withDefaults(\n  defineProps<{\n    containerHeight: number\n    scrollTop: number\n    viewportHeight: number\n    virtualizer: {\n      getTotalSize: () => number\n      scrollToOffset: (offset: number, options?: { behavior?: 'auto' | 'smooth' }) => void\n    }\n    markers?: GalleryScrollbarMarker[]\n    labels?: GalleryScrollbarLabel[]\n  }>(),\n  {\n    markers: () => [],\n    labels: () => [],\n  }\n)\n\nconst timelineRef = ref<HTMLElement | null>(null)\n\n// 顶底各留一段安全区，避免年份标签和指示器紧贴边缘。\nconst CONTENT_OFFSET_TOP = 24\nconst CONTENT_OFFSET_BOTTOM = 24\n\nconst availableHeight = computed(() => {\n  return Math.max(0, props.containerHeight - CONTENT_OFFSET_TOP - CONTENT_OFFSET_BOTTOM)\n})\n\n// 拖拽和 hover 都以轨道坐标表达，再统一映射回内容坐标。\nconst isDragging = ref(false)\nconst hoverY = ref<number | null>(null)\n\nfunction contentToTimeline(contentY: number): number {\n  const totalContentHeight = props.virtualizer.getTotalSize()\n  if (totalContentHeight === 0 || availableHeight.value === 0) {\n    return CONTENT_OFFSET_TOP\n  }\n\n  const ratio = contentY / totalContentHeight\n  return CONTENT_OFFSET_TOP + ratio * availableHeight.value\n}\n\nfunction timelineToContent(timelineY: number): number {\n  const totalContentHeight = props.virtualizer.getTotalSize()\n  if (availableHeight.value === 0) {\n    return 0\n  }\n\n  const adjustedY = Math.max(0, timelineY - CONTENT_OFFSET_TOP)\n  return (adjustedY / availableHeight.value) * totalContentHeight\n}\n\n// 把业务侧传入的“内容偏移量”统一映射成轨道坐标，后续模板无需了解内容总高度。\nconst mappedMarkers = computed(() => {\n  return props.markers.map((marker) => ({\n    ...marker,\n    offsetTop: contentToTimeline(marker.contentOffset),\n  }))\n})\n\nconst mappedLabels = computed(() => {\n  return props.labels.map((label) => ({\n    ...label,\n    offsetTop: Math.max(CONTENT_OFFSET_TOP - 25, contentToTimeline(label.contentOffset) - 25),\n  }))\n})\n\nconst indicatorTop = computed(() => {\n  return contentToTimeline(props.scrollTop)\n})\n\n// hover 提示复用最近邻 marker；没有 marker 时，轨道仍可作为普通滚动条使用。\nconst hoverLabel = computed(() => {\n  if (hoverY.value === null || mappedMarkers.value.length === 0) {\n    return null\n  }\n\n  let closestMarker: (typeof mappedMarkers.value)[number] | null = null\n  let minDistance = Infinity\n\n  for (const marker of mappedMarkers.value) {\n    const distance = Math.abs(marker.offsetTop - hoverY.value)\n    if (distance < minDistance) {\n      minDistance = distance\n      closestMarker = marker\n    }\n  }\n\n  return closestMarker?.label ?? null\n})\n\nfunction mapTimelineToContent(timelineY: number): number {\n  return timelineToContent(timelineY)\n}\n\n// 拖动使用节流，避免在高频 mousemove 下持续触发大范围重排。\nconst throttledScroll = useThrottleFn((y: number) => {\n  const targetScrollTop = mapTimelineToContent(y)\n  props.virtualizer.scrollToOffset(targetScrollTop, { behavior: 'auto' })\n}, 16)\n\nfunction handleMouseMove(event: MouseEvent) {\n  if (!timelineRef.value) {\n    return\n  }\n\n  const rect = timelineRef.value.getBoundingClientRect()\n  hoverY.value = event.clientY - rect.top\n}\n\nfunction handleMouseLeave() {\n  if (!isDragging.value) {\n    hoverY.value = null\n  }\n}\n\nfunction handleGlobalMouseMove(event: MouseEvent) {\n  if (!isDragging.value || !timelineRef.value) {\n    return\n  }\n\n  const rect = timelineRef.value.getBoundingClientRect()\n  const relativeY = event.clientY - rect.top\n  const clampedY = Math.max(0, Math.min(relativeY, rect.height))\n  throttledScroll(clampedY)\n}\n\nfunction handleMouseDown(event: MouseEvent) {\n  if (!timelineRef.value) {\n    return\n  }\n\n  isDragging.value = true\n  const rect = timelineRef.value.getBoundingClientRect()\n  const relativeY = event.clientY - rect.top\n  throttledScroll(relativeY)\n}\n\nfunction handleGlobalMouseUp() {\n  isDragging.value = false\n}\n\nfunction handleWheel(event: WheelEvent) {\n  event.preventDefault()\n\n  // 在轨道上滚轮时，直接把增量转发给内容区，保持与主视图一致的滚动手感。\n  const newScrollTop = props.scrollTop + event.deltaY\n  const totalContentHeight = props.virtualizer.getTotalSize()\n  const maxScrollTop = Math.max(0, totalContentHeight - props.viewportHeight)\n  const clampedScrollTop = Math.max(0, Math.min(newScrollTop, maxScrollTop))\n  props.virtualizer.scrollToOffset(clampedScrollTop, { behavior: 'auto' })\n}\n\nonMounted(() => {\n  document.addEventListener('mousemove', handleGlobalMouseMove)\n  document.addEventListener('mouseup', handleGlobalMouseUp)\n})\n\nonUnmounted(() => {\n  document.removeEventListener('mousemove', handleGlobalMouseMove)\n  document.removeEventListener('mouseup', handleGlobalMouseUp)\n})\n</script>\n\n<template>\n  <div\n    ref=\"timelineRef\"\n    class=\"timeline-scrollbar w-10 transition-all select-none\"\n    @mousedown=\"handleMouseDown\"\n    @mousemove=\"handleMouseMove\"\n    @mouseleave=\"handleMouseLeave\"\n    @wheel=\"handleWheel\"\n  >\n    <div class=\"relative h-full\">\n      <div\n        v-for=\"marker in mappedMarkers\"\n        :key=\"marker.id\"\n        class=\"pointer-events-none absolute right-2 h-1.5 w-1.5 rounded-full bg-border\"\n        :style=\"{ top: `${marker.offsetTop - 3}px` }\"\n      />\n\n      <div\n        v-for=\"label in mappedLabels\"\n        :key=\"label.id\"\n        class=\"pointer-events-none absolute right-0 left-0 px-2 py-1 text-right text-xs text-foreground\"\n        :style=\"{ top: `${label.offsetTop}px` }\"\n      >\n        {{ label.text }}\n      </div>\n\n      <div\n        v-if=\"hoverY !== null && !isDragging\"\n        class=\"pointer-events-none absolute right-1 left-2 rounded-sm bg-primary/40\"\n        :style=\"{ top: `${hoverY - 2}px`, height: '4px' }\"\n      />\n\n      <div\n        class=\"pointer-events-none absolute right-1 left-2 rounded-sm bg-primary shadow-lg\"\n        :style=\"{ top: `${indicatorTop - 2}px`, height: '4px' }\"\n      />\n\n      <div\n        v-if=\"hoverLabel\"\n        class=\"animate-fade-in pointer-events-none absolute -left-20 z-20 rounded-sm bg-popover/90 px-2 text-xs leading-6 text-popover-foreground shadow-md\"\n        :style=\"{ top: `${hoverY! - 12}px`, height: '24px' }\"\n      >\n        {{ hoverLabel }}\n      </div>\n    </div>\n  </div>\n</template>\n\n<style scoped>\n@keyframes fade-in {\n  from {\n    opacity: 0;\n  }\n  to {\n    opacity: 1;\n  }\n}\n\n.animate-fade-in {\n  animation: fade-in 0.15s ease-in-out;\n}\n</style>\n"
  },
  {
    "path": "web/src/features/gallery/components/shell/GallerySidebar.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed, onMounted, ref } from 'vue'\nimport { cn } from '@/lib/utils'\nimport { useI18n } from '@/composables/useI18n'\nimport { useToast } from '@/composables/useToast'\nimport { Button } from '@/components/ui/button'\nimport { Separator } from '@/components/ui/separator'\nimport { Plus } from 'lucide-vue-next'\nimport {\n  AlertDialog,\n  AlertDialogAction,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n} from '@/components/ui/alert-dialog'\nimport { useGallerySidebar, useGalleryData } from '../../composables'\nimport { useGalleryStore } from '../../store'\nimport type { FolderTreeNode } from '../../types'\nimport FolderTreeItem from '../folders/FolderTreeItem.vue'\nimport TagTreeItem from '../tags/TagTreeItem.vue'\nimport TagInlineEditor from '../tags/TagInlineEditor.vue'\nimport GalleryScanDialog from '../dialogs/GalleryScanDialog.vue'\nimport InfinityNikkiMetadataExtractDialog from '../infinity_nikki/InfinityNikkiMetadataExtractDialog.vue'\nimport { galleryApi } from '../../api'\nimport { hasGalleryAssetDragIds } from '../../composables/useGalleryDragPayload'\nimport type { ScanAssetsParams } from '../../api/dto'\nimport {\n  extractInfinityNikkiUidFromFolderPath,\n  INFINITY_NIKKI_LAST_UID_STORAGE_KEY,\n} from '@/extensions/infinity_nikki'\n\nconst galleryData = useGalleryData()\nconst galleryStore = useGalleryStore()\nconst { toast } = useToast()\nconst { t } = useI18n()\n\nconst {\n  folders,\n  foldersLoading,\n  foldersError,\n  tags,\n  tagsLoading,\n  tagsError,\n  selectedFolder,\n  selectedTag,\n  selectFolder,\n  clearFolderFilter,\n  updateFolderDisplayName,\n  openFolderInExplorer,\n  removeFolderWatch,\n  clearTagFilter,\n  selectTag,\n  loadTagTree,\n  createTag,\n  updateTag,\n  deleteTag,\n} = useGallerySidebar()\n\n// 标签创建状态\nconst isCreatingTag = ref(false)\n\nconst showAddFolderDialog = ref(false)\nconst showInfinityNikkiMetadataDialog = ref(false)\nconst infinityNikkiMetadataFolderId = ref<number | null>(null)\nconst infinityNikkiMetadataFolderName = ref('')\nconst infinityNikkiMetadataInitialUid = ref('')\nconst showRescanDialog = ref(false)\nconst rescanFolderId = ref<number | null>(null)\nconst rescanFolderName = ref('')\n\n// 区块标题表示该维度的「全体 / 未限定」：与 store.filter 一致，不跟详情面板耦合\nconst isFolderTitleSelected = computed(() => selectedFolder.value === null)\nconst isTagTitleSelected = computed(() => selectedTag.value === null)\n\nfunction startAddFolder() {\n  showAddFolderDialog.value = true\n}\n\nfunction handleAddFolderDialogOpenChange(open: boolean) {\n  showAddFolderDialog.value = open\n}\n\nfunction openInfinityNikkiMetadataDialog(folderId: number, folderName: string) {\n  infinityNikkiMetadataFolderId.value = folderId\n  infinityNikkiMetadataFolderName.value = folderName\n  const targetFolder = findFolderById(folders.value, folderId)\n  const uidFromFolderPath = targetFolder\n    ? extractInfinityNikkiUidFromFolderPath(targetFolder.path)\n    : null\n  const uidFromStorage = localStorage.getItem(INFINITY_NIKKI_LAST_UID_STORAGE_KEY)?.trim() || ''\n  infinityNikkiMetadataInitialUid.value = uidFromFolderPath ?? uidFromStorage\n  showInfinityNikkiMetadataDialog.value = true\n}\n\nfunction handleInfinityNikkiMetadataDialogOpenChange(open: boolean) {\n  showInfinityNikkiMetadataDialog.value = open\n  if (!open) {\n    infinityNikkiMetadataFolderId.value = null\n    infinityNikkiMetadataFolderName.value = ''\n    infinityNikkiMetadataInitialUid.value = ''\n  }\n}\n\nfunction openRescanDialog(folderId: number, folderName: string) {\n  rescanFolderId.value = folderId\n  rescanFolderName.value = folderName\n  showRescanDialog.value = true\n}\n\nfunction handleRescanDialogOpenChange(open: boolean) {\n  showRescanDialog.value = open\n}\n\nfunction resetRescanDialogState() {\n  showRescanDialog.value = false\n  rescanFolderId.value = null\n  rescanFolderName.value = ''\n}\n\nfunction findFolderById(nodes: FolderTreeNode[], folderId: number): FolderTreeNode | null {\n  for (const node of nodes) {\n    if (node.id === folderId) {\n      return node\n    }\n    const foundInChildren = findFolderById(node.children, folderId)\n    if (foundInChildren) {\n      return foundInChildren\n    }\n  }\n  return null\n}\n\nfunction startCreateTag() {\n  isCreatingTag.value = true\n}\n\nasync function handleCreateTag(name: string) {\n  try {\n    await createTag(name)\n    isCreatingTag.value = false\n  } catch (error) {\n    console.error('Failed to create tag:', error)\n  }\n}\n\nfunction handleCancelCreateTag() {\n  isCreatingTag.value = false\n}\n\nasync function handleRenameTag(tagId: number, newName: string) {\n  try {\n    await updateTag(tagId, newName)\n  } catch (error) {\n    console.error('Failed to rename tag:', error)\n  }\n}\n\nasync function handleCreateChildTag(parentId: number, name: string) {\n  try {\n    await createTag(name, parentId)\n  } catch (error) {\n    console.error('Failed to create child tag:', error)\n  }\n}\n\nasync function handleDeleteTag(tagId: number) {\n  try {\n    await deleteTag(tagId)\n  } catch (error) {\n    console.error('Failed to delete tag:', error)\n  }\n}\n\nfunction folderExistsById(nodes: FolderTreeNode[], folderId: number): boolean {\n  for (const node of nodes) {\n    if (node.id === folderId) {\n      return true\n    }\n    if (node.children && folderExistsById(node.children, folderId)) {\n      return true\n    }\n  }\n  return false\n}\n\nasync function handleRenameFolderDisplayName(folderId: number, displayName: string) {\n  try {\n    await updateFolderDisplayName(folderId, displayName)\n    await galleryData.loadFolderTree()\n\n    if (selectedFolder.value === folderId) {\n      const folderName = displayName.trim()\n      selectFolder(folderId, folderName)\n    }\n  } catch (error) {\n    const message = error instanceof Error ? error.message : String(error)\n    toast.error(t('gallery.sidebar.folders.rename.failedTitle'), { description: message })\n  }\n}\n\nasync function handleOpenFolderInExplorer(folderId: number) {\n  try {\n    await openFolderInExplorer(folderId)\n  } catch (error) {\n    const message = error instanceof Error ? error.message : String(error)\n    toast.error(t('gallery.sidebar.folders.openInExplorer.failedTitle'), { description: message })\n  }\n}\n\nasync function handleRemoveFolderWatch(folderId: number) {\n  try {\n    await removeFolderWatch(folderId)\n\n    await galleryData.loadFolderTree()\n\n    const currentSelectedFolderId = selectedFolder.value\n    if (\n      currentSelectedFolderId !== null &&\n      !folderExistsById(galleryStore.folders, currentSelectedFolderId)\n    ) {\n      clearFolderFilter()\n    }\n\n    if (galleryStore.isTimelineMode) {\n      await galleryData.loadTimelineData()\n    } else {\n      await galleryData.loadAllAssets()\n    }\n\n    toast.success(t('gallery.sidebar.folders.removeWatch.successTitle'), {\n      description: t('gallery.sidebar.folders.removeWatch.successDescription'),\n    })\n  } catch (error) {\n    const message = error instanceof Error ? error.message : String(error)\n    toast.error(t('gallery.sidebar.folders.removeWatch.failedTitle'), { description: message })\n  }\n}\n\nasync function confirmRescanFolder() {\n  const folderId = rescanFolderId.value\n  if (folderId === null) {\n    return\n  }\n\n  const targetFolder = findFolderById(folders.value, folderId)\n  if (!targetFolder) {\n    toast.error(t('gallery.sidebar.folders.rescan.failedTitle'), {\n      description: t('gallery.sidebar.folders.rescan.folderNotFoundDescription'),\n    })\n    resetRescanDialogState()\n    return\n  }\n\n  try {\n    const rescanParams: ScanAssetsParams = {\n      directory: targetFolder.path,\n      forceReanalyze: true,\n      rebuildThumbnails: true,\n    }\n    const result = await galleryData.startScanAssets(rescanParams)\n    toast.success(t('gallery.sidebar.folders.rescan.queuedTitle'), {\n      description: t('gallery.sidebar.folders.rescan.queuedDescription', {\n        taskId: result.taskId,\n      }),\n    })\n    resetRescanDialogState()\n  } catch (error) {\n    const message = error instanceof Error ? error.message : String(error)\n    toast.error(t('gallery.sidebar.folders.rescan.failedTitle'), { description: message })\n  }\n}\n\nasync function handleDropAssetsToFolder(folderId: number, assetIds: number[]) {\n  const uniqueIds = [...new Set(assetIds)]\n  if (uniqueIds.length === 0) {\n    return\n  }\n\n  try {\n    const result = await galleryApi.moveAssetsToFolder({\n      ids: uniqueIds,\n      targetFolderId: folderId,\n    })\n    const affectedCount = result.affectedCount ?? 0\n    const failedCount = result.failedCount ?? 0\n    const notFoundCount = result.notFoundCount ?? 0\n    const unchangedCount = result.unchangedCount ?? 0\n    if (!result.success && affectedCount === 0) {\n      throw new Error(\n        t('gallery.sidebar.folders.moveAssets.failedDescription', {\n          failed: failedCount,\n          notFound: notFoundCount,\n          unchanged: unchangedCount,\n        })\n      )\n    }\n\n    await Promise.all([\n      galleryData.loadFolderTree({ silent: true }),\n      galleryData.refreshCurrentQuery(),\n    ])\n    galleryStore.clearSelection()\n\n    if (result.success) {\n      toast.success(t('gallery.sidebar.folders.moveAssets.successTitle'), {\n        description: t('gallery.sidebar.folders.moveAssets.successDescription', {\n          count: affectedCount,\n        }),\n      })\n    } else {\n      toast.warning(t('gallery.sidebar.folders.moveAssets.partialTitle'), {\n        description: t('gallery.sidebar.folders.moveAssets.partialDescription', {\n          moved: affectedCount,\n          failed: failedCount,\n          notFound: notFoundCount,\n          unchanged: unchangedCount,\n        }),\n      })\n    }\n  } catch (error) {\n    const message = error instanceof Error ? error.message : String(error)\n    toast.error(t('gallery.sidebar.folders.moveAssets.failedTitle'), { description: message })\n  }\n}\n\nasync function handleDropAssetsToTag(tagId: number, assetIds: number[]) {\n  const uniqueIds = [...new Set(assetIds)]\n  if (uniqueIds.length === 0) {\n    return\n  }\n\n  try {\n    const result = await galleryApi.addTagToAssets({\n      assetIds: uniqueIds,\n      tagId,\n    })\n    const affectedCount = result.affectedCount ?? 0\n    const failedCount = result.failedCount ?? 0\n    const unchangedCount = result.unchangedCount ?? 0\n\n    if (!result.success && affectedCount === 0) {\n      throw new Error(\n        t('gallery.sidebar.tags.addAssets.failedDescription', {\n          failed: failedCount,\n          unchanged: unchangedCount,\n        })\n      )\n    }\n\n    await Promise.all([loadTagTree(), galleryData.refreshCurrentQuery()])\n    galleryStore.clearSelection()\n\n    if (result.success && failedCount === 0 && unchangedCount === 0) {\n      toast.success(t('gallery.sidebar.tags.addAssets.successTitle'), {\n        description: t('gallery.sidebar.tags.addAssets.successDescription', {\n          count: affectedCount,\n        }),\n      })\n    } else {\n      toast.warning(t('gallery.sidebar.tags.addAssets.partialTitle'), {\n        description: t('gallery.sidebar.tags.addAssets.partialDescription', {\n          affected: affectedCount,\n          failed: failedCount,\n          unchanged: unchangedCount,\n        }),\n      })\n    }\n  } catch (error) {\n    const message = error instanceof Error ? error.message : String(error)\n    toast.error(t('gallery.sidebar.tags.addAssets.failedTitle'), { description: message })\n  }\n}\n\nfunction handleSidebarDragOver(event: DragEvent) {\n  if (!hasGalleryAssetDragIds(event)) {\n    return\n  }\n  // 让侧边栏空白区域也保持可放置手势，避免出现系统“禁止”图标。\n  event.preventDefault()\n  if (event.dataTransfer) {\n    event.dataTransfer.dropEffect = 'move'\n  }\n}\n\nfunction handleSidebarDrop(event: DragEvent) {\n  if (!hasGalleryAssetDragIds(event)) {\n    return\n  }\n  // 真正移动仍由 FolderTreeItem 的 drop 处理；这里仅消费默认 drop 行为。\n  event.preventDefault()\n}\n\nonMounted(() => {\n  galleryData.loadFolderTree()\n  loadTagTree()\n})\n</script>\n\n<template>\n  <div class=\"flex h-full flex-col\" @dragover=\"handleSidebarDragOver\" @drop=\"handleSidebarDrop\">\n    <AlertDialog :open=\"showRescanDialog\" @update:open=\"handleRescanDialogOpenChange\">\n      <AlertDialogContent>\n        <AlertDialogHeader>\n          <AlertDialogTitle>\n            {{\n              t('gallery.sidebar.folders.rescan.confirmTitle', {\n                name: rescanFolderName,\n              })\n            }}\n          </AlertDialogTitle>\n          <AlertDialogDescription>\n            {{ t('gallery.sidebar.folders.rescan.confirmDescription') }}\n          </AlertDialogDescription>\n        </AlertDialogHeader>\n        <AlertDialogFooter>\n          <AlertDialogCancel @click=\"resetRescanDialogState\">\n            {{ t('gallery.sidebar.folders.rescan.cancel') }}\n          </AlertDialogCancel>\n          <AlertDialogAction @click=\"confirmRescanFolder\">\n            {{ t('gallery.sidebar.folders.rescan.confirm') }}\n          </AlertDialogAction>\n        </AlertDialogFooter>\n      </AlertDialogContent>\n    </AlertDialog>\n\n    <!-- 导航菜单 -->\n    <div class=\"flex-1 overflow-auto p-4\">\n      <!-- 文件夹区域 -->\n      <div class=\"space-y-2\">\n        <div class=\"flex items-center justify-between\">\n          <button\n            type=\"button\"\n            :class=\"\n              cn(\n                'cursor-pointer rounded-md px-2 py-1 text-left text-xs font-medium tracking-wider uppercase transition-colors duration-200 ease-out',\n                'focus-visible:ring-2 focus-visible:ring-sidebar-ring focus-visible:ring-offset-2 focus-visible:outline-none',\n                isFolderTitleSelected\n                  ? 'bg-sidebar-accent font-medium text-primary hover:text-primary'\n                  : 'text-sidebar-foreground hover:bg-sidebar-hover hover:text-sidebar-accent-foreground'\n              )\n            \"\n            @click=\"clearFolderFilter\"\n          >\n            {{ t('gallery.sidebar.folders.title') }}\n          </button>\n          <Button variant=\"sidebarGhost\" size=\"icon-xs\" @click=\"startAddFolder\">\n            <Plus class=\"h-3 w-3\" />\n          </Button>\n        </div>\n        <!-- 加载状态 -->\n        <div v-if=\"foldersLoading\" class=\"px-2 text-xs text-muted-foreground\">\n          {{ t('gallery.sidebar.common.loading') }}\n        </div>\n        <!-- 错误状态 -->\n        <div v-else-if=\"foldersError\" class=\"px-2 text-xs text-destructive\">\n          {{ foldersError }}\n        </div>\n        <!-- 文件夹树 -->\n        <div v-else class=\"space-y-1\">\n          <FolderTreeItem\n            v-for=\"folder in folders\"\n            :key=\"folder.id\"\n            :folder=\"folder\"\n            :selected-folder=\"selectedFolder\"\n            :depth=\"0\"\n            @select=\"selectFolder\"\n            @clear-selection=\"clearFolderFilter\"\n            @rename-display-name=\"handleRenameFolderDisplayName\"\n            @open-in-explorer=\"handleOpenFolderInExplorer\"\n            @remove-watch=\"handleRemoveFolderWatch\"\n            @rescan-folder=\"openRescanDialog\"\n            @extract-infinity-nikki-metadata=\"openInfinityNikkiMetadataDialog\"\n            @drop-assets-to-folder=\"handleDropAssetsToFolder\"\n          />\n        </div>\n      </div>\n\n      <Separator class=\"my-4\" />\n\n      <!-- 标签区域 -->\n      <div class=\"space-y-2\">\n        <div class=\"flex items-center justify-between\">\n          <button\n            type=\"button\"\n            :class=\"\n              cn(\n                'cursor-pointer rounded-md px-2 py-1 text-left text-xs font-medium tracking-wider uppercase transition-colors duration-200 ease-out',\n                'focus-visible:ring-2 focus-visible:ring-sidebar-ring focus-visible:ring-offset-2 focus-visible:outline-none',\n                isTagTitleSelected\n                  ? 'bg-sidebar-accent font-medium text-primary hover:text-primary'\n                  : 'text-sidebar-foreground hover:bg-sidebar-hover hover:text-sidebar-accent-foreground'\n              )\n            \"\n            @click=\"clearTagFilter\"\n          >\n            {{ t('gallery.sidebar.tags.title') }}\n          </button>\n          <Button variant=\"sidebarGhost\" size=\"icon-xs\" @click=\"startCreateTag\">\n            <Plus class=\"h-3 w-3\" />\n          </Button>\n        </div>\n        <!-- 加载状态 -->\n        <div v-if=\"tagsLoading\" class=\"px-2 text-xs text-muted-foreground\">\n          {{ t('gallery.sidebar.common.loading') }}\n        </div>\n        <!-- 错误状态 -->\n        <div v-else-if=\"tagsError\" class=\"px-2 text-xs text-destructive\">\n          {{ tagsError }}\n        </div>\n        <!-- 标签树 -->\n        <div v-else class=\"space-y-1\">\n          <!-- 快速创建标签 -->\n          <div v-if=\"isCreatingTag\" class=\"px-2\">\n            <TagInlineEditor\n              :placeholder=\"t('gallery.sidebar.tags.createPlaceholder')\"\n              @confirm=\"handleCreateTag\"\n              @cancel=\"handleCancelCreateTag\"\n            />\n          </div>\n          <!-- 标签列表 -->\n          <TagTreeItem\n            v-for=\"tag in tags\"\n            :key=\"tag.id\"\n            :tag=\"tag\"\n            :selected-tag=\"selectedTag\"\n            :depth=\"0\"\n            @select=\"selectTag\"\n            @rename=\"handleRenameTag\"\n            @create-child=\"handleCreateChildTag\"\n            @delete=\"handleDeleteTag\"\n            @drop-assets-to-tag=\"handleDropAssetsToTag\"\n          />\n        </div>\n      </div>\n    </div>\n\n    <GalleryScanDialog :open=\"showAddFolderDialog\" @update:open=\"handleAddFolderDialogOpenChange\" />\n    <InfinityNikkiMetadataExtractDialog\n      :open=\"showInfinityNikkiMetadataDialog\"\n      :folder-id=\"infinityNikkiMetadataFolderId\"\n      :folder-name=\"infinityNikkiMetadataFolderName\"\n      :initial-uid=\"infinityNikkiMetadataInitialUid\"\n      @update:open=\"handleInfinityNikkiMetadataDialogOpenChange\"\n    />\n  </div>\n</template>\n"
  },
  {
    "path": "web/src/features/gallery/components/shell/GalleryToolbar.vue",
    "content": "<template>\n  <div class=\"flex items-center justify-between gap-3 p-4\">\n    <!-- 左侧：搜索框 -->\n    <div class=\"max-w-[400px] flex-1\">\n      <div class=\"relative\">\n        <Search class=\"absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground\" />\n        <Input\n          :model-value=\"searchQuery\"\n          @update:model-value=\"updateSearchQuery\"\n          :placeholder=\"t('gallery.toolbar.search.placeholder')\"\n          class=\"pl-10\"\n        />\n        <Button\n          v-if=\"searchQuery\"\n          type=\"button\"\n          variant=\"sidebarGhost\"\n          size=\"icon-sm\"\n          class=\"absolute top-1/2 right-3 -translate-y-1/2\"\n          @click=\"clearSearch\"\n        >\n          <X class=\"h-4 w-4\" />\n        </Button>\n      </div>\n    </div>\n\n    <!-- 右侧：筛选、排序、视图控制 -->\n    <div class=\"flex shrink-0 items-center gap-2\">\n      <!-- 筛选与排序下拉菜单 -->\n      <TooltipProvider>\n        <Tooltip>\n          <TooltipTrigger as-child>\n            <div>\n              <DropdownMenu>\n                <DropdownMenuTrigger as-child>\n                  <Button variant=\"sidebarGhost\" size=\"sm\">\n                    <ListFilter class=\"h-4 w-4\" />\n                  </Button>\n                </DropdownMenuTrigger>\n                <DropdownMenuContent align=\"end\" class=\"w-56\">\n                  <!-- 类型筛选 -->\n                  <DropdownMenuLabel>{{\n                    t('gallery.toolbar.filter.type.label')\n                  }}</DropdownMenuLabel>\n                  <DropdownMenuRadioGroup\n                    :model-value=\"filter.type || 'all'\"\n                    @update:model-value=\"onTypeFilterChange\"\n                  >\n                    <DropdownMenuRadioItem value=\"all\">\n                      {{ t('gallery.toolbar.filter.type.all') }}\n                    </DropdownMenuRadioItem>\n                    <DropdownMenuRadioItem value=\"photo\">\n                      <Image class=\"mr-2 h-4 w-4\" />\n                      {{ t('gallery.toolbar.filter.type.photo') }}\n                    </DropdownMenuRadioItem>\n                    <DropdownMenuRadioItem value=\"video\">\n                      <Video class=\"mr-2 h-4 w-4\" />\n                      {{ t('gallery.toolbar.filter.type.video') }}\n                    </DropdownMenuRadioItem>\n                    <DropdownMenuRadioItem value=\"live_photo\">\n                      <Camera class=\"mr-2 h-4 w-4\" />\n                      {{ t('gallery.toolbar.filter.type.livePhoto') }}\n                    </DropdownMenuRadioItem>\n                  </DropdownMenuRadioGroup>\n\n                  <DropdownMenuSeparator />\n\n                  <!-- 排序方式 -->\n                  <DropdownMenuLabel>{{ t('gallery.toolbar.sort.label') }}</DropdownMenuLabel>\n                  <DropdownMenuRadioGroup\n                    :model-value=\"sortBy\"\n                    @update:model-value=\"onSortByChange\"\n                  >\n                    <DropdownMenuRadioItem value=\"createdAt\">\n                      <CalendarClock class=\"mr-2 h-4 w-4\" />\n                      {{ t('gallery.toolbar.sort.createdAt') }}\n                    </DropdownMenuRadioItem>\n                    <DropdownMenuRadioItem value=\"name\">\n                      <Type class=\"mr-2 h-4 w-4\" />\n                      {{ t('gallery.toolbar.sort.name') }}\n                    </DropdownMenuRadioItem>\n                    <DropdownMenuRadioItem value=\"resolution\">\n                      <Ruler class=\"mr-2 h-4 w-4\" />\n                      {{ t('gallery.toolbar.sort.resolution') }}\n                    </DropdownMenuRadioItem>\n                    <DropdownMenuRadioItem value=\"size\">\n                      <Ruler class=\"mr-2 h-4 w-4\" />\n                      {{ t('gallery.toolbar.sort.size') }}\n                    </DropdownMenuRadioItem>\n                  </DropdownMenuRadioGroup>\n\n                  <DropdownMenuSeparator />\n\n                  <!-- 排序顺序 -->\n                  <DropdownMenuItem @click=\"toggleSortOrder\">\n                    <ArrowUpDown class=\"mr-2 h-4 w-4\" />\n                    <span>\n                      {{\n                        sortOrder === 'asc'\n                          ? t('gallery.toolbar.sortOrder.asc')\n                          : t('gallery.toolbar.sortOrder.desc')\n                      }}\n                    </span>\n                  </DropdownMenuItem>\n\n                  <DropdownMenuSeparator />\n\n                  <!-- 文件夹选项 -->\n                  <DropdownMenuLabel>{{\n                    t('gallery.toolbar.folderOptions.label')\n                  }}</DropdownMenuLabel>\n                  <DropdownMenuCheckboxItem\n                    :model-value=\"includeSubfolders\"\n                    @update:model-value=\"toggleIncludeSubfolders\"\n                  >\n                    {{ t('gallery.toolbar.folderOptions.includeSubfolders') }}\n                  </DropdownMenuCheckboxItem>\n                </DropdownMenuContent>\n              </DropdownMenu>\n            </div>\n          </TooltipTrigger>\n          <TooltipContent>\n            <p>{{ t('gallery.toolbar.filterAndSort.tooltip') }}</p>\n          </TooltipContent>\n        </Tooltip>\n      </TooltipProvider>\n\n      <TooltipProvider>\n        <Tooltip>\n          <TooltipTrigger as-child>\n            <div>\n              <Popover v-model:open=\"colorPopoverOpen\">\n                <PopoverTrigger as-child>\n                  <Button variant=\"sidebarGhost\" size=\"sm\" class=\"relative\">\n                    <Palette class=\"h-4 w-4\" />\n                    <span\n                      v-if=\"activeColorHex\"\n                      class=\"absolute right-1 bottom-1 h-2.5 w-2.5 rounded-full border border-background\"\n                      :style=\"{ backgroundColor: activeColorHex }\"\n                    />\n                  </Button>\n                </PopoverTrigger>\n                <PopoverContent align=\"end\" class=\"w-auto p-3\">\n                  <div class=\"w-[220px] space-y-3\">\n                    <div class=\"flex items-center justify-between gap-3\">\n                      <div class=\"flex min-w-0 items-center gap-2\">\n                        <div\n                          class=\"h-5 w-5 shrink-0 rounded border border-border/80\"\n                          :style=\"{ backgroundColor: activeColorHex || draftColorHex }\"\n                        />\n                        <div class=\"min-w-0\">\n                          <p class=\"text-xs font-medium\">\n                            {{ t('gallery.toolbar.colorFilter.title') }}\n                          </p>\n                          <p class=\"truncate font-mono text-[11px] text-muted-foreground\">\n                            {{ activeColorHex || t('gallery.toolbar.colorFilter.none') }}\n                          </p>\n                        </div>\n                      </div>\n                      <Button\n                        v-if=\"activeColorHex\"\n                        variant=\"sidebarGhost\"\n                        size=\"sm\"\n                        class=\"h-7 px-2 text-xs\"\n                        @click=\"clearColorFilter\"\n                      >\n                        {{ t('gallery.toolbar.colorFilter.clear') }}\n                      </Button>\n                    </div>\n\n                    <ColorPicker\n                      :model-value=\"draftColorHex\"\n                      @update:model-value=\"(color) => (draftColorHex = color)\"\n                    />\n\n                    <div class=\"flex justify-end\">\n                      <Button size=\"sm\" class=\"h-7 px-3 text-xs\" @click=\"applyColorFilter\">\n                        {{ t('gallery.toolbar.colorFilter.apply') }}\n                      </Button>\n                    </div>\n                  </div>\n                </PopoverContent>\n              </Popover>\n            </div>\n          </TooltipTrigger>\n          <TooltipContent>\n            <p>{{ t('gallery.toolbar.colorFilter.tooltip') }}</p>\n          </TooltipContent>\n        </Tooltip>\n      </TooltipProvider>\n\n      <!-- 评分与标记筛选 -->\n      <TooltipProvider>\n        <Tooltip>\n          <TooltipTrigger as-child>\n            <div>\n              <Popover>\n                <PopoverTrigger as-child>\n                  <Button\n                    variant=\"sidebarGhost\"\n                    size=\"sm\"\n                    class=\"relative\"\n                    :class=\"hasReviewFilter ? 'text-primary' : ''\"\n                  >\n                    <Flag class=\"h-4 w-4\" />\n                    <span\n                      v-if=\"hasReviewFilter\"\n                      class=\"absolute right-1 bottom-1 h-2 w-2 rounded-full bg-primary\"\n                    />\n                  </Button>\n                </PopoverTrigger>\n                <PopoverContent align=\"end\" class=\"w-56 p-3\">\n                  <ReviewFilterPopover\n                    :rating=\"filter.rating\"\n                    :review-flag=\"filter.reviewFlag\"\n                    @update:rating=\"(v) => galleryView.setFilter({ rating: v })\"\n                    @update:review-flag=\"(v) => galleryView.setFilter({ reviewFlag: v })\"\n                  />\n                </PopoverContent>\n              </Popover>\n            </div>\n          </TooltipTrigger>\n          <TooltipContent>\n            <p>{{ t('gallery.toolbar.filter.review.tooltip') }}</p>\n          </TooltipContent>\n        </Tooltip>\n      </TooltipProvider>\n\n      <!-- 视图设置（模式 + 大小调整） -->\n      <TooltipProvider>\n        <Tooltip>\n          <TooltipTrigger as-child>\n            <div>\n              <Popover>\n                <PopoverTrigger as-child>\n                  <Button variant=\"sidebarGhost\" size=\"sm\">\n                    <component :is=\"currentViewModeIcon\" class=\"h-4 w-4\" />\n                  </Button>\n                </PopoverTrigger>\n                <PopoverContent align=\"end\" class=\"w-72\">\n                  <div class=\"space-y-6\">\n                    <!-- 视图模式选择 -->\n                    <div class=\"space-y-3\">\n                      <p class=\"text-sm font-medium\">\n                        {{ t('gallery.toolbar.viewMode.label') }}\n                      </p>\n                      <div class=\"grid grid-cols-4 gap-2\">\n                        <Button\n                          v-for=\"mode in viewModes\"\n                          :key=\"mode.value\"\n                          :variant=\"viewMode === mode.value ? 'default' : 'outline'\"\n                          size=\"sm\"\n                          class=\"flex h-auto flex-col items-center gap-1.5 py-3\"\n                          @click=\"setViewMode(mode.value)\"\n                        >\n                          <component :is=\"mode.icon\" class=\"h-5 w-5\" />\n                          <span class=\"text-xs\">{{ t(mode.i18nKey) }}</span>\n                        </Button>\n                      </div>\n                    </div>\n\n                    <!-- 分隔线 -->\n                    <div class=\"border-t\" />\n\n                    <!-- 缩略图大小调整 -->\n                    <div class=\"space-y-3\">\n                      <div class=\"flex items-center\">\n                        <p class=\"text-sm font-medium\">\n                          {{ t('gallery.toolbar.thumbnailSize.label') }}\n                        </p>\n                      </div>\n                      <Slider\n                        :model-value=\"[currentSliderPosition]\"\n                        @update:model-value=\"onViewSizeSliderChange\"\n                        :min=\"0\"\n                        :max=\"100\"\n                        :step=\"1\"\n                        class=\"w-full\"\n                      />\n                      <div class=\"flex justify-between text-xs text-muted-foreground\">\n                        <span>{{ t('gallery.toolbar.thumbnailSize.fine') }}</span>\n                        <span>{{ t('gallery.toolbar.thumbnailSize.showcase') }}</span>\n                      </div>\n                    </div>\n                  </div>\n                </PopoverContent>\n              </Popover>\n            </div>\n          </TooltipTrigger>\n          <TooltipContent>\n            <p>{{ t('gallery.toolbar.viewSettings.tooltip') }}</p>\n          </TooltipContent>\n        </Tooltip>\n      </TooltipProvider>\n    </div>\n  </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { computed, ref, watch } from 'vue'\nimport { Button } from '@/components/ui/button'\nimport { Input } from '@/components/ui/input'\nimport { Slider } from '@/components/ui/slider'\nimport { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'\nimport { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'\nimport ColorPicker from '@/components/ui/color-picker/ColorPicker.vue'\nimport ReviewFilterPopover from '../tags/ReviewFilterPopover.vue'\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuLabel,\n  DropdownMenuRadioGroup,\n  DropdownMenuRadioItem,\n  DropdownMenuSeparator,\n  DropdownMenuTrigger,\n  DropdownMenuCheckboxItem,\n} from '@/components/ui/dropdown-menu'\nimport {\n  Search,\n  X,\n  ArrowUpDown,\n  Grid3x3,\n  LayoutGrid,\n  List,\n  Rows3,\n  ListFilter,\n  Image,\n  Video,\n  Camera,\n  CalendarClock,\n  Palette,\n  Type,\n  Ruler,\n  Flag,\n} from 'lucide-vue-next'\nimport { useI18n } from '@/composables/useI18n'\nimport { useGalleryView } from '../../composables'\nimport type { ViewMode, SortBy, AssetType } from '../../types'\n\n// i18n\nconst { t } = useI18n()\n\n// 使用视图管理逻辑\nconst galleryView = useGalleryView()\n\n// 计算属性\nconst viewMode = computed(() => galleryView.viewMode.value)\nconst sortBy = computed(() => galleryView.sortBy.value)\nconst sortOrder = computed(() => galleryView.sortOrder.value)\nconst filter = computed(() => galleryView.filter.value)\nconst searchQuery = computed(() => filter.value.searchQuery || '')\nconst includeSubfolders = computed(() => galleryView.includeSubfolders.value)\nconst activeColorHex = computed(() => filter.value.colorHex)\n\n// 当前slider位置（从实际尺寸反向计算）\nconst currentSliderPosition = computed(() => galleryView.getSliderPosition())\nconst colorPopoverOpen = ref(false)\nconst draftColorHex = ref(activeColorHex.value || '#FFFFFF')\n\n// 评分与标记筛选\nconst hasReviewFilter = computed(\n  () => filter.value.rating !== undefined || filter.value.reviewFlag !== undefined\n)\n\n// 视图模式选项\nconst viewModes = [\n  { value: 'grid' as ViewMode, icon: Grid3x3, i18nKey: 'gallery.toolbar.viewMode.grid' },\n  { value: 'adaptive' as ViewMode, icon: Rows3, i18nKey: 'gallery.toolbar.viewMode.adaptive' },\n  { value: 'masonry' as ViewMode, icon: LayoutGrid, i18nKey: 'gallery.toolbar.viewMode.masonry' },\n  { value: 'list' as ViewMode, icon: List, i18nKey: 'gallery.toolbar.viewMode.list' },\n]\n\n// 当前视图模式的图标\nconst currentViewModeIcon = computed(() => {\n  const mode = viewModes.find((m) => m.value === viewMode.value)\n  return mode?.icon || Grid3x3\n})\n\nwatch(colorPopoverOpen, (open) => {\n  if (open) {\n    draftColorHex.value = activeColorHex.value || '#FFFFFF'\n  }\n})\n\n// 方法\nfunction updateSearchQuery(query: string | number) {\n  galleryView.setSearchQuery(String(query))\n}\n\nfunction clearSearch() {\n  galleryView.setSearchQuery('')\n}\n\nfunction onTypeFilterChange(value: string | number | bigint | Record<string, any> | null) {\n  const stringValue = String(value || 'all')\n  const type = stringValue === 'all' ? undefined : (stringValue as AssetType)\n  galleryView.setTypeFilter(type)\n}\n\nfunction onSortByChange(value: string | number | bigint | Record<string, any> | null) {\n  if (value) {\n    const newSortBy = String(value) as SortBy\n    galleryView.setSorting(newSortBy, sortOrder.value)\n  }\n}\n\nfunction toggleSortOrder() {\n  galleryView.toggleSortOrder()\n}\n\nfunction toggleIncludeSubfolders() {\n  galleryView.setIncludeSubfolders(!includeSubfolders.value)\n}\n\nfunction applyColorFilter() {\n  galleryView.setColorFilter(draftColorHex.value)\n  colorPopoverOpen.value = false\n}\n\nfunction clearColorFilter() {\n  galleryView.setColorFilter(undefined)\n  draftColorHex.value = '#FFFFFF'\n  colorPopoverOpen.value = false\n}\n\nfunction setViewMode(\n  mode:\n    | string\n    | number\n    | bigint\n    | Record<string, any>\n    | null\n    | (string | number | bigint | Record<string, any> | null)[]\n) {\n  if (mode && typeof mode === 'string') {\n    galleryView.setViewMode(mode as ViewMode)\n  }\n}\n\nfunction onViewSizeSliderChange(value: number[] | undefined) {\n  if (value && value.length > 0 && value[0] !== undefined) {\n    // 使用非线性映射函数设置尺寸\n    galleryView.setViewSizeFromSlider(value[0])\n  }\n}\n</script>\n"
  },
  {
    "path": "web/src/features/gallery/components/shell/GalleryViewer.vue",
    "content": "<script setup lang=\"ts\">\nimport { ref, watch, onMounted, onUnmounted, computed } from 'vue'\nimport { useDebounceFn, useEventListener, usePreferredReducedMotion } from '@vueuse/core'\nimport {\n  useGalleryAssetActions,\n  useGalleryData,\n  useGallerySelection,\n  useGalleryView,\n} from '../../composables'\nimport { hasGalleryAssetDragIds } from '../../composables/useGalleryDragPayload'\nimport { useGalleryStore } from '../../store'\nimport {\n  computeLightboxHeroRect,\n  consumeHero,\n  endHeroAnimation,\n  consumeReverseHero,\n} from '../../composables/useHeroTransition'\nimport GalleryToolbar from './GalleryToolbar.vue'\nimport GalleryContent from './GalleryContent.vue'\nimport GalleryLightbox from '../lightbox/GalleryLightbox.vue'\n\nconst galleryData = useGalleryData()\nconst store = useGalleryStore()\nconst assetActions = useGalleryAssetActions()\nconst gallerySelection = useGallerySelection()\nconst galleryView = useGalleryView()\nconst viewerRef = ref<HTMLElement | null>(null)\nconst galleryContentRef = ref<InstanceType<typeof GalleryContent> | null>(null)\nconst contentRef = ref<HTMLElement | null>(null)\nconst reduceMotion = usePreferredReducedMotion()\nconst shouldReduceMotion = computed(() => reduceMotion.value === 'reduce')\nconst CONTENT_WHEEL_ZOOM_THRESHOLD = 96\n\nconst galleryColumnClass = computed(() => {\n  const hidden = store.lightbox.isOpen && !store.lightbox.isClosing\n  const transition = shouldReduceMotion.value ? '' : 'transition-opacity duration-[220ms] ease-out'\n  return [\n    'flex h-full flex-col',\n    transition,\n    hidden ? 'pointer-events-none opacity-0' : 'opacity-100',\n  ].filter(Boolean)\n})\n\n// Hero overlay 动画状态\ninterface HeroOverlayState {\n  thumbnailUrl: string\n  toRect: DOMRect\n}\n\nconst heroOverlay = ref<HeroOverlayState | null>(null)\nconst heroOverlayStyle = ref<Record<string, string>>({})\nconst heroActive = ref(false)\nlet heroRafId: number | null = null\n// lightbox 打开期间，gallery 背景只做低优先级“预对齐”；连续切图时只追最后一张。\nlet pendingGalleryScrollIndex: number | undefined\nlet galleryScrollRafId: number | null = null\nlet isViewerUnmounted = false\nlet wheelZoomDelta = 0\nlet isRestoringLightbox = false\n\nfunction clearLightboxRecoveryParams() {\n  const currentUrl = new URL(window.location.href)\n  currentUrl.searchParams.delete('lbAssetId')\n  currentUrl.searchParams.delete('lbFolderId')\n  currentUrl.searchParams.delete('lbRetry')\n  window.history.replaceState({}, '', currentUrl.toString())\n}\n\nasync function restoreLightboxFromQuery() {\n  const currentUrl = new URL(window.location.href)\n  const assetIdRaw = currentUrl.searchParams.get('lbAssetId')\n  const folderIdRaw = currentUrl.searchParams.get('lbFolderId')\n  if (!assetIdRaw) {\n    return\n  }\n  if (!folderIdRaw) {\n    clearLightboxRecoveryParams()\n    return\n  }\n\n  const assetId = Number(assetIdRaw)\n  if (!Number.isInteger(assetId) || assetId <= 0) {\n    clearLightboxRecoveryParams()\n    return\n  }\n\n  if (folderIdRaw === 'all') {\n    store.setFilter({ folderId: undefined })\n  } else {\n    const folderId = Number(folderIdRaw)\n    if (!Number.isInteger(folderId) || folderId <= 0) {\n      clearLightboxRecoveryParams()\n      return\n    }\n    store.setFilter({ folderId: String(folderId) })\n  }\n\n  try {\n    isRestoringLightbox = true\n    await galleryData.refreshCurrentQuery()\n    const allAssetIds = await galleryData.queryCurrentAssetIds()\n    const index = allAssetIds.findIndex((id) => id === assetId)\n    if (index < 0) {\n      clearLightboxRecoveryParams()\n      return\n    }\n\n    const selectedAsset = await gallerySelection.selectOnlyIndex(index)\n    if (!selectedAsset) {\n      return\n    }\n\n    store.openLightbox()\n    clearLightboxRecoveryParams()\n  } catch (error) {\n    console.warn('Failed to restore lightbox state:', error)\n  } finally {\n    isRestoringLightbox = false\n  }\n}\n\n// 反向 hero overlay 动画状态\nconst reverseHeroOverlay = ref<{ thumbnailUrl: string } | null>(null)\nconst reverseHeroOverlayStyle = ref<Record<string, string>>({})\nconst reverseHeroActive = ref(false)\nlet reverseHeroRafId: number | null = null\n\n// 吸收一小段时间内的连续 activeIndex 变化，并把背景滚动放到下一帧，避免与前景切图争抢同一拍。\nconst flushGalleryScrollSync = useDebounceFn(() => {\n  const targetIndex = pendingGalleryScrollIndex\n  if (isViewerUnmounted || !store.lightbox.isOpen || targetIndex === undefined) {\n    return\n  }\n\n  if (galleryScrollRafId !== null) {\n    cancelAnimationFrame(galleryScrollRafId)\n  }\n\n  galleryScrollRafId = requestAnimationFrame(() => {\n    galleryScrollRafId = null\n    if (isViewerUnmounted || !store.lightbox.isOpen || pendingGalleryScrollIndex !== targetIndex) {\n      return\n    }\n\n    galleryContentRef.value?.scrollToIndex(targetIndex)\n  })\n}, 120)\n\n// 背景 gallery 不做“逐次同步滚动”，而是 latest-wins 的预对齐。\n// 目标是让退出时 active 卡片大概率已在视口内，同时尽量不打扰 lightbox 前景交互。\nwatch(\n  () => store.selection.activeIndex,\n  (activeIndex) => {\n    if (store.lightbox.isOpen && activeIndex !== undefined) {\n      pendingGalleryScrollIndex = activeIndex\n      flushGalleryScrollSync()\n    }\n  }\n)\n\nwatch(\n  () => store.lightbox.isOpen,\n  async (isOpen) => {\n    if (!isOpen) {\n      pendingGalleryScrollIndex = undefined\n      if (galleryScrollRafId !== null) {\n        cancelAnimationFrame(galleryScrollRafId)\n        galleryScrollRafId = null\n      }\n      return\n    }\n\n    const hero = consumeHero()\n    if (!hero) {\n      return\n    }\n\n    const viewerEl = viewerRef.value\n    if (!viewerEl) return\n    const containerRect = viewerEl.getBoundingClientRect()\n    const toRect = computeLightboxHeroRect(\n      containerRect,\n      hero.width,\n      hero.height,\n      store.lightbox.showFilmstrip\n    )\n\n    heroOverlay.value = { thumbnailUrl: hero.thumbnailUrl, toRect }\n    heroOverlayStyle.value = rectToFixedStyle(hero.rect, 'none')\n    heroActive.value = false\n\n    // 双 rAF：先让 overlay 以初始样式挂载，再在下一拍切到目标 rect，确保浏览器稳定触发 transition。\n    heroRafId = requestAnimationFrame(() => {\n      heroRafId = requestAnimationFrame(() => {\n        heroActive.value = true\n        heroOverlayStyle.value = rectToFixedStyle(toRect, 'enter')\n      })\n    })\n  }\n)\n\nonMounted(async () => {\n  await restoreLightboxFromQuery()\n})\n\nonUnmounted(() => {\n  isViewerUnmounted = true\n  pendingGalleryScrollIndex = undefined\n  if (heroRafId !== null) cancelAnimationFrame(heroRafId)\n  if (reverseHeroRafId !== null) cancelAnimationFrame(reverseHeroRafId)\n  if (galleryScrollRafId !== null) cancelAnimationFrame(galleryScrollRafId)\n})\n\nconst resetWheelZoomDelta = useDebounceFn(() => {\n  wheelZoomDelta = 0\n}, 140)\n\nfunction rectToFixedStyle(\n  rect: DOMRect,\n  animation: 'none' | 'enter' | 'exit'\n): Record<string, string> {\n  // 进入更柔和，退出更利落；这里只过渡几何属性，避免 transition: all 带来不必要的副作用。\n  const transition =\n    animation === 'enter'\n      ? 'left 260ms cubic-bezier(0.22, 1, 0.36, 1), top 260ms cubic-bezier(0.22, 1, 0.36, 1), width 260ms cubic-bezier(0.22, 1, 0.36, 1), height 260ms cubic-bezier(0.22, 1, 0.36, 1)'\n      : animation === 'exit'\n        ? 'left 220ms cubic-bezier(0.4, 0, 0.2, 1), top 220ms cubic-bezier(0.4, 0, 0.2, 1), width 220ms cubic-bezier(0.4, 0, 0.2, 1), height 220ms cubic-bezier(0.4, 0, 0.2, 1)'\n        : 'none'\n\n  return {\n    position: 'fixed',\n    left: `${rect.left}px`,\n    top: `${rect.top}px`,\n    width: `${rect.width}px`,\n    height: `${rect.height}px`,\n    transition,\n    zIndex: '9999',\n    objectFit: 'cover',\n    borderRadius: '4px',\n    pointerEvents: 'none',\n  }\n}\n\nfunction onHeroTransitionEnd() {\n  heroOverlay.value = null\n  heroActive.value = false\n  endHeroAnimation()\n}\n\nfunction onReverseHeroTransitionEnd() {\n  reverseHeroOverlay.value = null\n  reverseHeroActive.value = false\n}\n\n// 由 GalleryLightbox 在关闭序列中触发反向 hero 飞回\nasync function startReverseHero() {\n  const rh = consumeReverseHero()\n  if (!rh) return\n\n  reverseHeroOverlay.value = { thumbnailUrl: rh.thumbnailUrl }\n  reverseHeroOverlayStyle.value = rectToFixedStyle(rh.fromRect, 'none')\n  reverseHeroActive.value = false\n\n  reverseHeroRafId = requestAnimationFrame(() => {\n    reverseHeroRafId = requestAnimationFrame(() => {\n      reverseHeroActive.value = true\n      reverseHeroOverlayStyle.value = rectToFixedStyle(rh.toRect, 'exit')\n    })\n  })\n}\n\ndefineExpose({ startReverseHero })\n\nfunction toggleSelectedAssetsRejected() {\n  const activeIndex = store.selection.activeIndex\n  const activeAsset =\n    activeIndex === undefined ? null : (store.getAssetsInRange(activeIndex, activeIndex)[0] ?? null)\n\n  if (activeAsset?.reviewFlag === 'rejected') {\n    void assetActions.clearSelectedAssetsRejected()\n    return\n  }\n\n  void assetActions.setSelectedAssetsRejected()\n}\n\nfunction isEditableTarget(target: EventTarget | null): boolean {\n  if (!(target instanceof HTMLElement)) {\n    return false\n  }\n\n  return target.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)\n}\n\nfunction handleKeydown(event: KeyboardEvent) {\n  if (store.lightbox.isOpen || isEditableTarget(event.target)) {\n    return\n  }\n\n  if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a') {\n    event.preventDefault()\n    void gallerySelection.selectAllCurrentQuery()\n    return\n  }\n\n  if (store.selection.selectedIds.size === 0) {\n    return\n  }\n\n  switch (event.key) {\n    case '0':\n      event.preventDefault()\n      void assetActions.clearSelectedAssetsRating()\n      return\n    case '1':\n    case '2':\n    case '3':\n    case '4':\n    case '5':\n      event.preventDefault()\n      void assetActions.setSelectedAssetsRating(Number(event.key))\n      return\n    case 'x':\n    case 'X':\n      event.preventDefault()\n      toggleSelectedAssetsRejected()\n      return\n  }\n}\n\nfunction handleContentWheel(event: WheelEvent) {\n  if (store.lightbox.isOpen || !event.ctrlKey || isEditableTarget(event.target)) {\n    return\n  }\n\n  event.preventDefault()\n\n  if (event.deltaY === 0) {\n    return\n  }\n\n  wheelZoomDelta += event.deltaY\n  resetWheelZoomDelta()\n\n  while (Math.abs(wheelZoomDelta) >= CONTENT_WHEEL_ZOOM_THRESHOLD) {\n    if (wheelZoomDelta > 0) {\n      galleryView.decreaseSize()\n      wheelZoomDelta -= CONTENT_WHEEL_ZOOM_THRESHOLD\n      continue\n    }\n\n    galleryView.increaseSize()\n    wheelZoomDelta += CONTENT_WHEEL_ZOOM_THRESHOLD\n  }\n}\n\nfunction handleViewerDragOver(event: DragEvent) {\n  if (!hasGalleryAssetDragIds(event)) {\n    return\n  }\n  // 让 viewer 区域在拖拽经过时保持“可移动”手势，避免系统显示禁止图标。\n  event.preventDefault()\n  if (event.dataTransfer) {\n    event.dataTransfer.dropEffect = 'move'\n  }\n}\n\nfunction handleViewerDrop(event: DragEvent) {\n  if (!hasGalleryAssetDragIds(event)) {\n    return\n  }\n  // viewer 本身不执行移动，仅消费默认 drop 行为以维持一致交互反馈。\n  event.preventDefault()\n}\n\n// 监听筛选条件和文件夹选项变化，自动重新加载资产\nwatch(\n  () => [store.filter, store.includeSubfolders, store.sortBy, store.sortOrder],\n  async () => {\n    if (isRestoringLightbox) {\n      return\n    }\n    console.log('🔄 筛选条件变化，重新加载数据')\n    await galleryData.refreshCurrentQuery()\n  },\n  { deep: true }\n)\n\nuseEventListener(window, 'keydown', handleKeydown)\nuseEventListener(contentRef, 'wheel', handleContentWheel, { passive: false })\n</script>\n\n<template>\n  <div\n    ref=\"viewerRef\"\n    class=\"relative h-full\"\n    @dragover=\"handleViewerDragOver\"\n    @drop=\"handleViewerDrop\"\n  >\n    <!-- gallery 始终渲染；打开时用 opacity 隐藏以便过渡，关闭阶段 isClosing 时与 lightbox 同步淡入 -->\n    <div\n      :class=\"galleryColumnClass\"\n      :aria-hidden=\"store.lightbox.isOpen && !store.lightbox.isClosing ? true : undefined\"\n    >\n      <GalleryToolbar />\n      <div ref=\"contentRef\" class=\"flex-1 overflow-hidden\">\n        <GalleryContent ref=\"galleryContentRef\" />\n      </div>\n    </div>\n\n    <!-- lightbox 按需挂载/销毁，绝对定位覆盖在 gallery 上层 -->\n    <GalleryLightbox\n      v-if=\"store.lightbox.isOpen\"\n      :gallery-content-ref=\"galleryContentRef\"\n      @request-reverse-hero=\"startReverseHero\"\n    />\n\n    <!-- Hero overlay: 缩略图放大到 lightbox 的动画层 -->\n    <Teleport to=\"body\">\n      <img\n        v-if=\"heroOverlay\"\n        :src=\"heroOverlay.thumbnailUrl\"\n        :style=\"heroOverlayStyle\"\n        alt=\"\"\n        @transitionend=\"onHeroTransitionEnd\"\n      />\n      <img\n        v-if=\"reverseHeroOverlay\"\n        :src=\"reverseHeroOverlay.thumbnailUrl\"\n        :style=\"reverseHeroOverlayStyle\"\n        alt=\"\"\n        @transitionend=\"onReverseHeroTransitionEnd\"\n      />\n    </Teleport>\n  </div>\n</template>\n"
  },
  {
    "path": "web/src/features/gallery/components/tags/ReviewFilterPopover.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed } from 'vue'\nimport { useI18n } from '@/composables/useI18n'\nimport type { ReviewFlag } from '../../types'\n\nconst props = defineProps<{\n  rating: number | undefined\n  reviewFlag: ReviewFlag | undefined\n}>()\n\nconst emit = defineEmits<{\n  'update:rating': [value: number | undefined]\n  'update:reviewFlag': [value: ReviewFlag | undefined]\n}>()\n\nconst { t } = useI18n()\n\nconst STARS = [1, 2, 3, 4, 5] as const\n\nconst activeRating = computed(() => props.rating)\nconst activeFlag = computed(() => props.reviewFlag)\n\nfunction onRatingClick(value: number | 'unrated') {\n  const numeric = value === 'unrated' ? 0 : value\n  emit('update:rating', activeRating.value === numeric ? undefined : numeric)\n}\n</script>\n\n<template>\n  <div class=\"space-y-3\">\n    <div class=\"space-y-1.5\">\n      <p class=\"text-xs font-medium\">{{ t('gallery.toolbar.filter.rating.label') }}</p>\n      <div class=\"flex flex-wrap gap-1\">\n        <button\n          type=\"button\"\n          class=\"rounded px-2 py-1 text-xs transition-colors\"\n          :class=\"\n            activeRating === undefined\n              ? 'bg-accent text-accent-foreground'\n              : 'bg-muted text-muted-foreground hover:text-foreground'\n          \"\n          @click=\"emit('update:rating', undefined)\"\n        >\n          {{ t('gallery.toolbar.filter.rating.all') }}\n        </button>\n\n        <button\n          v-for=\"star in STARS\"\n          :key=\"star\"\n          type=\"button\"\n          class=\"flex items-center rounded px-2 py-1 transition-colors\"\n          :class=\"\n            activeRating === star\n              ? 'bg-accent text-accent-foreground'\n              : 'bg-muted text-muted-foreground hover:text-foreground'\n          \"\n          @click=\"onRatingClick(star)\"\n        >\n          <svg\n            v-for=\"s in STARS\"\n            :key=\"s\"\n            class=\"h-3 w-3 transition-colors\"\n            :class=\"s <= star ? 'text-amber-400' : 'text-muted-foreground/30'\"\n            viewBox=\"0 0 24 24\"\n            fill=\"currentColor\"\n          >\n            <path\n              d=\"M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z\"\n            />\n          </svg>\n        </button>\n\n        <button\n          type=\"button\"\n          class=\"rounded px-2 py-1 text-xs transition-colors\"\n          :class=\"\n            activeRating === 0\n              ? 'bg-accent text-accent-foreground'\n              : 'bg-muted text-muted-foreground hover:text-foreground'\n          \"\n          @click=\"onRatingClick('unrated')\"\n        >\n          {{ t('gallery.toolbar.filter.rating.unrated') }}\n        </button>\n      </div>\n    </div>\n\n    <div class=\"border-t\" />\n\n    <div class=\"space-y-1.5\">\n      <p class=\"text-xs font-medium\">{{ t('gallery.toolbar.filter.flag.label') }}</p>\n      <div class=\"flex flex-wrap gap-1\">\n        <button\n          type=\"button\"\n          class=\"rounded-full px-3 py-1 text-xs font-medium transition-colors\"\n          :class=\"\n            activeFlag === undefined\n              ? 'bg-accent text-accent-foreground'\n              : 'bg-muted text-muted-foreground hover:text-foreground'\n          \"\n          @click=\"emit('update:reviewFlag', undefined)\"\n        >\n          {{ t('gallery.toolbar.filter.flag.all') }}\n        </button>\n        <button\n          type=\"button\"\n          class=\"rounded-full px-3 py-1 text-xs font-medium transition-colors\"\n          :class=\"\n            activeFlag === 'rejected'\n              ? 'bg-rose-500/20 text-rose-600 dark:text-rose-400'\n              : 'bg-muted text-muted-foreground hover:text-foreground'\n          \"\n          @click=\"emit('update:reviewFlag', 'rejected')\"\n        >\n          {{ t('gallery.review.flag.rejected') }}\n        </button>\n      </div>\n    </div>\n  </div>\n</template>\n"
  },
  {
    "path": "web/src/features/gallery/components/tags/TagInlineEditor.vue",
    "content": "<script setup lang=\"ts\">\nimport { ref, nextTick, onMounted } from 'vue'\n\ninterface Props {\n  initialValue?: string\n  placeholder?: string\n}\n\nconst props = withDefaults(defineProps<Props>(), {\n  initialValue: '',\n  placeholder: '输入标签名...',\n})\n\nconst emit = defineEmits<{\n  confirm: [value: string]\n  cancel: []\n}>()\n\nconst inputValue = ref(props.initialValue)\nconst inputRef = ref<HTMLInputElement>()\n\nonMounted(() => {\n  nextTick(() => {\n    inputRef.value?.focus()\n    // 如果有初始值，选中所有文本\n    if (props.initialValue) {\n      inputRef.value?.select()\n    }\n  })\n})\n\nfunction handleConfirm() {\n  const trimmedValue = inputValue.value.trim()\n  if (trimmedValue) {\n    emit('confirm', trimmedValue)\n  } else {\n    emit('cancel')\n  }\n}\n\nfunction handleCancel() {\n  emit('cancel')\n}\n\nfunction handleBlur() {\n  // 延迟执行，避免与点击事件冲突\n  setTimeout(() => {\n    handleConfirm()\n  }, 100)\n}\n\nfunction handleKeydown(event: KeyboardEvent) {\n  if (event.key === 'Enter') {\n    event.preventDefault()\n    handleConfirm()\n  } else if (event.key === 'Escape') {\n    event.preventDefault()\n    handleCancel()\n  }\n}\n</script>\n\n<template>\n  <div class=\"flex items-center\">\n    <input\n      ref=\"inputRef\"\n      v-model=\"inputValue\"\n      type=\"text\"\n      :placeholder=\"placeholder\"\n      class=\"h-8 w-full rounded border border-input bg-background px-2 text-sm focus:ring-2 focus:ring-ring focus:outline-none\"\n      @blur=\"handleBlur\"\n      @keydown=\"handleKeydown\"\n    />\n  </div>\n</template>\n"
  },
  {
    "path": "web/src/features/gallery/components/tags/TagSelectorPopover.vue",
    "content": "<script setup lang=\"ts\">\nimport { ref, computed } from 'vue'\nimport { Button } from '@/components/ui/button'\nimport type { TagTreeNode } from '../../types'\n\ninterface Props {\n  tags: TagTreeNode[]\n  selectedTagIds?: number[]\n}\n\nconst props = withDefaults(defineProps<Props>(), {\n  selectedTagIds: () => [],\n})\n\nconst emit = defineEmits<{\n  toggle: [tagId: number]\n}>()\n\n// 标签展开状态\nconst expandedTagIds = ref<Set<number>>(new Set())\n\n// 当前选中的标签 ID 集合\nconst selectedIds = computed(() => new Set(props.selectedTagIds))\n\n// 切换展开状态\nfunction toggleExpand(tagId: number) {\n  if (expandedTagIds.value.has(tagId)) {\n    expandedTagIds.value.delete(tagId)\n  } else {\n    expandedTagIds.value.add(tagId)\n  }\n}\n\n// 切换标签\nfunction handleToggleTag(tagId: number) {\n  emit('toggle', tagId)\n}\n\n// 递归渲染标签树项\nfunction renderTagItem(tag: TagTreeNode, depth = 0) {\n  return {\n    tag,\n    depth,\n    hasChildren: tag.children && tag.children.length > 0,\n    isExpanded: expandedTagIds.value.has(tag.id),\n    isSelected: selectedIds.value.has(tag.id),\n  }\n}\n</script>\n\n<template>\n  <div class=\"max-h-96 w-64 overflow-y-auto p-2\">\n    <!-- 标题 -->\n    <div class=\"mb-2 px-2 text-xs font-medium text-muted-foreground\">选择标签</div>\n\n    <!-- 标签列表 -->\n    <div v-if=\"tags.length > 0\" class=\"space-y-0.5\">\n      <template v-for=\"tag in tags\" :key=\"tag.id\">\n        <!-- 标签项 -->\n        <div>\n          <Button\n            variant=\"ghost\"\n            size=\"sm\"\n            :class=\"[\n              'h-8 w-full justify-start gap-2 px-2 text-sm',\n              renderTagItem(tag).isSelected && 'bg-accent',\n            ]\"\n            @click=\"handleToggleTag(tag.id)\"\n          >\n            <!-- 展开箭头 -->\n            <span\n              v-if=\"renderTagItem(tag).hasChildren\"\n              class=\"flex h-4 w-4 flex-shrink-0 items-center justify-center\"\n              @click.stop=\"toggleExpand(tag.id)\"\n            >\n              <svg\n                xmlns=\"http://www.w3.org/2000/svg\"\n                width=\"12\"\n                height=\"12\"\n                viewBox=\"0 0 24 24\"\n                fill=\"none\"\n                stroke=\"currentColor\"\n                stroke-width=\"2\"\n                stroke-linecap=\"round\"\n                stroke-linejoin=\"round\"\n                class=\"transition-transform\"\n                :class=\"{ 'rotate-90': renderTagItem(tag).isExpanded }\"\n              >\n                <path d=\"m9 18 6-6-6-6\" />\n              </svg>\n            </span>\n            <span v-else class=\"w-4 flex-shrink-0\" />\n\n            <!-- 标签图标 -->\n            <svg\n              xmlns=\"http://www.w3.org/2000/svg\"\n              width=\"14\"\n              height=\"14\"\n              viewBox=\"0 0 24 24\"\n              fill=\"none\"\n              stroke=\"currentColor\"\n              stroke-width=\"2\"\n              stroke-linecap=\"round\"\n              stroke-linejoin=\"round\"\n              class=\"flex-shrink-0\"\n            >\n              <path\n                d=\"M12 2H2v10l9.29 9.29c.94.94 2.48.94 3.42 0l6.58-6.58c.94-.94.94-2.48 0-3.42L12 2Z\"\n              />\n              <path d=\"M7 7h.01\" />\n            </svg>\n\n            <!-- 标签名称 -->\n            <span class=\"flex-1 truncate text-left\">{{ tag.name }}</span>\n\n            <!-- 选中标记 -->\n            <svg\n              v-if=\"renderTagItem(tag).isSelected\"\n              xmlns=\"http://www.w3.org/2000/svg\"\n              width=\"14\"\n              height=\"14\"\n              viewBox=\"0 0 24 24\"\n              fill=\"none\"\n              stroke=\"currentColor\"\n              stroke-width=\"2\"\n              stroke-linecap=\"round\"\n              stroke-linejoin=\"round\"\n              class=\"flex-shrink-0 text-primary\"\n            >\n              <path d=\"M20 6 9 17l-5-5\" />\n            </svg>\n          </Button>\n\n          <!-- 递归渲染子标签 -->\n          <div\n            v-if=\"renderTagItem(tag).hasChildren && renderTagItem(tag).isExpanded\"\n            class=\"ml-4 space-y-0.5\"\n          >\n            <template v-for=\"child in tag.children\" :key=\"child.id\">\n              <div>\n                <Button\n                  variant=\"ghost\"\n                  size=\"sm\"\n                  :class=\"[\n                    'h-8 w-full justify-start gap-2 px-2 text-sm',\n                    selectedIds.has(child.id) && 'bg-accent',\n                  ]\"\n                  @click=\"handleToggleTag(child.id)\"\n                >\n                  <!-- 展开箭头（子标签） -->\n                  <span\n                    v-if=\"child.children && child.children.length > 0\"\n                    class=\"flex h-4 w-4 flex-shrink-0 items-center justify-center\"\n                    @click.stop=\"toggleExpand(child.id)\"\n                  >\n                    <svg\n                      xmlns=\"http://www.w3.org/2000/svg\"\n                      width=\"12\"\n                      height=\"12\"\n                      viewBox=\"0 0 24 24\"\n                      fill=\"none\"\n                      stroke=\"currentColor\"\n                      stroke-width=\"2\"\n                      stroke-linecap=\"round\"\n                      stroke-linejoin=\"round\"\n                      class=\"transition-transform\"\n                      :class=\"{ 'rotate-90': expandedTagIds.has(child.id) }\"\n                    >\n                      <path d=\"m9 18 6-6-6-6\" />\n                    </svg>\n                  </span>\n                  <span v-else class=\"w-4 flex-shrink-0\" />\n\n                  <!-- 标签图标 -->\n                  <svg\n                    xmlns=\"http://www.w3.org/2000/svg\"\n                    width=\"14\"\n                    height=\"14\"\n                    viewBox=\"0 0 24 24\"\n                    fill=\"none\"\n                    stroke=\"currentColor\"\n                    stroke-width=\"2\"\n                    stroke-linecap=\"round\"\n                    stroke-linejoin=\"round\"\n                    class=\"flex-shrink-0\"\n                  >\n                    <path\n                      d=\"M12 2H2v10l9.29 9.29c.94.94 2.48.94 3.42 0l6.58-6.58c.94-.94.94-2.48 0-3.42L12 2Z\"\n                    />\n                    <path d=\"M7 7h.01\" />\n                  </svg>\n\n                  <!-- 标签名称 -->\n                  <span class=\"flex-1 truncate text-left\">{{ child.name }}</span>\n\n                  <!-- 选中标记 -->\n                  <svg\n                    v-if=\"selectedIds.has(child.id)\"\n                    xmlns=\"http://www.w3.org/2000/svg\"\n                    width=\"14\"\n                    height=\"14\"\n                    viewBox=\"0 0 24 24\"\n                    fill=\"none\"\n                    stroke=\"currentColor\"\n                    stroke-width=\"2\"\n                    stroke-linecap=\"round\"\n                    stroke-linejoin=\"round\"\n                    class=\"flex-shrink-0 text-primary\"\n                  >\n                    <path d=\"M20 6 9 17l-5-5\" />\n                  </svg>\n                </Button>\n\n                <!-- 更深层级的递归（支持三级及以上） -->\n                <div\n                  v-if=\"child.children && child.children.length > 0 && expandedTagIds.has(child.id)\"\n                  class=\"ml-4 space-y-0.5\"\n                >\n                  <Button\n                    v-for=\"grandChild in child.children\"\n                    :key=\"grandChild.id\"\n                    variant=\"ghost\"\n                    size=\"sm\"\n                    :class=\"[\n                      'h-8 w-full justify-start gap-2 px-2 text-sm',\n                      selectedIds.has(grandChild.id) && 'bg-accent',\n                    ]\"\n                    @click=\"handleToggleTag(grandChild.id)\"\n                  >\n                    <span class=\"w-4 flex-shrink-0\" />\n                    <svg\n                      xmlns=\"http://www.w3.org/2000/svg\"\n                      width=\"14\"\n                      height=\"14\"\n                      viewBox=\"0 0 24 24\"\n                      fill=\"none\"\n                      stroke=\"currentColor\"\n                      stroke-width=\"2\"\n                      stroke-linecap=\"round\"\n                      stroke-linejoin=\"round\"\n                      class=\"flex-shrink-0\"\n                    >\n                      <path\n                        d=\"M12 2H2v10l9.29 9.29c.94.94 2.48.94 3.42 0l6.58-6.58c.94-.94.94-2.48 0-3.42L12 2Z\"\n                      />\n                      <path d=\"M7 7h.01\" />\n                    </svg>\n                    <span class=\"flex-1 truncate text-left\">{{ grandChild.name }}</span>\n                    <svg\n                      v-if=\"selectedIds.has(grandChild.id)\"\n                      xmlns=\"http://www.w3.org/2000/svg\"\n                      width=\"14\"\n                      height=\"14\"\n                      viewBox=\"0 0 24 24\"\n                      fill=\"none\"\n                      stroke=\"currentColor\"\n                      stroke-width=\"2\"\n                      stroke-linecap=\"round\"\n                      stroke-linejoin=\"round\"\n                      class=\"flex-shrink-0 text-primary\"\n                    >\n                      <path d=\"M20 6 9 17l-5-5\" />\n                    </svg>\n                  </Button>\n                </div>\n              </div>\n            </template>\n          </div>\n        </div>\n      </template>\n    </div>\n\n    <!-- 空状态 -->\n    <div v-else class=\"py-8 text-center text-sm text-muted-foreground\">暂无标签</div>\n  </div>\n</template>\n"
  },
  {
    "path": "web/src/features/gallery/components/tags/TagTreeItem.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed, ref } from 'vue'\nimport { cn } from '@/lib/utils'\nimport {\n  ContextMenu,\n  ContextMenuContent,\n  ContextMenuItem,\n  ContextMenuSeparator,\n  ContextMenuTrigger,\n} from '@/components/ui/context-menu'\nimport {\n  AlertDialog,\n  AlertDialogAction,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n} from '@/components/ui/alert-dialog'\nimport TagInlineEditor from './TagInlineEditor.vue'\nimport { useGalleryStore } from '../../store'\nimport type { TagTreeNode } from '../../types'\nimport {\n  hasGalleryAssetDragIds,\n  readGalleryAssetDragIds,\n} from '../../composables/useGalleryDragPayload'\n\ninterface Props {\n  tag: TagTreeNode\n  selectedTag: number | null\n  depth?: number\n}\n\nconst props = withDefaults(defineProps<Props>(), {\n  depth: 0,\n})\n\nconst emit = defineEmits<{\n  select: [tagId: number, tagName: string]\n  rename: [tagId: number, newName: string]\n  createChild: [parentId: number, name: string]\n  delete: [tagId: number]\n  dropAssetsToTag: [tagId: number, assetIds: number[]]\n}>()\n\nconst galleryStore = useGalleryStore()\n// 与文件夹树保持一致：展开状态统一走 store，而不是递归组件各自记一份局部状态。\nconst isExpanded = computed(() => galleryStore.isTagExpanded(props.tag.id))\n\n// 编辑状态\nconst isEditing = ref(false)\nconst isCreatingChild = ref(false)\n\n// 删除确认对话框状态\nconst showDeleteDialog = ref(false)\nconst isDragOver = ref(false)\n\n// 控制是否阻止 ContextMenu 的 closeAutoFocus\nconst shouldPreventAutoFocus = ref(false)\n\n// 切换展开状态（独立点击箭头）\nfunction toggleExpand() {\n  galleryStore.toggleTagExpanded(props.tag.id)\n}\n\n// 处理 item 点击\nfunction handleItemClick() {\n  if (isEditing.value) return\n\n  // 移除了选中状态下点击展开子标签的逻辑\n  // 现在只会选中标签，不会自动展开\n  emit('select', props.tag.id, props.tag.name)\n}\n\n// 双击重命名\nfunction handleDoubleClick() {\n  // isEditing.value = true\n}\n\n// 确认重命名\nfunction handleRenameConfirm(newName: string) {\n  emit('rename', props.tag.id, newName)\n  isEditing.value = false\n}\n\n// 取消重命名\nfunction handleRenameCancel() {\n  isEditing.value = false\n}\n\n// 开始创建子标签\nfunction startCreateChild() {\n  galleryStore.setTagExpanded(props.tag.id, true)\n  isCreatingChild.value = true\n  shouldPreventAutoFocus.value = true // 阻止 ContextMenu 关闭时的自动聚焦\n}\n\n// 确认创建子标签\nfunction handleCreateChildConfirm(name: string) {\n  emit('createChild', props.tag.id, name)\n  isCreatingChild.value = false\n}\n\n// 取消创建子标签\nfunction handleCreateChildCancel() {\n  isCreatingChild.value = false\n}\n\n// 处理 ContextMenu 关闭时的自动聚焦\nfunction handleContextMenuCloseAutoFocus(event: Event) {\n  if (shouldPreventAutoFocus.value) {\n    event.preventDefault() // 阻止自动聚焦，让输入框保持焦点\n    shouldPreventAutoFocus.value = false // 重置标志\n  }\n}\n\n// 右键菜单操作\nfunction startRename() {\n  isEditing.value = true\n  shouldPreventAutoFocus.value = true // 阻止 ContextMenu 关闭时的自动聚焦\n}\n\nfunction requestDelete() {\n  showDeleteDialog.value = true\n}\n\nfunction confirmDelete() {\n  emit('delete', props.tag.id)\n  showDeleteDialog.value = false\n}\n\nfunction handleDragEnter(event: DragEvent) {\n  if (!hasGalleryAssetDragIds(event)) {\n    return\n  }\n  event.preventDefault()\n  isDragOver.value = true\n}\n\nfunction handleDragOver(event: DragEvent) {\n  if (!hasGalleryAssetDragIds(event)) {\n    return\n  }\n  event.preventDefault()\n  if (event.dataTransfer) {\n    event.dataTransfer.dropEffect = 'move'\n  }\n  isDragOver.value = true\n}\n\nfunction handleDragLeave() {\n  isDragOver.value = false\n}\n\nfunction handleDrop(event: DragEvent) {\n  event.preventDefault()\n  isDragOver.value = false\n  const assetIds = readGalleryAssetDragIds(event)\n  if (assetIds.length === 0) {\n    return\n  }\n  emit('dropAssetsToTag', props.tag.id, assetIds)\n}\n</script>\n\n<template>\n  <div>\n    <!-- 删除确认对话框 -->\n    <AlertDialog v-model:open=\"showDeleteDialog\">\n      <AlertDialogContent>\n        <AlertDialogHeader>\n          <AlertDialogTitle>确认删除标签？</AlertDialogTitle>\n          <AlertDialogDescription>\n            将删除标签「{{ tag.name }}」及其所有关联。此操作不可恢复。\n          </AlertDialogDescription>\n        </AlertDialogHeader>\n        <AlertDialogFooter>\n          <AlertDialogCancel>取消</AlertDialogCancel>\n          <AlertDialogAction @click=\"confirmDelete\">确认删除</AlertDialogAction>\n        </AlertDialogFooter>\n      </AlertDialogContent>\n    </AlertDialog>\n\n    <!-- 标签 item -->\n    <div v-if=\"isEditing\" class=\"px-2\" :style=\"{ paddingLeft: `${depth * 12}px` }\">\n      <TagInlineEditor\n        :initial-value=\"tag.name\"\n        placeholder=\"输入标签名...\"\n        @confirm=\"handleRenameConfirm\"\n        @cancel=\"handleRenameCancel\"\n      />\n    </div>\n    <!-- 右键菜单 -->\n    <ContextMenu v-else>\n      <ContextMenuTrigger as-child>\n        <button\n          type=\"button\"\n          :class=\"\n            cn(\n              'group relative flex h-8 w-full cursor-pointer items-center justify-between rounded-md border-0 bg-transparent px-0 text-left text-sm transition-colors duration-200 ease-out outline-none',\n              'focus-visible:ring-2 focus-visible:ring-sidebar-ring focus-visible:ring-offset-2',\n              isDragOver ? 'bg-primary/12 text-primary' : '',\n              selectedTag === tag.id\n                ? 'bg-sidebar-accent font-medium text-primary hover:text-primary [&_svg]:text-primary'\n                : 'text-sidebar-foreground hover:bg-sidebar-hover hover:text-sidebar-accent-foreground'\n            )\n          \"\n          :style=\"{ paddingLeft: `${depth * 12 + 8}px` }\"\n          @click=\"handleItemClick\"\n          @dblclick=\"handleDoubleClick\"\n          @dragenter=\"handleDragEnter\"\n          @dragover=\"handleDragOver\"\n          @dragleave=\"handleDragLeave\"\n          @drop=\"handleDrop\"\n        >\n          <!-- 左侧：图标 + 名称 -->\n          <div class=\"flex min-w-0 items-center gap-2\">\n            <!-- 标签图标 -->\n            <svg\n              xmlns=\"http://www.w3.org/2000/svg\"\n              width=\"14\"\n              height=\"14\"\n              viewBox=\"0 0 24 24\"\n              fill=\"none\"\n              stroke=\"currentColor\"\n              stroke-width=\"2\"\n              stroke-linecap=\"round\"\n              stroke-linejoin=\"round\"\n              class=\"flex-shrink-0\"\n            >\n              <path\n                d=\"M12 2H2v10l9.29 9.29c.94.94 2.48.94 3.42 0l6.58-6.58c.94-.94.94-2.48 0-3.42L12 2Z\"\n              />\n              <path d=\"M7 7h.01\" />\n            </svg>\n\n            <!-- 标签名称 -->\n            <span class=\"truncate text-sm\">\n              {{ tag.name }}\n            </span>\n          </div>\n\n          <!-- 右侧：展开箭头 -->\n          <div\n            class=\"flex flex-shrink-0 items-center gap-2\"\n            v-if=\"tag.children && tag.children.length > 0\"\n          >\n            <!-- 展开/收起箭头 -->\n            <span\n              class=\"-mr-1.5 flex-shrink-0 rounded-md p-1.5 hover:bg-sidebar-hover\"\n              @click.stop=\"toggleExpand\"\n            >\n              <svg\n                xmlns=\"http://www.w3.org/2000/svg\"\n                width=\"12\"\n                height=\"12\"\n                viewBox=\"0 0 24 24\"\n                fill=\"none\"\n                stroke=\"currentColor\"\n                stroke-width=\"2\"\n                stroke-linecap=\"round\"\n                stroke-linejoin=\"round\"\n                class=\"transition-transform\"\n                :class=\"{ 'rotate-90': isExpanded }\"\n              >\n                <path d=\"m9 18 6-6-6-6\" />\n              </svg>\n            </span>\n          </div>\n        </button>\n      </ContextMenuTrigger>\n\n      <ContextMenuContent @close-auto-focus=\"handleContextMenuCloseAutoFocus\">\n        <ContextMenuItem @click=\"startRename\">\n          <svg\n            xmlns=\"http://www.w3.org/2000/svg\"\n            width=\"14\"\n            height=\"14\"\n            viewBox=\"0 0 24 24\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            stroke-width=\"2\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\"\n            class=\"mr-2\"\n          >\n            <path d=\"M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z\" />\n            <path d=\"m15 5 4 4\" />\n          </svg>\n          重命名\n        </ContextMenuItem>\n        <ContextMenuItem @click=\"startCreateChild\">\n          <svg\n            xmlns=\"http://www.w3.org/2000/svg\"\n            width=\"14\"\n            height=\"14\"\n            viewBox=\"0 0 24 24\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            stroke-width=\"2\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\"\n            class=\"mr-2\"\n          >\n            <path d=\"M5 12h14\" />\n            <path d=\"M12 5v14\" />\n          </svg>\n          添加子标签\n        </ContextMenuItem>\n        <ContextMenuSeparator />\n        <ContextMenuItem @click=\"requestDelete\" class=\"text-destructive focus:text-destructive\">\n          <svg\n            xmlns=\"http://www.w3.org/2000/svg\"\n            width=\"14\"\n            height=\"14\"\n            viewBox=\"0 0 24 24\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            stroke-width=\"2\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\"\n            class=\"mr-2\"\n          >\n            <path d=\"M3 6h18\" />\n            <path d=\"M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6\" />\n            <path d=\"M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2\" />\n          </svg>\n          删除\n        </ContextMenuItem>\n      </ContextMenuContent>\n    </ContextMenu>\n\n    <!-- 递归渲染子标签 -->\n    <div v-if=\"isExpanded\" class=\"space-y-1\">\n      <!-- 创建子标签 -->\n      <div v-if=\"isCreatingChild\" class=\"px-2\" :style=\"{ paddingLeft: `${(depth + 1) * 12}px` }\">\n        <TagInlineEditor\n          placeholder=\"输入子标签名...\"\n          @confirm=\"handleCreateChildConfirm\"\n          @cancel=\"handleCreateChildCancel\"\n        />\n      </div>\n      <!-- 子标签列表 -->\n      <TagTreeItem\n        v-for=\"child in tag.children\"\n        :key=\"child.id\"\n        :tag=\"child\"\n        :selected-tag=\"selectedTag\"\n        :depth=\"depth + 1\"\n        @select=\"(tagId, tagName) => emit('select', tagId, tagName)\"\n        @rename=\"(tagId, newName) => emit('rename', tagId, newName)\"\n        @create-child=\"(parentId, name) => emit('createChild', parentId, name)\"\n        @delete=\"(tagId) => emit('delete', tagId)\"\n        @drop-assets-to-tag=\"(tagId, assetIds) => emit('dropAssetsToTag', tagId, assetIds)\"\n      />\n    </div>\n  </div>\n</template>\n"
  },
  {
    "path": "web/src/features/gallery/components/viewer/AdaptiveView.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed, onMounted, ref } from 'vue'\nimport { useElementSize } from '@vueuse/core'\nimport type { Asset } from '../../types'\nimport {\n  useAdaptiveVirtualizer,\n  useGalleryContextMenu,\n  useGallerySelection,\n  useGalleryLightbox,\n  useTimelineRail,\n} from '../../composables'\nimport { prepareHero } from '../../composables/useHeroTransition'\nimport { galleryApi } from '../../api'\nimport { useGalleryDragPayload } from '../../composables/useGalleryDragPayload'\nimport { useGalleryStore } from '../../store'\nimport { useI18n } from '@/composables/useI18n'\nimport AssetCard from '../asset/AssetCard.vue'\nimport GalleryScrollbarRail from '../shell/GalleryScrollbarRail.vue'\n\nconst store = useGalleryStore()\nconst gallerySelection = useGallerySelection()\nconst galleryLightbox = useGalleryLightbox()\nconst galleryContextMenu = useGalleryContextMenu()\nconst { prepareAssetDrag } = useGalleryDragPayload()\nconst { locale } = useI18n()\n\nconst scrollContainerRef = ref<HTMLElement | null>(null)\nconst scrollTop = ref(0)\n\n// AdaptiveView 不再依赖 ScrollArea，避免第三方滚动容器内部测量语义干扰 thumb 尺寸。\nconst { width: containerWidth, height: containerHeight } = useElementSize(scrollContainerRef)\n\nconst adaptiveVirtualizer = useAdaptiveVirtualizer({\n  containerRef: scrollContainerRef,\n  containerWidth,\n})\n\nconst { markers: railMarkers, labels: railLabels } = useTimelineRail({\n  isTimelineMode: computed(() => store.isTimelineMode),\n  buckets: computed(() => store.timelineBuckets),\n  locale,\n  getOffsetByAssetIndex(assetIndex) {\n    const rowIndex = adaptiveVirtualizer.rowIndexByAssetIndex.value.get(assetIndex)\n    if (rowIndex === undefined) {\n      return undefined\n    }\n\n    return adaptiveVirtualizer.rows.value[rowIndex]?.start\n  },\n})\n\nonMounted(async () => {\n  await adaptiveVirtualizer.init()\n})\n\nfunction handleScroll(event: Event) {\n  // 轨道指示器与 hover 映射都依赖真实 scrollTop，因此这里直接从原生容器同步。\n  const target = event.target as HTMLElement\n  scrollTop.value = target.scrollTop\n}\n\nfunction handleAssetClick(asset: Asset, event: MouseEvent, index: number) {\n  void gallerySelection.handleAssetClick(asset, event, index)\n}\n\nfunction handleAssetDoubleClick(asset: Asset, event: MouseEvent, index: number) {\n  const cardEl = (event.target as HTMLElement).closest('[data-asset-card]')\n  if (cardEl) {\n    const rect = cardEl.getBoundingClientRect()\n    const thumbnailUrl = galleryApi.getAssetThumbnailUrl(asset)\n    prepareHero(rect, thumbnailUrl, asset.width ?? 1, asset.height ?? 1)\n  }\n\n  gallerySelection.handleAssetDoubleClick(asset, event)\n  void galleryLightbox.openLightbox(index)\n}\n\nasync function handleAssetContextMenu(asset: Asset, event: MouseEvent, index: number) {\n  await gallerySelection.handleAssetContextMenu(asset, event, index)\n  galleryContextMenu.openForAsset({ asset, event, index, sourceView: 'adaptive' })\n}\n\nfunction handleAssetDragStart(asset: Asset, event: DragEvent) {\n  prepareAssetDrag(event, asset.id)\n}\n\nfunction scrollToIndex(index: number) {\n  adaptiveVirtualizer.scrollToIndex(index)\n}\n\nfunction getCardRect(index: number): DOMRect | null {\n  const container = scrollContainerRef.value\n  if (!container) {\n    return null\n  }\n\n  // 统一通过 data-index 找到当前已渲染卡片，供灯箱 hero / reverse-hero 动画复用。\n  const card = container.querySelector(\n    `[data-index=\"${index}\"] [data-asset-card]`\n  ) as HTMLElement | null\n\n  return card?.getBoundingClientRect() ?? null\n}\n\ndefineExpose({ scrollToIndex, getCardRect })\n</script>\n\n<template>\n  <div class=\"flex h-full\">\n    <div\n      ref=\"scrollContainerRef\"\n      class=\"hide-scrollbar flex-1 overflow-auto py-2 pr-2 pl-6\"\n      @scroll=\"handleScroll\"\n    >\n      <div class=\"pb-3\">\n        <div\n          :style=\"{\n            height: `${adaptiveVirtualizer.virtualizer.value.getTotalSize()}px`,\n            position: 'relative',\n          }\"\n        >\n          <div\n            v-for=\"virtualRow in adaptiveVirtualizer.virtualRows.value\"\n            :key=\"virtualRow.index\"\n            :style=\"{\n              position: 'absolute',\n              top: 0,\n              left: 0,\n              width: '100%',\n              height: `${virtualRow.size}px`,\n              transform: `translateY(${virtualRow.start}px)`,\n              display: 'flex',\n              gap: `${adaptiveVirtualizer.gap}px`,\n            }\"\n          >\n            <template v-for=\"item in virtualRow.items\" :key=\"item.id\">\n              <div\n                :data-index=\"item.index\"\n                class=\"shrink-0\"\n                :style=\"{ width: `${item.width}px`, height: `${item.height}px` }\"\n              >\n                <AssetCard\n                  v-if=\"item.asset !== null\"\n                  :asset=\"item.asset\"\n                  :aspect-ratio=\"`${item.width} / ${item.height}`\"\n                  :is-selected=\"gallerySelection.isAssetSelected(item.asset.id)\"\n                  @click=\"(asset, event) => handleAssetClick(asset, event, item.index)\"\n                  @double-click=\"(asset, event) => handleAssetDoubleClick(asset, event, item.index)\"\n                  @context-menu=\"\n                    (asset, event) => void handleAssetContextMenu(asset, event, item.index)\n                  \"\n                  @drag-start=\"(asset, event) => handleAssetDragStart(asset, event)\"\n                />\n\n                <div v-else class=\"h-full w-full animate-pulse rounded-lg bg-muted\" />\n              </div>\n            </template>\n          </div>\n        </div>\n      </div>\n    </div>\n\n    <GalleryScrollbarRail\n      :container-height=\"containerHeight\"\n      :scroll-top=\"scrollTop\"\n      :viewport-height=\"containerHeight\"\n      :virtualizer=\"adaptiveVirtualizer.virtualizer.value\"\n      :markers=\"railMarkers\"\n      :labels=\"railLabels\"\n    />\n  </div>\n</template>\n\n<style scoped>\n.hide-scrollbar::-webkit-scrollbar {\n  display: none;\n}\n\n.hide-scrollbar {\n  scrollbar-width: none;\n}\n</style>\n"
  },
  {
    "path": "web/src/features/gallery/components/viewer/GridTimelineRailBridge.vue",
    "content": "<script setup lang=\"ts\">\nimport { ref, watch, onUnmounted } from 'vue'\nimport type {\n  GalleryScrollbarLabel,\n  GalleryScrollbarMarker,\n} from '../shell/GalleryScrollbarRail.vue'\nimport GalleryScrollbarRail from '../shell/GalleryScrollbarRail.vue'\n\ninterface VirtualizerLike {\n  getTotalSize: () => number\n  scrollToOffset: (offset: number, options?: { behavior?: 'auto' | 'smooth' }) => void\n}\n\nconst props = defineProps<{\n  scrollContainer: HTMLElement | null\n  containerHeight: number\n  virtualizer: VirtualizerLike\n  markers: GalleryScrollbarMarker[]\n  labels: GalleryScrollbarLabel[]\n}>()\n\nconst scrollTop = ref(0)\nconst viewportHeight = ref(0)\nlet cleanup: (() => void) | null = null\nlet pendingFrameId: number | null = null\nlet pendingContainer: HTMLElement | null = null\n\nfunction syncFromContainer(container: HTMLElement) {\n  scrollTop.value = container.scrollTop\n  viewportHeight.value = container.clientHeight\n}\n\nfunction flushScrollSync() {\n  pendingFrameId = null\n  if (!pendingContainer) {\n    return\n  }\n\n  syncFromContainer(pendingContainer)\n}\n\nfunction scheduleScrollSync(container: HTMLElement) {\n  pendingContainer = container\n  if (pendingFrameId !== null) {\n    return\n  }\n\n  pendingFrameId = requestAnimationFrame(flushScrollSync)\n}\n\nfunction detach() {\n  pendingContainer = null\n  if (pendingFrameId !== null) {\n    cancelAnimationFrame(pendingFrameId)\n    pendingFrameId = null\n  }\n\n  if (cleanup) {\n    cleanup()\n    cleanup = null\n  }\n}\n\nfunction attach(container: HTMLElement) {\n  const onScroll = () => {\n    scheduleScrollSync(container)\n  }\n\n  syncFromContainer(container)\n  container.addEventListener('scroll', onScroll, { passive: true })\n  cleanup = () => {\n    container.removeEventListener('scroll', onScroll)\n  }\n}\n\nwatch(\n  () => props.scrollContainer,\n  (container) => {\n    detach()\n    if (container) {\n      attach(container)\n    } else {\n      scrollTop.value = 0\n      viewportHeight.value = 0\n    }\n  },\n  { immediate: true }\n)\n\nonUnmounted(() => {\n  detach()\n})\n</script>\n\n<template>\n  <GalleryScrollbarRail\n    :container-height=\"containerHeight\"\n    :scroll-top=\"scrollTop\"\n    :viewport-height=\"viewportHeight\"\n    :virtualizer=\"virtualizer\"\n    :markers=\"markers\"\n    :labels=\"labels\"\n  />\n</template>\n"
  },
  {
    "path": "web/src/features/gallery/components/viewer/GridView.vue",
    "content": "<script setup lang=\"ts\">\nimport { ref, computed, onMounted, watch } from 'vue'\nimport { useElementSize } from '@vueuse/core'\nimport { useGalleryStore } from '../../store'\nimport type { Asset } from '../../types'\nimport {\n  useGalleryView,\n  useGallerySelection,\n  useGalleryLightbox,\n  useGalleryContextMenu,\n  useGridVirtualizer,\n  useTimelineRail,\n} from '../../composables'\nimport { prepareHero } from '../../composables/useHeroTransition'\nimport { galleryApi } from '../../api'\nimport { useGalleryDragPayload } from '../../composables/useGalleryDragPayload'\nimport AssetCard from '../asset/AssetCard.vue'\nimport GridTimelineRailBridge from './GridTimelineRailBridge.vue'\nimport { useI18n } from '@/composables/useI18n'\nimport ScrollArea from '@/components/ui/scroll-area/ScrollArea.vue'\n\nconst store = useGalleryStore()\nconst galleryView = useGalleryView()\nconst gallerySelection = useGallerySelection()\nconst galleryLightbox = useGalleryLightbox()\nconst galleryContextMenu = useGalleryContextMenu()\nconst { prepareAssetDrag } = useGalleryDragPayload()\nconst { locale } = useI18n()\n\nconst scrollAreaRef = ref<InstanceType<typeof ScrollArea> | null>(null)\nconst scrollContainerRef = ref<HTMLElement | null>(null)\n\nconst isTimelineMode = computed(() => store.isTimelineMode)\nconst { width: containerWidth, height: containerHeight } = useElementSize(scrollContainerRef)\nconst columns = computed(() => {\n  const itemSize = galleryView.viewSize.value\n  const gap = 16\n  return Math.max(1, Math.floor((containerWidth.value + gap) / (itemSize + gap)))\n})\n\nconst gridVirtualizer = useGridVirtualizer({\n  containerRef: scrollContainerRef,\n  columns,\n  containerWidth,\n})\n\nconst { markers: railMarkers, labels: railLabels } = useTimelineRail({\n  isTimelineMode,\n  buckets: computed(() => store.timelineBuckets),\n  locale,\n  getOffsetByAssetIndex(assetIndex) {\n    const rowIndex = Math.floor(assetIndex / Math.max(columns.value, 1))\n    return rowIndex * gridVirtualizer.estimatedRowHeight.value\n  },\n})\n\nwatch(isTimelineMode, async (newValue) => {\n  setTimeout(async () => {\n    if (!newValue && scrollAreaRef.value) {\n      scrollContainerRef.value = scrollAreaRef.value.viewportElement\n    }\n    await gridVirtualizer.init()\n  }, 1000)\n})\n\nonMounted(async () => {\n  if (!isTimelineMode.value && scrollAreaRef.value) {\n    scrollContainerRef.value = scrollAreaRef.value.viewportElement\n  }\n\n  await gridVirtualizer.init()\n})\n\nfunction handleAssetClick(asset: Asset, event: MouseEvent, index: number) {\n  void gallerySelection.handleAssetClick(asset, event, index)\n}\n\nfunction handleAssetDoubleClick(asset: Asset, event: MouseEvent, index: number) {\n  const cardEl = (event.target as HTMLElement).closest('[data-asset-card]')\n  if (cardEl) {\n    const rect = cardEl.getBoundingClientRect()\n    const thumbnailUrl = galleryApi.getAssetThumbnailUrl(asset)\n    prepareHero(rect, thumbnailUrl, asset.width ?? 1, asset.height ?? 1)\n  }\n  gallerySelection.handleAssetDoubleClick(asset, event)\n  void galleryLightbox.openLightbox(index)\n}\n\nasync function handleAssetContextMenu(asset: Asset, event: MouseEvent, index: number) {\n  await gallerySelection.handleAssetContextMenu(asset, event, index)\n  galleryContextMenu.openForAsset({ asset, event, index, sourceView: 'grid' })\n}\n\nfunction handleAssetDragStart(asset: Asset, event: DragEvent) {\n  prepareAssetDrag(event, asset.id)\n}\n\nfunction scrollToIndex(index: number) {\n  const row = Math.floor(index / columns.value)\n  gridVirtualizer.virtualizer.value.scrollToIndex(row, { align: 'auto' })\n}\n\nfunction getCardRect(index: number): DOMRect | null {\n  const container = scrollContainerRef.value\n  if (!container) return null\n  const cards = container.querySelectorAll('[data-asset-card]')\n  // 虚拟列表只渲染可见行，找到与 index 对应的卡片\n  const row = Math.floor(index / columns.value)\n  const col = index % columns.value\n  const virtualRows = gridVirtualizer.virtualRows.value\n  const rowIdx = virtualRows.findIndex((r) => r.index === row)\n  if (rowIdx === -1) return null\n  // 每行有 columns 个卡片，从已渲染的 cards 中定位\n  const cardIndex = rowIdx * columns.value + col\n  const card = cards[cardIndex]\n  return card ? card.getBoundingClientRect() : null\n}\n\ndefineExpose({ scrollToIndex, getCardRect })\n</script>\n\n<template>\n  <div v-if=\"isTimelineMode\" class=\"flex h-full\">\n    <div ref=\"scrollContainerRef\" class=\"hide-scrollbar flex-1 overflow-auto py-2 pr-2 pl-6\">\n      <div\n        :style=\"{\n          height: `${gridVirtualizer.virtualizer.value.getTotalSize()}px`,\n          position: 'relative',\n        }\"\n      >\n        <div\n          v-for=\"virtualRow in gridVirtualizer.virtualRows.value\"\n          :key=\"virtualRow.index\"\n          :data-index=\"virtualRow.index\"\n          :style=\"{\n            position: 'absolute',\n            top: 0,\n            left: 0,\n            width: '100%',\n            height: `${virtualRow.size}px`,\n            transform: `translateY(${virtualRow.start}px)`,\n          }\"\n        >\n          <div\n            class=\"grid justify-items-center gap-4\"\n            :style=\"{\n              gridTemplateColumns: `repeat(${columns}, 1fr)`,\n            }\"\n          >\n            <template\n              v-for=\"(asset, idx) in virtualRow.assets\"\n              :key=\"asset?.id ?? `placeholder-${virtualRow.index}-${idx}`\"\n            >\n              <AssetCard\n                v-if=\"asset !== null\"\n                :asset=\"asset\"\n                :is-selected=\"gallerySelection.isAssetSelected(asset.id)\"\n                @click=\"(a, e) => handleAssetClick(a, e, virtualRow.index * columns + idx)\"\n                @double-click=\"\n                  (a, e) => handleAssetDoubleClick(a, e, virtualRow.index * columns + idx)\n                \"\n                @context-menu=\"\n                  (a, e) => void handleAssetContextMenu(a, e, virtualRow.index * columns + idx)\n                \"\n                @drag-start=\"(a, e) => handleAssetDragStart(a, e)\"\n              />\n\n              <div\n                v-else\n                class=\"skeleton-card rounded-lg\"\n                :style=\"{\n                  width: `${galleryView.viewSize.value}px`,\n                  height: `${galleryView.viewSize.value}px`,\n                }\"\n              />\n            </template>\n          </div>\n        </div>\n      </div>\n    </div>\n\n    <GridTimelineRailBridge\n      :scroll-container=\"scrollContainerRef\"\n      :container-height=\"containerHeight\"\n      :virtualizer=\"gridVirtualizer.virtualizer.value\"\n      :markers=\"railMarkers\"\n      :labels=\"railLabels\"\n    />\n  </div>\n\n  <ScrollArea v-else ref=\"scrollAreaRef\" class=\"mr-1 h-full\">\n    <div class=\"px-6\">\n      <div\n        :style=\"{\n          height: `${gridVirtualizer.virtualizer.value.getTotalSize()}px`,\n          position: 'relative',\n        }\"\n      >\n        <div\n          v-for=\"virtualRow in gridVirtualizer.virtualRows.value\"\n          :key=\"virtualRow.index\"\n          :data-index=\"virtualRow.index\"\n          :style=\"{\n            position: 'absolute',\n            top: 0,\n            left: 0,\n            width: '100%',\n            height: `${virtualRow.size}px`,\n            transform: `translateY(${virtualRow.start}px)`,\n          }\"\n        >\n          <div\n            class=\"grid justify-items-center gap-4\"\n            :style=\"{\n              gridTemplateColumns: `repeat(${columns}, 1fr)`,\n            }\"\n          >\n            <template\n              v-for=\"(asset, idx) in virtualRow.assets\"\n              :key=\"asset?.id ?? `placeholder-${virtualRow.index}-${idx}`\"\n            >\n              <AssetCard\n                v-if=\"asset !== null\"\n                :asset=\"asset\"\n                :is-selected=\"gallerySelection.isAssetSelected(asset.id)\"\n                @click=\"(a, e) => handleAssetClick(a, e, virtualRow.index * columns + idx)\"\n                @double-click=\"\n                  (a, e) => handleAssetDoubleClick(a, e, virtualRow.index * columns + idx)\n                \"\n                @context-menu=\"\n                  (a, e) => void handleAssetContextMenu(a, e, virtualRow.index * columns + idx)\n                \"\n                @drag-start=\"(a, e) => handleAssetDragStart(a, e)\"\n              />\n\n              <div\n                v-else\n                class=\"skeleton-card rounded-lg\"\n                :style=\"{\n                  width: `${galleryView.viewSize.value}px`,\n                  height: `${galleryView.viewSize.value}px`,\n                }\"\n              />\n            </template>\n          </div>\n        </div>\n      </div>\n    </div>\n  </ScrollArea>\n</template>\n\n<style scoped>\n.hide-scrollbar::-webkit-scrollbar {\n  display: none;\n}\n\n.hide-scrollbar {\n  scrollbar-width: none;\n}\n\n.skeleton-card {\n  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);\n  background-size: 200% 100%;\n  animation: loading 1.5s ease-in-out infinite;\n}\n\n@keyframes loading {\n  0% {\n    background-position: 200% 0;\n  }\n  100% {\n    background-position: -200% 0;\n  }\n}\n</style>\n"
  },
  {
    "path": "web/src/features/gallery/components/viewer/ListView.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed, onMounted, ref } from 'vue'\nimport { ArrowDown, ArrowUp, ArrowUpDown } from 'lucide-vue-next'\nimport { useI18n } from '@/composables/useI18n'\nimport {\n  useGallerySelection,\n  useGalleryLightbox,\n  useGalleryContextMenu,\n  useGalleryView,\n  useListVirtualizer,\n} from '../../composables'\nimport type { Asset, SortBy, SortOrder } from '../../types'\nimport { prepareHero } from '../../composables/useHeroTransition'\nimport { galleryApi } from '../../api'\nimport { useGalleryDragPayload } from '../../composables/useGalleryDragPayload'\nimport AssetListRow from '../asset/AssetListRow.vue'\nimport ScrollArea from '@/components/ui/scroll-area/ScrollArea.vue'\nimport ScrollBar from '@/components/ui/scroll-area/ScrollBar.vue'\n\n// 行高 = viewSize * 0.4，使缩略图大小与网格视图的卡片尺寸保持视觉一致\nconst LIST_ROW_HEIGHT_FACTOR = 0.4\n// 固定表头高度（表头在滚动区外，不参与虚拟列表的 scrollPadding）\nconst LIST_HEADER_HEIGHT = 36\n\nconst { t } = useI18n()\nconst galleryView = useGalleryView()\nconst gallerySelection = useGallerySelection()\nconst galleryLightbox = useGalleryLightbox()\nconst galleryContextMenu = useGalleryContextMenu()\nconst { prepareAssetDrag } = useGalleryDragPayload()\n\nconst scrollAreaRef = ref<InstanceType<typeof ScrollArea> | null>(null)\nconst scrollContainerRef = ref<HTMLElement | null>(null)\n\nconst rowHeight = computed(() => Math.round(galleryView.viewSize.value * LIST_ROW_HEIGHT_FACTOR))\n\n// 缩略图尺寸略小于行高，留出上下内边距；最小 28px 保证可辨识\nconst thumbnailSize = computed(() => Math.max(28, rowHeight.value - 12))\n// 缩略图列宽 = 缩略图尺寸 + 左右内边距\nconst thumbnailColumnWidth = computed(() => thumbnailSize.value + 20)\n// 五列布局：缩略图 | 文件名（弹性） | 类型（固定） | 分辨率（固定） | 大小（固定）\nconst columnsTemplate = computed(\n  () => `${thumbnailColumnWidth.value}px minmax(240px, 1fr) 88px 128px 108px`\n)\n\nconst listVirtualizer = useListVirtualizer({\n  containerRef: scrollContainerRef,\n  rowHeight,\n})\n\nconst sortBy = computed(() => galleryView.sortBy.value)\nconst sortOrder = computed(() => galleryView.sortOrder.value)\n\nfunction getDefaultSortOrder(field: SortBy): SortOrder {\n  // 名称默认升序，其余字段默认降序（大/新的排前面）\n  switch (field) {\n    case 'name':\n      return 'asc'\n    case 'resolution':\n      return 'desc'\n    case 'size':\n      return 'desc'\n    case 'createdAt':\n    default:\n      return 'desc'\n  }\n}\n\nfunction handleSortHeaderClick(field: SortBy) {\n  if (sortBy.value === field) {\n    galleryView.toggleSortOrder()\n    return\n  }\n\n  galleryView.setSorting(field, getDefaultSortOrder(field))\n}\n\nfunction getSortIcon(field: SortBy) {\n  if (sortBy.value !== field) {\n    return ArrowUpDown\n  }\n\n  return sortOrder.value === 'asc' ? ArrowUp : ArrowDown\n}\n\nfunction getSortButtonClass(field: SortBy): string {\n  return sortBy.value === field ? 'text-foreground' : 'text-muted-foreground hover:text-foreground'\n}\n\nonMounted(async () => {\n  // ScrollArea 的真实滚动容器在挂载后才可通过 viewportElement 获取\n  if (scrollAreaRef.value) {\n    scrollContainerRef.value = scrollAreaRef.value.viewportElement\n  }\n\n  await listVirtualizer.init()\n})\n\nfunction handleAssetClick(asset: Asset, event: MouseEvent, index: number) {\n  void gallerySelection.handleAssetClick(asset, event, index)\n}\n\nfunction handleAssetDoubleClick(asset: Asset, event: MouseEvent, index: number) {\n  const thumbnailEl = (event.target as HTMLElement).closest('[data-asset-thumbnail]')\n  if (thumbnailEl) {\n    const rect = thumbnailEl.getBoundingClientRect()\n    const thumbnailUrl = galleryApi.getAssetThumbnailUrl(asset)\n    prepareHero(rect, thumbnailUrl, asset.width ?? 1, asset.height ?? 1)\n  }\n\n  gallerySelection.handleAssetDoubleClick(asset, event)\n  void galleryLightbox.openLightbox(index)\n}\n\nasync function handleAssetContextMenu(asset: Asset, event: MouseEvent, index: number) {\n  await gallerySelection.handleAssetContextMenu(asset, event, index)\n  galleryContextMenu.openForAsset({ asset, event, index, sourceView: 'list' })\n}\n\nfunction handleAssetDragStart(asset: Asset, event: DragEvent) {\n  prepareAssetDrag(event, asset.id)\n}\n\nfunction scrollToIndex(index: number) {\n  listVirtualizer.virtualizer.value.scrollToIndex(index, { align: 'auto' })\n}\n\nfunction getCardRect(index: number): DOMRect | null {\n  const container = scrollContainerRef.value\n  if (!container) {\n    return null\n  }\n\n  // 通过 data-index 定位虚拟行，再取其内的缩略图元素位置，用于灯箱过渡动画\n  const thumbnail = container.querySelector(\n    `[data-index=\"${index}\"] [data-asset-thumbnail]`\n  ) as HTMLElement | null\n\n  return thumbnail?.getBoundingClientRect() ?? null\n}\n\ndefineExpose({ scrollToIndex, getCardRect })\n</script>\n\n<template>\n  <div class=\"mr-1 flex h-full min-h-0 flex-col\">\n    <div class=\"shrink-0 px-4\">\n      <div\n        class=\"grid items-center gap-3 px-3\"\n        :style=\"{\n          gridTemplateColumns: columnsTemplate,\n          height: `${LIST_HEADER_HEIGHT}px`,\n        }\"\n      >\n        <div />\n        <button\n          type=\"button\"\n          class=\"flex items-center gap-1 text-left text-xs font-medium transition-colors\"\n          :class=\"getSortButtonClass('name')\"\n          @click=\"handleSortHeaderClick('name')\"\n        >\n          <span>{{ t('gallery.details.asset.fileName') }}</span>\n          <component :is=\"getSortIcon('name')\" class=\"h-3.5 w-3.5\" />\n        </button>\n        <div class=\"text-xs font-medium text-muted-foreground\">\n          {{ t('gallery.details.asset.type') }}\n        </div>\n        <button\n          type=\"button\"\n          class=\"flex items-center gap-1 text-left text-xs font-medium transition-colors\"\n          :class=\"getSortButtonClass('resolution')\"\n          @click=\"handleSortHeaderClick('resolution')\"\n        >\n          <span>{{ t('gallery.details.asset.resolution') }}</span>\n          <component :is=\"getSortIcon('resolution')\" class=\"h-3.5 w-3.5\" />\n        </button>\n        <button\n          type=\"button\"\n          class=\"ml-auto flex items-center justify-end gap-1 text-right text-xs font-medium transition-colors\"\n          :class=\"getSortButtonClass('size')\"\n          @click=\"handleSortHeaderClick('size')\"\n        >\n          <span>{{ t('gallery.details.asset.fileSize') }}</span>\n          <component :is=\"getSortIcon('size')\" class=\"h-3.5 w-3.5\" />\n        </button>\n      </div>\n    </div>\n\n    <ScrollArea ref=\"scrollAreaRef\" type=\"always\" class=\"min-h-0 flex-1\">\n      <template #scrollbar>\n        <ScrollBar\n          class=\"w-4 p-0.5\"\n          thumb-class=\"bg-muted-foreground/35 hover:bg-muted-foreground/50\"\n        />\n      </template>\n      <div class=\"px-4 pb-3\">\n        <div\n          class=\"relative\"\n          :style=\"{\n            height: `${listVirtualizer.virtualizer.value.getTotalSize()}px`,\n          }\"\n        >\n          <div\n            v-for=\"virtualItem in listVirtualizer.virtualItems.value\"\n            :key=\"virtualItem.index\"\n            :data-index=\"virtualItem.index\"\n            :style=\"{\n              position: 'absolute',\n              top: 0,\n              left: 0,\n              width: '100%',\n              height: `${virtualItem.size}px`,\n              transform: `translateY(${virtualItem.start}px)`,\n            }\"\n          >\n            <AssetListRow\n              v-if=\"virtualItem.asset !== null\"\n              :asset=\"virtualItem.asset\"\n              :is-selected=\"gallerySelection.isAssetSelected(virtualItem.asset.id)\"\n              :row-height=\"rowHeight\"\n              :thumbnail-size=\"thumbnailSize\"\n              :columns-template=\"columnsTemplate\"\n              @click=\"(asset, event) => handleAssetClick(asset, event, virtualItem.index)\"\n              @double-click=\"\n                (asset, event) => handleAssetDoubleClick(asset, event, virtualItem.index)\n              \"\n              @context-menu=\"\n                (asset, event) => void handleAssetContextMenu(asset, event, virtualItem.index)\n              \"\n              @drag-start=\"(asset, event) => handleAssetDragStart(asset, event)\"\n            />\n\n            <div\n              v-else\n              class=\"grid animate-pulse items-center gap-3 rounded-sm px-3\"\n              :style=\"{\n                gridTemplateColumns: columnsTemplate,\n                height: `${rowHeight}px`,\n              }\"\n            >\n              <div\n                class=\"rounded-sm bg-muted\"\n                :style=\"{ width: `${thumbnailSize}px`, height: `${thumbnailSize}px` }\"\n              />\n              <div class=\"h-3 rounded bg-muted\" />\n              <div class=\"h-3 rounded bg-muted\" />\n              <div class=\"h-3 rounded bg-muted\" />\n              <div class=\"ml-auto h-3 w-20 rounded bg-muted\" />\n            </div>\n          </div>\n        </div>\n      </div>\n    </ScrollArea>\n  </div>\n</template>\n"
  },
  {
    "path": "web/src/features/gallery/components/viewer/MasonryView.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed, onMounted, ref, type ComponentPublicInstance } from 'vue'\nimport { useElementSize, useEventListener } from '@vueuse/core'\nimport type { Asset } from '../../types'\nimport {\n  useGalleryView,\n  useGallerySelection,\n  useGalleryLightbox,\n  useGalleryContextMenu,\n  useMasonryVirtualizer,\n  useTimelineRail,\n} from '../../composables'\nimport { prepareHero } from '../../composables/useHeroTransition'\nimport { galleryApi } from '../../api'\nimport { useGalleryDragPayload } from '../../composables/useGalleryDragPayload'\nimport { useGalleryStore } from '../../store'\nimport { useI18n } from '@/composables/useI18n'\nimport AssetCard from '../asset/AssetCard.vue'\nimport GalleryScrollbarRail from '../shell/GalleryScrollbarRail.vue'\n\nconst store = useGalleryStore()\nconst galleryView = useGalleryView()\nconst gallerySelection = useGallerySelection()\nconst galleryLightbox = useGalleryLightbox()\nconst galleryContextMenu = useGalleryContextMenu()\nconst { prepareAssetDrag } = useGalleryDragPayload()\nconst { locale } = useI18n()\n\nconst scrollContainerRef = ref<HTMLElement | null>(null)\nconst scrollTop = ref(0)\n\nconst { width: containerWidth, height: containerHeight } = useElementSize(scrollContainerRef)\n// 根据容器宽度和卡片目标尺寸计算列数，与 GridView 的算法保持一致\nconst columns = computed(() => {\n  const itemSize = galleryView.viewSize.value\n  const gap = 16\n  return Math.max(1, Math.floor((containerWidth.value + gap) / (itemSize + gap)))\n})\n\nconst masonryVirtualizer = useMasonryVirtualizer({\n  containerRef: scrollContainerRef,\n  columns,\n  containerWidth,\n})\n\nconst { markers: railMarkers, labels: railLabels } = useTimelineRail({\n  isTimelineMode: computed(() => store.isTimelineMode),\n  buckets: computed(() => store.timelineBuckets),\n  locale,\n  getOffsetByAssetIndex(assetIndex) {\n    return masonryVirtualizer.itemStartByIndex.value.get(assetIndex)\n  },\n})\n\nonMounted(async () => {\n  await masonryVirtualizer.init()\n})\n\nfunction handleScroll(event: Event) {\n  const target = event.target as HTMLElement\n  scrollTop.value = target.scrollTop\n}\n\nuseEventListener(scrollContainerRef, 'scroll', handleScroll)\n\nfunction getAssetAspectRatio(asset: Asset | null): string {\n  if (!asset || !asset.width || !asset.height || asset.width <= 0 || asset.height <= 0) {\n    return '1 / 1'\n  }\n\n  return `${asset.width} / ${asset.height}`\n}\n\nfunction handleAssetClick(asset: Asset, event: MouseEvent, index: number) {\n  void gallerySelection.handleAssetClick(asset, event, index)\n}\n\nfunction handleAssetDoubleClick(asset: Asset, event: MouseEvent, index: number) {\n  const cardEl = (event.target as HTMLElement).closest('[data-asset-card]')\n  if (cardEl) {\n    const rect = cardEl.getBoundingClientRect()\n    const thumbnailUrl = galleryApi.getAssetThumbnailUrl(asset)\n    prepareHero(rect, thumbnailUrl, asset.width ?? 1, asset.height ?? 1)\n  }\n\n  gallerySelection.handleAssetDoubleClick(asset, event)\n  void galleryLightbox.openLightbox(index)\n}\n\nasync function handleAssetContextMenu(asset: Asset, event: MouseEvent, index: number) {\n  await gallerySelection.handleAssetContextMenu(asset, event, index)\n  galleryContextMenu.openForAsset({ asset, event, index, sourceView: 'masonry' })\n}\n\nfunction handleAssetDragStart(asset: Asset, event: DragEvent) {\n  prepareAssetDrag(event, asset.id)\n}\n\nfunction scrollToIndex(index: number) {\n  masonryVirtualizer.virtualizer.value.scrollToIndex(index, { align: 'auto' })\n}\n\nfunction getCardRect(index: number): DOMRect | null {\n  const container = scrollContainerRef.value\n  if (!container) {\n    return null\n  }\n\n  // 通过 data-index 定位虚拟项，再取其内的卡片元素位置，用于灯箱过渡动画\n  const card = container.querySelector(\n    `[data-index=\"${index}\"] [data-asset-card]`\n  ) as HTMLElement | null\n\n  return card?.getBoundingClientRect() ?? null\n}\n\nfunction measureItemElement(element: Element | ComponentPublicInstance | null) {\n  // Vue 的 :ref 回调可能传入组件实例，过滤后只将原生 HTMLElement 交给 virtualizer 实测\n  if (element instanceof HTMLElement || element === null) {\n    masonryVirtualizer.measureElement(element)\n  }\n}\n\ndefineExpose({ scrollToIndex, getCardRect })\n</script>\n\n<template>\n  <div class=\"flex h-full\">\n    <div\n      ref=\"scrollContainerRef\"\n      class=\"hide-scrollbar h-full flex-1 overflow-auto py-2 pr-2 pl-6\"\n      @scroll=\"handleScroll\"\n    >\n      <div>\n        <div\n          :style=\"{\n            height: `${masonryVirtualizer.virtualizer.value.getTotalSize()}px`,\n            position: 'relative',\n          }\"\n        >\n          <div\n            v-for=\"virtualItem in masonryVirtualizer.virtualItems.value\"\n            :key=\"virtualItem.index\"\n            :ref=\"measureItemElement\"\n            :data-index=\"virtualItem.index\"\n            :style=\"{\n              position: 'absolute',\n              top: 0,\n              left: 0,\n              width: `${masonryVirtualizer.columnWidth.value}px`,\n              transform: `translateX(${masonryVirtualizer.getLaneOffset(virtualItem.lane)}px) translateY(${virtualItem.start}px)`,\n            }\"\n          >\n            <AssetCard\n              v-if=\"virtualItem.asset !== null\"\n              :asset=\"virtualItem.asset\"\n              :aspect-ratio=\"getAssetAspectRatio(virtualItem.asset)\"\n              :is-selected=\"gallerySelection.isAssetSelected(virtualItem.asset.id)\"\n              @click=\"(asset, event) => handleAssetClick(asset, event, virtualItem.index)\"\n              @double-click=\"\n                (asset, event) => handleAssetDoubleClick(asset, event, virtualItem.index)\n              \"\n              @context-menu=\"\n                (asset, event) => void handleAssetContextMenu(asset, event, virtualItem.index)\n              \"\n              @drag-start=\"(asset, event) => handleAssetDragStart(asset, event)\"\n            />\n\n            <div\n              v-else\n              class=\"animate-pulse rounded-lg bg-muted\"\n              :style=\"{\n                width: '100%',\n                height: `${masonryVirtualizer.getAssetHeight(null)}px`,\n              }\"\n            />\n          </div>\n        </div>\n      </div>\n    </div>\n\n    <GalleryScrollbarRail\n      :container-height=\"containerHeight\"\n      :scroll-top=\"scrollTop\"\n      :viewport-height=\"containerHeight\"\n      :virtualizer=\"masonryVirtualizer.virtualizer.value\"\n      :markers=\"railMarkers\"\n      :labels=\"railLabels\"\n    />\n  </div>\n</template>\n\n<style scoped>\n.hide-scrollbar::-webkit-scrollbar {\n  display: none;\n}\n\n.hide-scrollbar {\n  scrollbar-width: none;\n}\n</style>\n"
  },
  {
    "path": "web/src/features/gallery/composables/index.ts",
    "content": "export { useGalleryData } from './useGalleryData'\nexport { useGalleryView } from './useGalleryView'\nexport { useGallerySelection } from './useGallerySelection'\nexport { useGalleryLayout } from './useGalleryLayout'\nexport { useGallerySidebar } from './useGallerySidebar'\nexport { useGalleryLightbox } from './useGalleryLightbox'\nexport { useGridVirtualizer } from './useGridVirtualizer'\nexport { useMasonryVirtualizer } from './useMasonryVirtualizer'\nexport { useListVirtualizer } from './useListVirtualizer'\nexport { useAdaptiveVirtualizer } from './useAdaptiveVirtualizer'\nexport { useGalleryAssetActions } from './useGalleryAssetActions'\nexport { useGalleryContextMenu } from './useGalleryContextMenu'\nexport { useTimelineRail } from './timelineRail'\n"
  },
  {
    "path": "web/src/features/gallery/composables/timelineRail.ts",
    "content": "import { computed, type ComputedRef, type Ref } from 'vue'\nimport type { TimelineBucket } from '../types'\n\nexport interface TimelineRailMarker {\n  id: string\n  contentOffset: number\n  label?: string\n}\n\nexport interface TimelineRailLabel {\n  id: string\n  text: string\n  contentOffset: number\n}\n\nfunction formatMonthFull(monthStr: string, locale: string): string {\n  const [yearStr, monthStrNum] = monthStr.split('-')\n  const year = Number(yearStr)\n  const month = Number(monthStrNum)\n  if (!Number.isInteger(year) || !Number.isInteger(month) || month < 1 || month > 12) {\n    return monthStr\n  }\n\n  const date = new Date(Date.UTC(year, month - 1, 1))\n  return new Intl.DateTimeFormat(locale, {\n    year: 'numeric',\n    month: 'long',\n    timeZone: 'UTC',\n  }).format(date)\n}\n\nexport function useTimelineRail(options: {\n  isTimelineMode: Ref<boolean> | ComputedRef<boolean>\n  buckets: Ref<TimelineBucket[]> | ComputedRef<TimelineBucket[]>\n  locale: Ref<string> | ComputedRef<string>\n  getOffsetByAssetIndex: (assetIndex: number) => number | undefined\n}) {\n  const markers = computed((): TimelineRailMarker[] => {\n    if (!options.isTimelineMode.value || options.buckets.value.length === 0) {\n      return []\n    }\n\n    const result: TimelineRailMarker[] = []\n    let globalAssetIndex = 0\n\n    for (const bucket of options.buckets.value) {\n      const contentOffset = options.getOffsetByAssetIndex(globalAssetIndex)\n      if (contentOffset !== undefined) {\n        result.push({\n          id: bucket.month,\n          contentOffset,\n          label: formatMonthFull(bucket.month, options.locale.value),\n        })\n      }\n\n      globalAssetIndex += bucket.count\n    }\n\n    return result\n  })\n\n  const labels = computed((): TimelineRailLabel[] => {\n    const result: TimelineRailLabel[] = []\n    let currentYear: string | null = null\n\n    for (const marker of markers.value) {\n      const year = marker.id.split('-')[0]\n      if (year && year !== currentYear) {\n        result.push({\n          id: year,\n          text: year,\n          contentOffset: marker.contentOffset,\n        })\n        currentYear = year\n      }\n    }\n\n    return result\n  })\n\n  return {\n    markers,\n    labels,\n  }\n}\n"
  },
  {
    "path": "web/src/features/gallery/composables/useAdaptiveVirtualizer.ts",
    "content": "import { computed, ref, watch, type Ref } from 'vue'\nimport { useVirtualizer } from '@tanstack/vue-virtual'\nimport { useGalleryStore } from '../store'\nimport { useGalleryData } from './useGalleryData'\nimport { galleryApi } from '../api'\nimport { toQueryAssetsFilters } from '../queryFilters'\nimport type { AdaptiveLayoutRow, AdaptiveLayoutRowItem, Asset, AssetLayoutMetaItem } from '../types'\n\n// 自适应视图的行内间距。这里故意比 grid 更紧凑，保持相册式观感。\nconst ADAPTIVE_GAP = 6\n\nexport interface UseAdaptiveVirtualizerOptions {\n  // 原生滚动容器；adaptive 不再依赖 ScrollArea，而是直接读写真实滚动元素。\n  containerRef: Ref<HTMLElement | null>\n  // 内容区宽度，用来把“按比例排版”转换成真实行宽与行高。\n  containerWidth: Ref<number>\n}\n\nexport interface VirtualAdaptiveRowItem extends AdaptiveLayoutRowItem {\n  // 真实资产数据按需分页加载；未加载到时保持 null，渲染骨架占位。\n  asset: Asset | null\n}\n\nexport interface VirtualAdaptiveRow {\n  index: number\n  start: number\n  size: number\n  items: VirtualAdaptiveRowItem[]\n}\n\n// 宽高缺失或异常时回退到安全比例，避免单张错误数据把整行布局拉坏。\nfunction normalizeAspectRatio(item: AssetLayoutMetaItem): number {\n  if (!item.width || !item.height || item.width <= 0 || item.height <= 0) {\n    return 1\n  }\n\n  return Math.max(0.25, Math.min(4, item.width / item.height))\n}\n\nfunction buildAdaptiveRows(\n  metaItems: AssetLayoutMetaItem[],\n  contentWidth: number,\n  targetRowHeight: number,\n  gap: number\n): { rows: AdaptiveLayoutRow[]; rowIndexByAssetIndex: Map<number, number> } {\n  // 这一层只做“几何排版”，不关心真实 Asset 是否已加载。\n  // 输入是轻量布局元数据，输出是稳定的行分布和 assetIndex -> rowIndex 映射。\n  if (metaItems.length === 0 || contentWidth <= 0 || targetRowHeight <= 0) {\n    return { rows: [], rowIndexByAssetIndex: new Map() }\n  }\n\n  const rows: AdaptiveLayoutRow[] = []\n  const rowIndexByAssetIndex = new Map<number, number>()\n  let currentItems: Array<{ index: number; id: number; aspectRatio: number }> = []\n  let currentAspectSum = 0\n  let currentStart = 0\n\n  const finalizeRow = (justify: boolean) => {\n    // justify=true 表示普通行需要铺满内容宽度；最后一行则保持目标高度，不强行拉伸。\n    if (currentItems.length === 0) {\n      return\n    }\n\n    const totalGap = Math.max(0, currentItems.length - 1) * gap\n    const maxRowHeight = Math.max(1, targetRowHeight)\n    const fittedRowHeight = Math.max(1, (contentWidth - totalGap) / currentAspectSum)\n    const rowHeight = justify ? fittedRowHeight : Math.min(maxRowHeight, fittedRowHeight)\n    const rowIndex = rows.length\n\n    const items: AdaptiveLayoutRowItem[] = currentItems.map((item) => {\n      rowIndexByAssetIndex.set(item.index, rowIndex)\n      return {\n        index: item.index,\n        id: item.id,\n        width: item.aspectRatio * rowHeight,\n        height: rowHeight,\n        aspectRatio: item.aspectRatio,\n      }\n    })\n\n    rows.push({\n      index: rowIndex,\n      start: currentStart,\n      size: rowHeight,\n      items,\n    })\n\n    currentStart += rowHeight + gap\n    currentItems = []\n    currentAspectSum = 0\n  }\n\n  metaItems.forEach((item, index) => {\n    const aspectRatio = normalizeAspectRatio(item)\n    currentItems.push({ index, id: item.id, aspectRatio })\n    currentAspectSum += aspectRatio\n\n    // 经典 justified layout：当当前行按目标高度排版后已触达容器宽度，就立即收束成一行。\n    const totalGap = Math.max(0, currentItems.length - 1) * gap\n    const projectedRowWidth = currentAspectSum * targetRowHeight + totalGap\n    if (projectedRowWidth >= contentWidth) {\n      finalizeRow(true)\n    }\n  })\n\n  finalizeRow(false)\n\n  return { rows, rowIndexByAssetIndex }\n}\n\nexport function useAdaptiveVirtualizer(options: UseAdaptiveVirtualizerOptions) {\n  const { containerRef, containerWidth } = options\n\n  const store = useGalleryStore()\n  const galleryData = useGalleryData()\n\n  // 在 adaptive 模式里，viewSize 的语义不再是“方形卡片边长”，而是“目标行高”。\n  const targetRowHeight = computed(() => Math.max(100, store.viewConfig.size))\n  // 外层滚动容器直接承担左右内边距，布局宽度直接使用可见内容区宽度。\n  const contentWidth = computed(() => Math.max(0, containerWidth.value))\n  const layoutMetaItems = ref<AssetLayoutMetaItem[]>([])\n  const virtualRows = ref<VirtualAdaptiveRow[]>([])\n  const loadingPages = ref<Set<number>>(new Set())\n  const layoutRequestId = ref(0)\n\n  const layout = computed(() =>\n    buildAdaptiveRows(\n      layoutMetaItems.value,\n      contentWidth.value,\n      targetRowHeight.value,\n      ADAPTIVE_GAP\n    )\n  )\n\n  // 虚拟滚动的单位是“行”而不是“资产”。这正是 adaptive 与 masonry/grid 的核心区别。\n  const virtualizer = useVirtualizer<HTMLElement, HTMLElement>({\n    get count() {\n      return layout.value.rows.length\n    },\n    getScrollElement: () => containerRef.value,\n    estimateSize: (index) => layout.value.rows[index]?.size ?? targetRowHeight.value,\n    gap: ADAPTIVE_GAP,\n    paddingStart: 0,\n    paddingEnd: 16,\n    overscan: 8,\n  })\n\n  async function reloadLayoutMeta() {\n    // 只要筛选/排序变化，就重新拉取轻量布局元数据，保证整批行断点稳定。\n    const requestId = layoutRequestId.value + 1\n    layoutRequestId.value = requestId\n\n    const filters = toQueryAssetsFilters(store.filter, store.includeSubfolders)\n\n    try {\n      const response = await galleryApi.queryAssetLayoutMeta({\n        filters,\n        sortBy: store.sortBy,\n        sortOrder: store.sortOrder,\n      })\n\n      if (layoutRequestId.value !== requestId) {\n        return\n      }\n\n      layoutMetaItems.value = response.items\n    } catch (error) {\n      if (layoutRequestId.value !== requestId) {\n        return\n      }\n\n      layoutMetaItems.value = []\n      console.error('Failed to reload adaptive layout meta:', error)\n    }\n  }\n\n  function syncVirtualRows(items: ReturnType<typeof virtualizer.value.getVirtualItems>) {\n    const rows = layout.value.rows\n    if (items.length === 0 || rows.length === 0) {\n      virtualRows.value = []\n      store.setVisibleRange(undefined, undefined)\n      return\n    }\n\n    // store.visibleRange 仍然以“全局 asset index”表达，供现有分页加载与选中逻辑复用。\n    const visibleIndexes = items.flatMap(\n      (virtualItem) => rows[virtualItem.index]?.items.map((item) => item.index) ?? []\n    )\n\n    if (visibleIndexes.length === 0) {\n      store.setVisibleRange(undefined, undefined)\n    } else {\n      store.setVisibleRange(Math.min(...visibleIndexes), Math.max(...visibleIndexes))\n    }\n\n    virtualRows.value = items.map((virtualItem) => {\n      const row = rows[virtualItem.index]!\n      return {\n        index: row.index,\n        start: virtualItem.start,\n        size: virtualItem.size,\n        items: row.items.map((item) => {\n          const [asset] = store.getAssetsInRange(item.index, item.index)\n          return {\n            ...item,\n            asset: asset ?? null,\n          }\n        }),\n      }\n    })\n  }\n\n  async function loadMissingData(items: ReturnType<typeof virtualizer.value.getVirtualItems>) {\n    if (items.length === 0) {\n      return\n    }\n\n    // 行里每个 item 仍映射回原始结果集索引，因此分页策略可以完全复用 galleryData.loadPage。\n    const rows = layout.value.rows\n    const neededPages = new Set<number>()\n\n    items.forEach((virtualItem) => {\n      const row = rows[virtualItem.index]\n      if (!row) {\n        return\n      }\n\n      row.items.forEach((item) => {\n        neededPages.add(Math.floor(item.index / store.perPage) + 1)\n      })\n    })\n\n    const loadPromises: Promise<void>[] = []\n    neededPages.forEach((pageNum) => {\n      if (!store.isPageLoaded(pageNum) && !loadingPages.value.has(pageNum)) {\n        loadingPages.value.add(pageNum)\n        const loadPromise = galleryData.loadPage(pageNum).finally(() => {\n          loadingPages.value.delete(pageNum)\n        })\n        loadPromises.push(loadPromise)\n      }\n    })\n\n    if (loadPromises.length > 0) {\n      await Promise.all(loadPromises)\n    }\n  }\n\n  async function init() {\n    const hasReusableCache = store.totalCount > 0 && store.paginatedAssets.size > 0\n\n    // 先拿布局元数据，再按当前查询加载可见资产页；两条链路职责分离。\n    // 若已有可用分页缓存，则只更新布局元数据，避免 refreshCurrentQuery 把缓存先替换成 page1。\n    if (hasReusableCache) {\n      await reloadLayoutMeta()\n      return\n    }\n\n    await Promise.all([reloadLayoutMeta(), galleryData.refreshCurrentQuery()])\n  }\n\n  watch(\n    () => [store.filter, store.includeSubfolders, store.sortBy, store.sortOrder],\n    async () => {\n      await reloadLayoutMeta()\n    },\n    { deep: true }\n  )\n\n  watch(\n    () => ({\n      items: virtualizer.value.getVirtualItems(),\n      rows: layout.value.rows,\n      paginatedAssetsVersion: store.paginatedAssetsVersion,\n    }),\n    async ({ items }) => {\n      syncVirtualRows(items)\n      await loadMissingData(items)\n      syncVirtualRows(virtualizer.value.getVirtualItems())\n    }\n  )\n\n  watch([layout, targetRowHeight], () => {\n    // 行分布或目标高度变化后通知 virtualizer 重算总高度和可见窗口。\n    if (layout.value.rows.length > 0) {\n      virtualizer.value.measure()\n    }\n  })\n\n  function scrollToIndex(index: number) {\n    // 灯箱返回/背景预对齐仍以 asset index 为中心语义，因此这里需要先映射到行再滚动。\n    const rowIndex = layout.value.rowIndexByAssetIndex.get(index)\n    if (rowIndex === undefined) {\n      return\n    }\n\n    virtualizer.value.scrollToIndex(rowIndex, { align: 'auto' })\n  }\n\n  return {\n    virtualizer,\n    virtualRows,\n    rows: computed(() => layout.value.rows),\n    rowIndexByAssetIndex: computed(() => layout.value.rowIndexByAssetIndex),\n    gap: ADAPTIVE_GAP,\n    init,\n    scrollToIndex,\n  }\n}\n"
  },
  {
    "path": "web/src/features/gallery/composables/useGalleryAssetActions.ts",
    "content": "import { computed } from 'vue'\nimport { useI18n } from '@/composables/useI18n'\nimport { useToast } from '@/composables/useToast'\nimport { galleryApi } from '../api'\nimport { useGalleryStore } from '../store'\nimport type { ReviewFlag } from '../types'\n\nexport function useGalleryAssetActions() {\n  const store = useGalleryStore()\n  const { t } = useI18n()\n  const { toast } = useToast()\n\n  const selectedAssetIds = computed(() => Array.from(store.selection.selectedIds))\n  const hasSelection = computed(() => selectedAssetIds.value.length > 0)\n  const isSingleSelection = computed(() => selectedAssetIds.value.length === 1)\n  const selectedAssetId = computed(() => {\n    if (!isSingleSelection.value) {\n      return undefined\n    }\n\n    return selectedAssetIds.value[0]\n  })\n\n  function buildMoveToTrashDescription(result: {\n    affectedCount?: number\n    failedCount?: number\n    notFoundCount?: number\n  }) {\n    return t('gallery.contextMenu.moveToTrash.partialDescription', {\n      moved: result.affectedCount ?? 0,\n      failed: result.failedCount ?? 0,\n      notFound: result.notFoundCount ?? 0,\n    })\n  }\n\n  function buildCopyFilesDescription(result: {\n    affectedCount?: number\n    failedCount?: number\n    notFoundCount?: number\n  }) {\n    return t('gallery.contextMenu.copyFiles.partialDescription', {\n      copied: result.affectedCount ?? 0,\n      failed: result.failedCount ?? 0,\n      notFound: result.notFoundCount ?? 0,\n    })\n  }\n\n  async function handleOpenAssetDefault() {\n    const assetId = selectedAssetId.value\n    if (assetId === undefined) {\n      return\n    }\n\n    try {\n      const result = await galleryApi.openAssetDefault(assetId)\n      if (!result.success) {\n        throw new Error(result.message)\n      }\n    } catch (error) {\n      const message = error instanceof Error ? error.message : String(error)\n      toast.error(t('gallery.contextMenu.openDefaultApp.failedTitle'), {\n        description: message,\n      })\n    }\n  }\n\n  async function handleRevealAssetInExplorer() {\n    const assetId = selectedAssetId.value\n    if (assetId === undefined) {\n      return\n    }\n\n    try {\n      const result = await galleryApi.revealAssetInExplorer(assetId)\n      if (!result.success) {\n        throw new Error(result.message)\n      }\n    } catch (error) {\n      const message = error instanceof Error ? error.message : String(error)\n      toast.error(t('gallery.contextMenu.revealInExplorer.failedTitle'), {\n        description: message,\n      })\n    }\n  }\n\n  async function handleCopyAssetsToClipboard() {\n    if (selectedAssetIds.value.length === 0) {\n      return\n    }\n\n    try {\n      const result = await galleryApi.copyAssetsToClipboard(selectedAssetIds.value)\n      const copiedCount = result.affectedCount ?? 0\n\n      if (!result.success && copiedCount === 0) {\n        throw new Error(result.message)\n      }\n\n      if (result.success) {\n        toast.success(t('gallery.contextMenu.copyFiles.successTitle'), {\n          description: t('gallery.contextMenu.copyFiles.successDescription', {\n            count: copiedCount || selectedAssetIds.value.length,\n          }),\n        })\n      } else {\n        toast.warning(t('gallery.contextMenu.copyFiles.partialTitle'), {\n          description: buildCopyFilesDescription(result),\n        })\n      }\n    } catch (error) {\n      const message = error instanceof Error ? error.message : String(error)\n      toast.error(t('gallery.contextMenu.copyFiles.failedTitle'), {\n        description: message,\n      })\n    }\n  }\n\n  async function handleMoveAssetsToTrash() {\n    if (selectedAssetIds.value.length === 0) {\n      return\n    }\n\n    const ids = [...selectedAssetIds.value]\n    const previousActiveIndex = store.selection.activeIndex\n\n    try {\n      const result = await galleryApi.moveAssetsToTrash(ids)\n      const affectedCount = result.affectedCount ?? 0\n      const failedCount = result.failedCount ?? 0\n      const notFoundCount = result.notFoundCount ?? 0\n      if (!result.success && affectedCount === 0) {\n        throw new Error(\n          t('gallery.contextMenu.moveToTrash.failedDescription', {\n            failed: failedCount,\n            notFound: notFoundCount,\n          })\n        )\n      }\n\n      // 等待 gallery.changed 统一刷新，先做最小本地修复避免短暂状态错乱。\n      const nextSelectedIds = selectedAssetIds.value.filter((id) => !ids.includes(id))\n      store.replaceSelection(nextSelectedIds)\n      store.setSelectionAnchor(undefined)\n\n      const activeAssetId = store.selection.activeAssetId\n      if (activeAssetId !== undefined && ids.includes(activeAssetId)) {\n        if (store.lightbox.isOpen) {\n          store.closeLightbox()\n        }\n        store.clearActiveAsset()\n        if (previousActiveIndex !== undefined && previousActiveIndex > 0) {\n          store.setSelectionActive(previousActiveIndex - 1)\n        }\n      }\n\n      if (result.success) {\n        toast.success(t('gallery.contextMenu.moveToTrash.successTitle'), {\n          description: t('gallery.contextMenu.moveToTrash.successDescription', {\n            count: affectedCount || ids.length,\n          }),\n        })\n      } else {\n        toast.warning(t('gallery.contextMenu.moveToTrash.partialTitle'), {\n          description: buildMoveToTrashDescription(result),\n        })\n      }\n    } catch (error) {\n      const message = error instanceof Error ? error.message : String(error)\n      toast.error(t('gallery.contextMenu.moveToTrash.failedTitle'), {\n        description: message,\n      })\n    }\n  }\n\n  async function updateSelectedAssetsReviewState(payload: {\n    rating?: number\n    reviewFlag?: ReviewFlag\n  }) {\n    if (selectedAssetIds.value.length === 0) {\n      return\n    }\n\n    try {\n      const result = await galleryApi.updateAssetsReviewState({\n        assetIds: selectedAssetIds.value,\n        rating: payload.rating,\n        reviewFlag: payload.reviewFlag,\n      })\n\n      if (!result.success) {\n        throw new Error(result.message)\n      }\n\n      // 审片是高频操作：成功后优先 patch 当前已加载数据，避免等待整页重载。\n      store.patchAssetsReviewState(selectedAssetIds.value, payload)\n    } catch (error) {\n      const message = error instanceof Error ? error.message : String(error)\n      toast.error(t('gallery.review.update.failedTitle'), {\n        description: message,\n      })\n      throw error\n    }\n  }\n\n  async function setSelectedAssetsRating(rating: number) {\n    await updateSelectedAssetsReviewState({ rating })\n  }\n\n  async function clearSelectedAssetsRating() {\n    await updateSelectedAssetsReviewState({ rating: 0 })\n  }\n\n  async function setSelectedAssetsReviewFlag(reviewFlag: ReviewFlag) {\n    await updateSelectedAssetsReviewState({ reviewFlag })\n  }\n\n  async function clearSelectedAssetsReviewFlag() {\n    await updateSelectedAssetsReviewState({ reviewFlag: 'none' })\n  }\n\n  async function setSelectedAssetsRejected() {\n    await setSelectedAssetsReviewFlag('rejected')\n  }\n\n  async function clearSelectedAssetsRejected() {\n    await clearSelectedAssetsReviewFlag()\n  }\n\n  return {\n    selectedAssetIds,\n    hasSelection,\n    isSingleSelection,\n    selectedAssetId,\n    handleOpenAssetDefault,\n    handleRevealAssetInExplorer,\n    handleCopyAssetsToClipboard,\n    handleMoveAssetsToTrash,\n    updateSelectedAssetsReviewState,\n    setSelectedAssetsRating,\n    clearSelectedAssetsRating,\n    setSelectedAssetsReviewFlag,\n    clearSelectedAssetsReviewFlag,\n    setSelectedAssetsRejected,\n    clearSelectedAssetsRejected,\n  }\n}\n"
  },
  {
    "path": "web/src/features/gallery/composables/useGalleryContextMenu.ts",
    "content": "import { reactive, readonly } from 'vue'\nimport type { Asset } from '../types'\n\nexport type GalleryContextMenuSourceView = 'grid' | 'list' | 'masonry' | 'adaptive' | 'filmstrip'\n\ninterface GalleryContextMenuState {\n  isOpen: boolean\n  requestToken: number\n  anchorX: number\n  anchorY: number\n  contextAssetId?: number\n  contextIndex?: number\n  sourceView?: GalleryContextMenuSourceView\n}\n\nconst state = reactive<GalleryContextMenuState>({\n  isOpen: false,\n  requestToken: 0,\n  anchorX: 0,\n  anchorY: 0,\n  contextAssetId: undefined,\n  contextIndex: undefined,\n  sourceView: undefined,\n})\n\ninterface OpenForAssetOptions {\n  asset: Asset\n  event: MouseEvent\n  index: number\n  sourceView: GalleryContextMenuSourceView\n}\n\nexport function useGalleryContextMenu() {\n  function openForAsset(options: OpenForAssetOptions) {\n    const { asset, event, index, sourceView } = options\n    // 右键入口统一拦截浏览器默认菜单，避免与自定义菜单重叠。\n    event.preventDefault()\n    event.stopPropagation()\n\n    // 上下文信息只记录“当前语义焦点”，菜单动作仍由现有 assetActions 读取 selection 执行。\n    state.contextAssetId = asset.id\n    state.contextIndex = index\n    state.sourceView = sourceView\n    state.anchorX = event.clientX\n    state.anchorY = event.clientY\n    // 已开状态下先关闭，让宿主在下一拍基于新锚点“重开”，避免位置不刷新。\n    if (state.isOpen) {\n      state.isOpen = false\n    }\n    // token 仅作为“定位后重开”的信号，不承载业务状态。\n    state.requestToken += 1\n  }\n\n  function setOpen(open: boolean) {\n    state.isOpen = open\n  }\n\n  function close() {\n    setOpen(false)\n  }\n\n  return {\n    state: readonly(state),\n    openForAsset,\n    setOpen,\n    close,\n  }\n}\n"
  },
  {
    "path": "web/src/features/gallery/composables/useGalleryData.ts",
    "content": "import { useGalleryStore } from '../store'\nimport { galleryApi } from '../api'\nimport type { Asset, ScanAssetsParams } from '../types'\nimport { toQueryAssetsFilters } from '../queryFilters'\n\n/**\n * Gallery数据管理 Composable\n * 负责协调 API 调用和 Store 操作\n * 组件应直接从 Store 读取状态，而不是通过这里的 computed 属性\n */\nexport function useGalleryData() {\n  const store = useGalleryStore()\n\n  function findLoadedAssetById(assetId: number) {\n    for (const pageAssets of store.paginatedAssets.values()) {\n      const asset = pageAssets.find((item) => item.id === assetId)\n      if (asset) {\n        return asset\n      }\n    }\n\n    return undefined\n  }\n\n  function hasRenderableResults(): boolean {\n    return (\n      store.paginatedAssets.size > 0 || store.totalCount > 0 || store.timelineBuckets.length > 0\n    )\n  }\n\n  function getAnchorPageNumber() {\n    const startIndex = store.visibleRange.startIndex\n    if (startIndex === undefined || startIndex < 0) {\n      return 1\n    }\n\n    return Math.floor(startIndex / store.perPage) + 1\n  }\n\n  function getVisiblePageNumbers(total: number): number[] {\n    if (total <= 0) {\n      return []\n    }\n\n    const maxIndex = total - 1\n    const startIndex = store.visibleRange.startIndex\n    const endIndex = store.visibleRange.endIndex\n\n    if (startIndex === undefined || endIndex === undefined) {\n      return [1]\n    }\n\n    const clampedStart = Math.max(0, Math.min(startIndex, maxIndex))\n    const clampedEnd = Math.max(clampedStart, Math.min(endIndex, maxIndex))\n    const startPage = Math.floor(clampedStart / store.perPage) + 1\n    const endPage = Math.floor(clampedEnd / store.perPage) + 1\n    const pages: number[] = []\n\n    for (let pageNum = startPage; pageNum <= endPage; pageNum += 1) {\n      pages.push(pageNum)\n    }\n\n    return pages.length > 0 ? pages : [1]\n  }\n\n  async function queryAssetPage(pageNum: number, activeAssetId?: number) {\n    const filters = toQueryAssetsFilters(store.filter, store.includeSubfolders)\n\n    return galleryApi.queryAssets({\n      filters,\n      sortBy: store.sortBy,\n      sortOrder: store.sortOrder,\n      activeAssetId,\n      page: pageNum,\n      perPage: store.perPage,\n    })\n  }\n\n  async function queryCurrentAssetIds() {\n    const filters = toQueryAssetsFilters(store.filter, store.includeSubfolders)\n    const response = await galleryApi.queryAssetLayoutMeta({\n      filters,\n      sortBy: store.sortBy,\n      sortOrder: store.sortOrder,\n    })\n\n    return response.items.map((item) => item.id)\n  }\n\n  async function queryVisiblePages(total: number, preferredPage: number) {\n    if (total <= 0) {\n      return new Map<number, Asset[]>()\n    }\n\n    const maxPage = Math.max(1, Math.ceil(total / store.perPage))\n    const visiblePages = new Set(getVisiblePageNumbers(total))\n    visiblePages.add(Math.max(1, Math.min(preferredPage, maxPage)))\n    const pageNumbers = [...visiblePages].sort((left, right) => left - right)\n\n    const responses = await Promise.all(pageNumbers.map((pageNum) => queryAssetPage(pageNum)))\n    const pages = new Map<number, Asset[]>()\n\n    pageNumbers.forEach((pageNum, index) => {\n      pages.set(pageNum, responses[index]?.items ?? [])\n    })\n\n    return pages\n  }\n\n  // 在筛选结果刷新后，用 activeAssetId 将当前位置重建到新的结果集上。\n  async function reconcileActiveAsset(activeAssetIndex?: number, requestVersion?: number) {\n    if (requestVersion !== undefined && !store.isQueryVersionCurrent(requestVersion)) {\n      return\n    }\n\n    const activeAssetId = store.selection.activeAssetId\n    if (activeAssetId === undefined) {\n      return\n    }\n\n    // 原资产已不在新结果集里：清空 active，并在灯箱场景下直接退出，避免跳到错误图片。\n    if (activeAssetIndex === undefined) {\n      if (store.lightbox.isOpen) {\n        store.closeLightbox()\n      }\n\n      if (store.detailsPanel.type === 'asset' && store.detailsPanel.asset.id === activeAssetId) {\n        store.clearDetailsFocus()\n      }\n\n      store.clearActiveAsset()\n      return\n    }\n\n    store.setSelectionActive(activeAssetIndex)\n\n    // 重定位只返回索引；这里确保对应页面已加载，后续 UI 才能拿到完整资产对象。\n    const targetPage = Math.floor(activeAssetIndex / store.perPage) + 1\n    if (!store.isPageLoaded(targetPage)) {\n      await loadPage(targetPage)\n      if (requestVersion !== undefined && !store.isQueryVersionCurrent(requestVersion)) {\n        return\n      }\n    }\n\n    const loadedActiveAsset = findLoadedAssetById(activeAssetId)\n    if (\n      loadedActiveAsset &&\n      store.detailsPanel.type === 'asset' &&\n      store.detailsPanel.asset.id === loadedActiveAsset.id\n    ) {\n      store.setDetailsFocus({ type: 'asset', asset: loadedActiveAsset })\n    }\n\n    if (store.lightbox.isOpen && loadedActiveAsset) {\n      store.replaceSelection([loadedActiveAsset.id])\n      store.setSelectionAnchor(activeAssetIndex)\n      store.setDetailsFocus({ type: 'asset', asset: loadedActiveAsset })\n    }\n  }\n\n  async function refreshGridData() {\n    const requestVersion = store.beginQueryRefresh()\n    const shouldShowLoading = !hasRenderableResults()\n\n    try {\n      if (shouldShowLoading) {\n        store.setLoading(true)\n      }\n      store.setError(null)\n\n      let pageNum = getAnchorPageNumber()\n      let response = await queryAssetPage(pageNum, store.selection.activeAssetId)\n\n      if (!store.isQueryVersionCurrent(requestVersion)) {\n        return\n      }\n\n      const maxPage = Math.max(1, Math.ceil(response.totalCount / store.perPage))\n      if (response.totalCount > 0 && pageNum > maxPage) {\n        pageNum = maxPage\n        response = await queryAssetPage(pageNum, store.selection.activeAssetId)\n        if (!store.isQueryVersionCurrent(requestVersion)) {\n          return\n        }\n      }\n\n      const pages = await queryVisiblePages(response.totalCount, pageNum)\n      if (!store.isQueryVersionCurrent(requestVersion)) {\n        return\n      }\n\n      {\n        store.clearTimelineData()\n        store.setPagination(response.totalCount, pageNum, pageNum < maxPage)\n        store.replacePaginatedAssets(pages)\n      }\n\n      await reconcileActiveAsset(response.activeAssetIndex, requestVersion)\n\n      console.log('📊 加载完成:', {\n        totalCount: response.totalCount,\n        loadedPages: [...pages.keys()],\n        perPage: store.perPage,\n      })\n    } catch (error) {\n      console.error('加载失败:', error)\n      store.setError('加载数据失败')\n    } finally {\n      store.finishQueryRefresh(requestVersion)\n      if (shouldShowLoading) {\n        store.setLoading(false)\n      }\n    }\n  }\n\n  async function refreshTimelineData() {\n    const requestVersion = store.beginQueryRefresh()\n    const shouldShowLoading = !hasRenderableResults()\n\n    try {\n      if (shouldShowLoading) {\n        store.setLoading(true)\n      }\n      store.setError(null)\n\n      const filters = toQueryAssetsFilters(store.filter, store.includeSubfolders)\n      const bucketsResponse = await galleryApi.getTimelineBuckets({\n        folderId: filters.folderId,\n        includeSubfolders: filters.includeSubfolders,\n        sortOrder: store.sortOrder,\n        activeAssetId: store.selection.activeAssetId,\n        type: filters.type,\n        search: filters.search,\n        rating: filters.rating,\n        reviewFlag: filters.reviewFlag,\n        tagIds: filters.tagIds,\n        tagMatchMode: filters.tagMatchMode,\n        clothIds: filters.clothIds,\n        clothMatchMode: filters.clothMatchMode,\n        colorHexes: filters.colorHexes,\n      })\n\n      if (!store.isQueryVersionCurrent(requestVersion)) {\n        return\n      }\n\n      const pageNum = Math.max(\n        1,\n        Math.min(\n          getAnchorPageNumber(),\n          Math.max(1, Math.ceil(bucketsResponse.totalCount / store.perPage))\n        )\n      )\n      const pages = await queryVisiblePages(bucketsResponse.totalCount, pageNum)\n      if (!store.isQueryVersionCurrent(requestVersion)) {\n        return\n      }\n\n      {\n        store.setTimelineBuckets(bucketsResponse.buckets)\n        store.setTimelineTotalCount(bucketsResponse.totalCount)\n        store.setPagination(\n          bucketsResponse.totalCount,\n          pageNum,\n          pageNum < Math.max(1, Math.ceil(bucketsResponse.totalCount / store.perPage))\n        )\n        store.replacePaginatedAssets(pages)\n      }\n\n      await reconcileActiveAsset(bucketsResponse.activeAssetIndex, requestVersion)\n\n      console.log('📅 时间线数据加载成功:', {\n        months: bucketsResponse.buckets.length,\n        total: bucketsResponse.totalCount,\n        loadedPages: [...pages.keys()],\n      })\n    } catch (error) {\n      console.error('Failed to load timeline data:', error)\n      store.setError('加载时间线数据失败')\n    } finally {\n      store.finishQueryRefresh(requestVersion)\n      if (shouldShowLoading) {\n        store.setLoading(false)\n      }\n    }\n  }\n\n  // ============= 数据加载操作 =============\n\n  /**\n   * 加载时间线数据（月份元数据 + 当前可见页）\n   */\n  async function loadTimelineData() {\n    await refreshTimelineData()\n  }\n\n  /**\n   * 加载普通模式资产 - 保留旧结果，等新结果就绪后原子替换\n   */\n  async function loadAllAssets() {\n    await refreshGridData()\n  }\n\n  async function refreshCurrentQuery() {\n    if (store.isTimelineMode) {\n      await refreshTimelineData()\n      return\n    }\n\n    await refreshGridData()\n  }\n\n  /**\n   * 加载指定页（用于虚拟列表按需加载）\n   */\n  async function loadPage(pageNum: number) {\n    if (store.isPageLoaded(pageNum)) {\n      return\n    }\n\n    const requestVersion = store.queryVersion\n\n    try {\n      const response = await queryAssetPage(pageNum)\n      if (!store.isQueryVersionCurrent(requestVersion)) {\n        return\n      }\n\n      store.setPageAssets(pageNum, response.items)\n\n      console.log('✅ 第', pageNum, '页加载完成:', response.items.length, '个资产')\n    } catch (error) {\n      console.error('加载第', pageNum, '页失败:', error)\n      throw error\n    }\n  }\n\n  /**\n   * 加载文件夹树\n   */\n  async function loadFolderTree(options: { silent?: boolean } = {}) {\n    const { silent = false } = options\n\n    try {\n      if (!silent) {\n        store.setFoldersLoading(true)\n        store.setFoldersError(null)\n      }\n\n      const folderTree = await galleryApi.getFolderTree()\n      store.setFolders(folderTree)\n    } catch (error) {\n      console.error('Failed to load folder tree:', error)\n      if (!silent) {\n        store.setFoldersError('加载文件夹树失败')\n      }\n      throw error\n    } finally {\n      if (!silent) {\n        store.setFoldersLoading(false)\n      }\n    }\n  }\n\n  /**\n   * 扫描资产目录\n   */\n  async function scanAssets(options: ScanAssetsParams) {\n    try {\n      const result = await galleryApi.scanAssets(options)\n\n      return result\n    } catch (error) {\n      console.error('Failed to scan assets:', error)\n      throw error\n    }\n  }\n\n  /**\n   * 提交后台扫描任务\n   */\n  async function startScanAssets(options: ScanAssetsParams) {\n    try {\n      const result = await galleryApi.startScanAssets(options)\n\n      return result\n    } catch (error) {\n      console.error('Failed to start scan task:', error)\n      throw error\n    }\n  }\n\n  /**\n   * 获取资产缩略图URL\n   */\n  function getAssetThumbnailUrl(asset: any) {\n    return galleryApi.getAssetThumbnailUrl(asset)\n  }\n\n  function getAssetUrl(asset: Asset) {\n    return galleryApi.getAssetUrl(asset)\n  }\n\n  return {\n    // 数据加载方法\n    loadTimelineData,\n    loadAllAssets,\n    refreshCurrentQuery,\n    loadPage,\n    queryCurrentAssetIds,\n    loadFolderTree,\n    scanAssets,\n    startScanAssets,\n\n    // 工具函数\n    getAssetThumbnailUrl,\n    getAssetUrl,\n  }\n}\n"
  },
  {
    "path": "web/src/features/gallery/composables/useGalleryDragPayload.ts",
    "content": "import { useGalleryStore } from '../store'\n\nconst GALLERY_ASSET_DRAG_MIME = 'application/x-spinningmomo-gallery-asset-ids'\nconst DRAG_PREVIEW_MAX_EDGE = 128\nconst DRAG_PREVIEW_RADIUS = 4\n// drag image 必须挂在真实 DOM 上；这里保存当前临时节点，便于 dragend 清理。\nlet activeDragPreview: HTMLElement | null = null\n\nfunction parseAssetIds(raw: string): number[] {\n  if (!raw) {\n    return []\n  }\n\n  try {\n    const parsed = JSON.parse(raw) as unknown\n    if (!Array.isArray(parsed)) {\n      return []\n    }\n    return parsed.map((id) => Number(id)).filter((id) => Number.isInteger(id) && id > 0)\n  } catch {\n    return []\n  }\n}\n\nfunction cleanupDragPreview() {\n  if (!activeDragPreview) {\n    return\n  }\n  activeDragPreview.remove()\n  activeDragPreview = null\n}\n\nfunction createDragPreview(sourceEl: HTMLElement, selectedCount: number): HTMLElement {\n  // 列表视图用“纯数字圆点”，避免覆盖侧边栏文本。\n  const isListRowDrag = !!sourceEl.closest('[data-asset-list-row]')\n  if (isListRowDrag) {\n    const badgeOnly = document.createElement('div')\n    badgeOnly.style.position = 'fixed'\n    badgeOnly.style.left = '-10000px'\n    badgeOnly.style.top = '-10000px'\n    badgeOnly.style.width = '28px'\n    badgeOnly.style.height = '28px'\n    badgeOnly.style.borderRadius = '999px'\n    badgeOnly.style.background = 'rgba(0, 0, 0, 0.78)'\n    badgeOnly.style.color = '#fff'\n    badgeOnly.style.fontSize = '12px'\n    badgeOnly.style.fontWeight = '700'\n    badgeOnly.style.display = 'flex'\n    badgeOnly.style.alignItems = 'center'\n    badgeOnly.style.justifyContent = 'center'\n    badgeOnly.style.pointerEvents = 'none'\n    badgeOnly.style.boxShadow = '0 8px 18px rgba(0, 0, 0, 0.28)'\n    badgeOnly.textContent = String(Math.max(1, selectedCount))\n    document.body.appendChild(badgeOnly)\n    activeDragPreview = badgeOnly\n    return badgeOnly\n  }\n\n  const previewShell = document.createElement('div')\n  const rect = sourceEl.getBoundingClientRect()\n  const width = Math.max(1, rect.width)\n  const height = Math.max(1, rect.height)\n  const scale = Math.min(1, DRAG_PREVIEW_MAX_EDGE / Math.max(width, height))\n  const previewWidth = Math.round(width * scale)\n  const previewHeight = Math.round(height * scale)\n\n  previewShell.style.position = 'fixed'\n  previewShell.style.left = '-10000px'\n  previewShell.style.top = '-10000px'\n  previewShell.style.width = `${previewWidth}px`\n  previewShell.style.height = `${previewHeight}px`\n  previewShell.style.pointerEvents = 'none'\n  previewShell.style.margin = '0'\n  previewShell.style.boxSizing = 'border-box'\n  previewShell.style.overflow = 'hidden'\n  previewShell.style.borderRadius = `${DRAG_PREVIEW_RADIUS}px`\n  previewShell.style.background = 'rgba(15, 23, 42, 0.35)'\n  previewShell.style.boxShadow = '0 10px 24px rgba(0, 0, 0, 0.24)'\n\n  // 优先只取图片层，避免把卡片 hover/ring 等复杂样式带进拖拽预览。\n  const media = sourceEl.querySelector('img')?.cloneNode(true) as HTMLImageElement | undefined\n  if (media) {\n    media.style.width = '100%'\n    media.style.height = '100%'\n    media.style.objectFit = 'cover'\n    media.style.pointerEvents = 'none'\n    previewShell.appendChild(media)\n  } else {\n    const fallback = sourceEl.cloneNode(true) as HTMLElement\n    fallback\n      .querySelectorAll('[data-selection-indicator], [data-selection-mask]')\n      .forEach((node) => {\n        node.remove()\n      })\n    fallback.style.width = '100%'\n    fallback.style.height = '100%'\n    fallback.style.margin = '0'\n    fallback.style.borderRadius = `${DRAG_PREVIEW_RADIUS}px`\n    fallback.style.overflow = 'hidden'\n    fallback.style.pointerEvents = 'none'\n    previewShell.appendChild(fallback)\n  }\n\n  if (selectedCount > 1) {\n    // 多选时叠加数量角标，提示这是批量拖拽。\n    const badge = document.createElement('div')\n    badge.textContent = String(selectedCount)\n    badge.style.position = 'absolute'\n    badge.style.right = '6px'\n    badge.style.top = '6px'\n    badge.style.minWidth = '20px'\n    badge.style.height = '20px'\n    badge.style.padding = '0 6px'\n    badge.style.borderRadius = '999px'\n    badge.style.background = 'rgba(0, 0, 0, 0.78)'\n    badge.style.color = '#fff'\n    badge.style.fontSize = '12px'\n    badge.style.fontWeight = '700'\n    badge.style.display = 'flex'\n    badge.style.alignItems = 'center'\n    badge.style.justifyContent = 'center'\n    badge.style.lineHeight = '20px'\n    previewShell.appendChild(badge)\n  }\n\n  document.body.appendChild(previewShell)\n  activeDragPreview = previewShell\n  return previewShell\n}\n\nexport function readGalleryAssetDragIds(event: DragEvent): number[] {\n  const dataTransfer = event.dataTransfer\n  if (!dataTransfer) {\n    return []\n  }\n\n  // drop 阶段优先读自定义 MIME，格式最稳定。\n  const typedPayload = dataTransfer.getData(GALLERY_ASSET_DRAG_MIME)\n  if (typedPayload) {\n    return parseAssetIds(typedPayload)\n  }\n\n  // 兜底兼容：某些环境可能只保留 text/plain。\n  const fallbackPayload = dataTransfer.getData('text/plain')\n  return fallbackPayload\n    .split(',')\n    .map((value) => Number(value.trim()))\n    .filter((id) => Number.isInteger(id) && id > 0)\n}\n\nexport function hasGalleryAssetDragIds(event: DragEvent): boolean {\n  const dataTransfer = event.dataTransfer\n  if (!dataTransfer) {\n    return false\n  }\n\n  // dragover/dragenter 阶段很多浏览器拿不到 getData，只能靠 types 判断可放置性。\n  const types = Array.from(dataTransfer.types ?? [])\n  return types.includes(GALLERY_ASSET_DRAG_MIME) || types.includes('text/plain')\n}\n\nexport function useGalleryDragPayload() {\n  const store = useGalleryStore()\n\n  function resolveDragAssetIds(primaryAssetId: number): number[] {\n    // 交互语义：若拖拽项已在当前选择集里，则拖“整个选择集”；否则仅拖当前项。\n    const selectedIds = Array.from(store.selection.selectedIds)\n    if (selectedIds.length > 0 && store.selection.selectedIds.has(primaryAssetId)) {\n      return selectedIds\n    }\n    return [primaryAssetId]\n  }\n\n  function prepareAssetDrag(event: DragEvent, primaryAssetId: number): number[] {\n    const dataTransfer = event.dataTransfer\n    if (!dataTransfer) {\n      return []\n    }\n\n    const assetIds = resolveDragAssetIds(primaryAssetId)\n    const serialized = JSON.stringify(assetIds)\n    // 同时写入自定义 MIME 和 text/plain，提升跨组件/浏览器实现差异下的稳定性。\n    dataTransfer.effectAllowed = 'move'\n    dataTransfer.setData(GALLERY_ASSET_DRAG_MIME, serialized)\n    dataTransfer.setData('text/plain', assetIds.join(','))\n\n    // 拖拽源优先定位缩略图容器，让预览在不同视图下表现更一致。\n    const currentTarget = event.currentTarget\n    if (currentTarget instanceof HTMLElement) {\n      const previewSource =\n        (currentTarget.querySelector('[data-asset-thumbnail]') as HTMLElement | null) ??\n        currentTarget\n      cleanupDragPreview()\n      const preview = createDragPreview(previewSource, assetIds.length)\n      // setDragImage 的 x/y 是“光标热点”坐标：x 越大预览越往左，所以这里用负值让徽标往右。\n      const isListRowDrag = !!currentTarget.closest('[data-asset-list-row]')\n      dataTransfer.setDragImage(preview, isListRowDrag ? -12 : -1, -1)\n      window.addEventListener('dragend', cleanupDragPreview, { once: true })\n    }\n\n    return assetIds\n  }\n\n  return {\n    prepareAssetDrag,\n  }\n}\n"
  },
  {
    "path": "web/src/features/gallery/composables/useGalleryLayout.ts",
    "content": "import { computed } from 'vue'\nimport { useGalleryStore } from '../store'\n\n/**\n * Gallery 布局管理 Composable\n * 管理侧边栏和详情面板的显示状态\n */\nexport function useGalleryLayout() {\n  const store = useGalleryStore()\n\n  // ============= 布局状态 =============\n  const isSidebarOpen = computed(() => store.sidebarOpen)\n  const isDetailsOpen = computed(() => store.detailsOpen)\n  const leftSidebarSize = computed({\n    get: () => store.leftSidebarSize,\n    set: (size: string) => store.setLeftSidebarSize(size),\n  })\n  const rightDetailsSize = computed({\n    get: () => store.rightDetailsSize,\n    set: (size: string) => store.setRightDetailsSize(size),\n  })\n  const leftSidebarOpenSize = computed({\n    get: () => store.leftSidebarOpenSize,\n    set: (size: string) => store.setLeftSidebarOpenSize(size),\n  })\n  const rightDetailsOpenSize = computed({\n    get: () => store.rightDetailsOpenSize,\n    set: (size: string) => store.setRightDetailsOpenSize(size),\n  })\n\n  // ============= 布局操作 =============\n\n  /**\n   * 切换侧边栏显示/隐藏\n   */\n  function toggleSidebar() {\n    store.setSidebarOpen(!store.sidebarOpen)\n  }\n\n  /**\n   * 切换详情面板显示/隐藏\n   */\n  function toggleDetails() {\n    store.setDetailsOpen(!store.detailsOpen)\n  }\n\n  /**\n   * 设置侧边栏状态\n   */\n  function setSidebarOpen(open: boolean) {\n    store.setSidebarOpen(open)\n  }\n\n  /**\n   * 设置详情面板状态\n   */\n  function setDetailsOpen(open: boolean) {\n    store.setDetailsOpen(open)\n  }\n\n  return {\n    // 状态\n    isSidebarOpen,\n    isDetailsOpen,\n    leftSidebarSize,\n    rightDetailsSize,\n    leftSidebarOpenSize,\n    rightDetailsOpenSize,\n\n    // 操作\n    toggleSidebar,\n    toggleDetails,\n    setSidebarOpen,\n    setDetailsOpen,\n  }\n}\n"
  },
  {
    "path": "web/src/features/gallery/composables/useGalleryLightbox.ts",
    "content": "import { ref, watch } from 'vue'\nimport { galleryApi } from '../api'\nimport { useGalleryStore } from '../store'\nimport { useGallerySelection } from './useGallerySelection'\nimport type { Asset } from '../types'\n\ninterface ImageState {\n  status: 'idle' | 'loading' | 'loaded' | 'error'\n  url?: string\n}\n\nexport function useGalleryLightbox() {\n  const store = useGalleryStore()\n  const gallerySelection = useGallerySelection()\n\n  const imageStates = ref<Map<number, ImageState>>(new Map())\n  const loading = ref<Set<number>>(new Set())\n  const loaded = ref<Set<number>>(new Set())\n\n  function findLoadedAssetById(assetId: number): Asset | null {\n    for (const pageAssets of store.paginatedAssets.values()) {\n      const found = pageAssets.find((asset) => asset.id === assetId)\n      if (found) {\n        return found\n      }\n    }\n\n    return null\n  }\n\n  function isPreloadableImageAsset(asset: Asset | null): boolean {\n    // 视频交给 <video> 自己按需拉取分片；这里的图片预热只服务 still image 的秒开体验。\n    return asset?.type === 'photo' || asset?.type === 'live_photo'\n  }\n\n  /**\n   * 预加载单张图片\n   * 使用 new Image() 而非 fetch，是为了让浏览器将图片写入 HTTP 缓存，\n   * 后续 <img> 标签请求同一 URL 时可直接命中缓存，无需二次下载。\n   */\n  async function preloadImage(assetId: number): Promise<void> {\n    const asset = findLoadedAssetById(assetId)\n    if (!asset || !isPreloadableImageAsset(asset)) {\n      return\n    }\n\n    if (loaded.value.has(assetId) || loading.value.has(assetId)) {\n      return\n    }\n\n    loading.value.add(assetId)\n    const url = galleryApi.getAssetUrl(asset)\n    imageStates.value.set(assetId, { status: 'loading', url })\n\n    return new Promise((resolve, reject) => {\n      const img = new Image()\n      img.onload = () => {\n        loading.value.delete(assetId)\n        loaded.value.add(assetId)\n        imageStates.value.set(assetId, { status: 'loaded', url })\n        resolve()\n      }\n      img.onerror = () => {\n        loading.value.delete(assetId)\n        imageStates.value.set(assetId, { status: 'error', url })\n        reject(new Error(`Failed to load image: ${assetId}`))\n      }\n      img.src = url\n    })\n  }\n\n  /**\n   * 预加载当前图片及前后各 PRELOAD_RANGE 张。\n   * 策略：优先 await 当前帧尽快显示，再并行预加载相邻帧。\n   * video 类型整段跳过：由 <video> 自行分片请求，避免 Image 预拉全文件。\n   */\n  async function preloadRange(currentIndex: number) {\n    const PRELOAD_RANGE = 2\n    const totalCount = store.totalCount\n    const start = Math.max(0, currentIndex - PRELOAD_RANGE)\n    const end = Math.min(totalCount - 1, currentIndex + PRELOAD_RANGE)\n\n    const currentAsset = store.getAssetsInRange(currentIndex, currentIndex)[0]\n    if (!currentAsset) return\n\n    if (isPreloadableImageAsset(currentAsset)) {\n      try {\n        await preloadImage(currentAsset.id)\n      } catch (err) {\n        console.warn(\n          `Failed to preload current image [index=${currentIndex}, id=${currentAsset.id}]`,\n          err\n        )\n      }\n    }\n\n    const preloadPromises: Promise<void>[] = []\n    for (let offset = 1; offset <= PRELOAD_RANGE; offset++) {\n      if (currentIndex + offset <= end) {\n        const asset = store.getAssetsInRange(currentIndex + offset, currentIndex + offset)[0]\n        if (asset) {\n          preloadPromises.push(\n            preloadImage(asset.id).catch((err) => {\n              console.warn(\n                `Failed to preload image [index=${currentIndex + offset}, id=${asset.id}]`,\n                err\n              )\n            })\n          )\n        }\n      }\n\n      if (currentIndex - offset >= start) {\n        const asset = store.getAssetsInRange(currentIndex - offset, currentIndex - offset)[0]\n        if (asset) {\n          preloadPromises.push(\n            preloadImage(asset.id).catch((err) => {\n              console.warn(\n                `Failed to preload image [index=${currentIndex - offset}, id=${asset.id}]`,\n                err\n              )\n            })\n          )\n        }\n      }\n    }\n\n    Promise.allSettled(preloadPromises)\n  }\n\n  function getImageState(assetId: number): ImageState {\n    return imageStates.value.get(assetId) || { status: 'idle' }\n  }\n\n  async function syncLightboxSelection(index: number) {\n    if (store.selectedCount > 1) {\n      return gallerySelection.activateIndex(index, { syncDetails: true })\n    }\n\n    return gallerySelection.selectOnlyIndex(index)\n  }\n\n  watch(\n    () => store.selection.activeIndex,\n    (newIndex, oldIndex) => {\n      if (store.lightbox.isOpen && newIndex !== undefined) {\n        if (newIndex !== oldIndex) {\n          store.resetLightboxView()\n        }\n\n        preloadRange(newIndex).catch((err) => {\n          console.warn('Failed to preload lightbox range:', err)\n        })\n      }\n    },\n    { immediate: true }\n  )\n\n  async function openLightbox(index: number) {\n    const asset = await syncLightboxSelection(index)\n    if (!asset) {\n      return\n    }\n\n    store.openLightbox()\n    preloadRange(index).catch((err) => {\n      console.warn('Failed to preload lightbox range:', err)\n    })\n  }\n\n  function setImmersive(immersive: boolean) {\n    store.setLightboxImmersive(immersive)\n  }\n\n  function toggleImmersive() {\n    store.toggleLightboxImmersive()\n  }\n\n  function toggleFilmstrip() {\n    store.toggleLightboxFilmstrip()\n  }\n\n  function showFitMode() {\n    store.setLightboxFitMode('contain')\n  }\n\n  function showActualSize() {\n    store.setLightboxFitMode('actual')\n    store.setLightboxZoom(1)\n  }\n\n  function setActualZoom(zoom: number) {\n    store.setLightboxFitMode('actual')\n    store.setLightboxZoom(zoom)\n  }\n\n  function toggleFitActual() {\n    if (store.lightbox.fitMode === 'contain') {\n      showActualSize()\n      return\n    }\n\n    showFitMode()\n  }\n\n  function goToPrevious() {\n    const currentIndex = store.selection.activeIndex\n    if (currentIndex === undefined || currentIndex <= 0) {\n      return\n    }\n\n    void syncLightboxSelection(currentIndex - 1)\n  }\n\n  function goToNext() {\n    const currentIndex = store.selection.activeIndex\n    if (currentIndex === undefined || currentIndex >= store.totalCount - 1) {\n      return\n    }\n\n    void syncLightboxSelection(currentIndex + 1)\n  }\n\n  function goToIndex(index: number) {\n    void syncLightboxSelection(index)\n  }\n\n  function closeLightbox() {\n    store.closeLightbox()\n  }\n\n  return {\n    openLightbox,\n    closeLightbox,\n    setImmersive,\n    toggleImmersive,\n    toggleFilmstrip,\n    showFitMode,\n    showActualSize,\n    setActualZoom,\n    toggleFitActual,\n    goToPrevious,\n    goToNext,\n    goToIndex,\n    getImageState,\n    preloadImage,\n  }\n}\n"
  },
  {
    "path": "web/src/features/gallery/composables/useGallerySelection.ts",
    "content": "import { computed } from 'vue'\nimport { useGalleryData } from './useGalleryData'\nimport { useGalleryStore } from '../store'\nimport type { Asset } from '../types'\n\n/**\n * Gallery选择管理 Composable\n * 负责资产的选择交互逻辑：单选、多选、范围选择等\n */\nexport function useGallerySelection() {\n  const store = useGalleryStore()\n  const galleryData = useGalleryData()\n\n  const selectedIds = computed(() => store.selection.selectedIds)\n  const selectedCount = computed(() => store.selectedCount)\n  const hasSelection = computed(() => store.hasSelection)\n  const activeIndex = computed(() => store.selection.activeIndex)\n  const activeAssetId = computed(() => store.selection.activeAssetId)\n  const totalCount = computed(() => store.totalCount)\n  const perPage = computed(() => store.perPage)\n\n  function clearSelection() {\n    store.clearSelection()\n    store.clearDetailsFocus()\n  }\n\n  function normalizeIndex(index: number): number | undefined {\n    if (totalCount.value <= 0) {\n      return undefined\n    }\n\n    return Math.max(0, Math.min(index, totalCount.value - 1))\n  }\n\n  function findLoadedAssetById(id: number): Asset | undefined {\n    for (const pageAssets of store.paginatedAssets.values()) {\n      const asset = pageAssets.find((item) => item.id === id)\n      if (asset) {\n        return asset\n      }\n    }\n\n    return undefined\n  }\n\n  function getLoadedAssetByIndex(index: number): Asset | undefined {\n    const [asset] = store.getAssetsInRange(index, index)\n    return asset ?? undefined\n  }\n\n  async function ensureRangeLoaded(startIndex: number, endIndex: number) {\n    const startPage = Math.floor(startIndex / perPage.value) + 1\n    const endPage = Math.floor(endIndex / perPage.value) + 1\n    const loadPromises: Promise<void>[] = []\n\n    for (let page = startPage; page <= endPage; page++) {\n      if (!store.isPageLoaded(page)) {\n        loadPromises.push(galleryData.loadPage(page))\n      }\n    }\n\n    if (loadPromises.length > 0) {\n      await Promise.all(loadPromises)\n    }\n  }\n\n  async function getAssetByIndex(index: number): Promise<Asset | undefined> {\n    const normalizedIndex = normalizeIndex(index)\n    if (normalizedIndex === undefined) {\n      return undefined\n    }\n\n    await ensureRangeLoaded(normalizedIndex, normalizedIndex)\n    return getLoadedAssetByIndex(normalizedIndex)\n  }\n\n  function getPrimarySelectedAsset(preferredAsset?: Asset): Asset | undefined {\n    if (preferredAsset && selectedIds.value.has(preferredAsset.id)) {\n      return preferredAsset\n    }\n\n    const currentActiveAssetId = activeAssetId.value\n    if (currentActiveAssetId !== undefined) {\n      const activeAsset = findLoadedAssetById(currentActiveAssetId)\n      if (activeAsset && selectedIds.value.has(activeAsset.id)) {\n        return activeAsset\n      }\n    }\n\n    for (const id of selectedIds.value) {\n      const asset = findLoadedAssetById(id)\n      if (asset) {\n        return asset\n      }\n    }\n\n    return undefined\n  }\n\n  function syncDetailsFocusFromSelection(preferredAsset?: Asset) {\n    if (store.selectedCount > 1) {\n      store.setDetailsFocus({ type: 'batch' })\n      return\n    }\n\n    if (store.selectedCount === 1) {\n      const asset = getPrimarySelectedAsset(preferredAsset)\n      if (asset) {\n        store.setDetailsFocus({ type: 'asset', asset })\n        return\n      }\n    }\n\n    store.clearDetailsFocus()\n  }\n\n  async function activateIndex(index: number, options: { syncDetails?: boolean } = {}) {\n    const normalizedIndex = normalizeIndex(index)\n    if (normalizedIndex === undefined) {\n      return undefined\n    }\n\n    const asset = await getAssetByIndex(normalizedIndex)\n    if (!asset) {\n      return undefined\n    }\n\n    store.setActiveAsset(asset.id, normalizedIndex)\n    if (options.syncDetails) {\n      syncDetailsFocusFromSelection(asset)\n    }\n\n    return asset\n  }\n\n  async function selectOnlyIndex(index: number) {\n    const normalizedIndex = normalizeIndex(index)\n    if (normalizedIndex === undefined) {\n      return undefined\n    }\n\n    const asset = await getAssetByIndex(normalizedIndex)\n    if (!asset) {\n      return undefined\n    }\n\n    store.replaceSelection([asset.id])\n    store.setSelectionAnchor(normalizedIndex)\n    store.setActiveAsset(asset.id, normalizedIndex)\n    syncDetailsFocusFromSelection(asset)\n    return asset\n  }\n\n  async function toggleIndex(index: number) {\n    const normalizedIndex = normalizeIndex(index)\n    if (normalizedIndex === undefined) {\n      return undefined\n    }\n\n    const asset = await getAssetByIndex(normalizedIndex)\n    if (!asset) {\n      return undefined\n    }\n\n    const wasSelected = selectedIds.value.has(asset.id)\n    store.selectAsset(asset.id, !wasSelected, true)\n\n    if (!wasSelected) {\n      store.setSelectionAnchor(normalizedIndex)\n    } else if (store.selectedCount === 0) {\n      store.setSelectionAnchor(undefined)\n    }\n\n    store.setActiveAsset(asset.id, normalizedIndex)\n    syncDetailsFocusFromSelection(asset)\n    return asset\n  }\n\n  async function rangeSelectToIndex(index: number) {\n    const normalizedIndex = normalizeIndex(index)\n    if (normalizedIndex === undefined) {\n      return undefined\n    }\n\n    const anchorIndex = store.selection.anchorIndex\n    if (anchorIndex === undefined || anchorIndex < 0 || anchorIndex >= totalCount.value) {\n      return selectOnlyIndex(normalizedIndex)\n    }\n\n    const startIndex = Math.min(anchorIndex, normalizedIndex)\n    const endIndex = Math.max(anchorIndex, normalizedIndex)\n    await ensureRangeLoaded(startIndex, endIndex)\n\n    const selectedRangeIds: number[] = []\n    for (let currentIndex = startIndex; currentIndex <= endIndex; currentIndex++) {\n      const asset = getLoadedAssetByIndex(currentIndex)\n      if (asset) {\n        selectedRangeIds.push(asset.id)\n      }\n    }\n\n    if (selectedRangeIds.length === 0) {\n      return undefined\n    }\n\n    const targetAsset = getLoadedAssetByIndex(normalizedIndex)\n    store.replaceSelection(selectedRangeIds)\n    if (targetAsset) {\n      store.setActiveAsset(targetAsset.id, normalizedIndex)\n    } else {\n      store.setSelectionActive(normalizedIndex)\n    }\n    syncDetailsFocusFromSelection(targetAsset)\n    return targetAsset\n  }\n\n  async function selectAllCurrentQuery() {\n    const ids = await galleryData.queryCurrentAssetIds()\n    if (ids.length === 0) {\n      clearSelection()\n      store.clearActiveAsset()\n      return\n    }\n\n    store.replaceSelection(ids)\n\n    const currentActiveAssetId = store.selection.activeAssetId\n    const activeIndex =\n      currentActiveAssetId === undefined ? -1 : ids.findIndex((id) => id === currentActiveAssetId)\n    const targetIndex = activeIndex >= 0 ? activeIndex : 0\n    const targetAssetId = ids[targetIndex]!\n\n    store.setSelectionAnchor(targetIndex)\n    store.setActiveAsset(targetAssetId, targetIndex)\n\n    if (ids.length === 1) {\n      const asset = await getAssetByIndex(targetIndex)\n      syncDetailsFocusFromSelection(asset)\n      return\n    }\n\n    syncDetailsFocusFromSelection()\n  }\n\n  async function handleAssetClick(_asset: Asset, event: MouseEvent, index: number) {\n    if (event.shiftKey) {\n      await rangeSelectToIndex(index)\n      return\n    }\n\n    if (event.ctrlKey || event.metaKey) {\n      await toggleIndex(index)\n      return\n    }\n\n    await selectOnlyIndex(index)\n  }\n\n  function handleAssetDoubleClick(_asset: Asset, _event: MouseEvent) {\n    // The view layer decides what double click should do (for example, open the Lightbox).\n  }\n\n  async function prepareContextMenuForIndex(index: number) {\n    const normalizedIndex = normalizeIndex(index)\n    if (normalizedIndex === undefined) {\n      return undefined\n    }\n\n    const asset = await getAssetByIndex(normalizedIndex)\n    if (!asset) {\n      return undefined\n    }\n\n    if (selectedIds.value.has(asset.id)) {\n      store.setActiveAsset(asset.id, normalizedIndex)\n      syncDetailsFocusFromSelection(asset)\n      return asset\n    }\n\n    return selectOnlyIndex(normalizedIndex)\n  }\n\n  async function handleAssetContextMenu(asset: Asset, _event: MouseEvent, index?: number) {\n    if (index !== undefined) {\n      return prepareContextMenuForIndex(index)\n    }\n\n    if (!selectedIds.value.has(asset.id)) {\n      store.replaceSelection([asset.id])\n      store.setActiveAssetId(asset.id)\n      syncDetailsFocusFromSelection(asset)\n      return asset\n    }\n\n    store.setActiveAssetId(asset.id)\n    syncDetailsFocusFromSelection(asset)\n    return asset\n  }\n\n  function isAssetSelected(id: number): boolean {\n    return selectedIds.value.has(id)\n  }\n\n  return {\n    selectedIds,\n    selectedCount,\n    hasSelection,\n    activeIndex,\n    activeAssetId,\n\n    clearSelection,\n    activateIndex,\n    selectOnlyIndex,\n    toggleIndex,\n    rangeSelectToIndex,\n    selectAllCurrentQuery,\n    syncDetailsFocusFromSelection,\n    prepareContextMenuForIndex,\n\n    handleAssetClick,\n    handleAssetDoubleClick,\n    handleAssetContextMenu,\n\n    isAssetSelected,\n  }\n}\n"
  },
  {
    "path": "web/src/features/gallery/composables/useGallerySidebar.ts",
    "content": "import { computed } from 'vue'\nimport { useGalleryStore } from '../store'\nimport { galleryApi } from '../api'\nimport type { FolderTreeNode, TagTreeNode } from '../types'\n\n/**\n * Gallery 侧边栏管理 Composable\n * 管理侧边栏选择、筛选与标签操作逻辑\n * 树的展开状态已经收敛到 store 持久化，这里不再维护同名本地 UI 状态。\n * 数据获取由 useGalleryData 负责\n */\nexport function useGallerySidebar() {\n  const store = useGalleryStore()\n  const ROOT_FOLDER_ID = -1\n  const ROOT_TAG_ID = -1\n\n  // ============= 计算属性 =============\n\n  // 从 store 读取文件夹树数据\n  const folders = computed(() => store.folders)\n  const foldersLoading = computed(() => store.foldersLoading)\n  const foldersError = computed(() => store.foldersError)\n\n  // 从 store 读取标签树数据\n  const tags = computed(() => store.tags)\n  const tagsLoading = computed(() => store.tagsLoading)\n  const tagsError = computed(() => store.tagsError)\n\n  const selectedFolder = computed(() => {\n    if (store.filter.folderId) {\n      const folderId = Number(store.filter.folderId)\n      return isNaN(folderId) ? null : folderId\n    }\n    return null\n  })\n  const selectedTag = computed(() => {\n    if (store.filter.tagIds && store.filter.tagIds.length > 0) {\n      return store.filter.tagIds[0] ?? null\n    }\n    return null\n  })\n\n  // ============= 工具函数 =============\n\n  /**\n   * 递归查找文件夹节点\n   */\n  function findFolderById(folders: FolderTreeNode[], id: number): FolderTreeNode | null {\n    for (const folder of folders) {\n      if (folder.id === id) return folder\n      if (folder.children) {\n        const found = findFolderById(folder.children, id)\n        if (found) return found\n      }\n    }\n    return null\n  }\n\n  /**\n   * 递归查找标签节点\n   */\n  function findTagById(tags: TagTreeNode[], id: number): TagTreeNode | null {\n    for (const tag of tags) {\n      if (tag.id === id) return tag\n      if (tag.children) {\n        const found = findTagById(tag.children, id)\n        if (found) return found\n      }\n    }\n    return null\n  }\n\n  // ============= UI 交互操作方法 =============\n\n  /**\n   * 选择文件夹\n   */\n  function selectFolder(folderId: number, folderName: string) {\n    store.setFilter({ folderId: String(folderId) })\n\n    // 查找文件夹对象并设置详情面板\n    const folder = findFolderById(store.folders, folderId)\n    if (folder) {\n      store.setDetailsFocus({ type: 'folder', folder })\n    }\n\n    console.log('📁 选择文件夹:', folderName)\n  }\n\n  /**\n   * 清空文件夹筛选（保留其他筛选）\n   */\n  function clearFolderFilter() {\n    store.setFilter({ folderId: undefined })\n    store.setDetailsFocus({\n      type: 'folder',\n      folder: {\n        id: ROOT_FOLDER_ID,\n        path: '',\n        parentId: undefined,\n        name: '__root__',\n        displayName: '__root__',\n        coverAssetId: undefined,\n        sortOrder: 0,\n        isHidden: false,\n        createdAt: 0,\n        updatedAt: 0,\n        assetCount: store.foldersAssetTotalCount,\n        children: [],\n      },\n    })\n    console.log('📁 清空文件夹筛选')\n  }\n\n  /**\n   * 更新文件夹显示名称（仅应用内）\n   */\n  async function updateFolderDisplayName(id: number, displayName: string) {\n    try {\n      await galleryApi.updateFolderDisplayName({\n        id,\n        displayName,\n      })\n      console.log('✏️ 更新文件夹显示名称:', id)\n    } catch (error) {\n      console.error('Failed to update folder display name:', error)\n      throw error\n    }\n  }\n\n  /**\n   * 在资源管理器中打开文件夹\n   */\n  async function openFolderInExplorer(id: number) {\n    try {\n      await galleryApi.openFolderInExplorer(id)\n      console.log('📂 在资源管理器中打开文件夹:', id)\n    } catch (error) {\n      console.error('Failed to open folder in explorer:', error)\n      throw error\n    }\n  }\n\n  /**\n   * 移出根文件夹监听并清理索引\n   */\n  async function removeFolderWatch(id: number) {\n    try {\n      const result = await galleryApi.removeFolderWatch(id)\n      console.log('🗑️ 移出文件夹监听:', id)\n      return result\n    } catch (error) {\n      console.error('Failed to remove folder watch:', error)\n      throw error\n    }\n  }\n\n  /**\n   * 清空标签筛选（保留其他筛选）\n   */\n  function clearTagFilter() {\n    store.setFilter({ tagIds: [], tagMatchMode: 'any' })\n    store.setDetailsFocus({\n      type: 'tag',\n      tag: {\n        id: ROOT_TAG_ID,\n        name: '__root__',\n        parentId: undefined,\n        sortOrder: 0,\n        createdAt: 0,\n        updatedAt: 0,\n        assetCount: store.tagsAssetTotalCount,\n        children: [],\n      },\n    })\n    console.log('🏷️ 清空标签筛选')\n  }\n\n  /**\n   * 选择标签\n   */\n  function selectTag(tagId: number, tagName: string) {\n    // 检查是否点击了当前已选中的标签\n    if (selectedTag.value === tagId) {\n      store.setFilter({ tagIds: [], tagMatchMode: 'any' })\n      console.log('🏷️ 取消标签筛选:', tagName)\n    } else {\n      // 选中新标签\n      store.setFilter({ tagIds: [tagId], tagMatchMode: 'any' })\n\n      // 查找标签对象并设置详情面板\n      const tag = findTagById(store.tags, tagId)\n      if (tag) {\n        store.setDetailsFocus({ type: 'tag', tag })\n      }\n\n      console.log('🏷️ 选择标签:', tagName)\n    }\n  }\n\n  /**\n   * 选择\"所有媒体\"\n   */\n  function selectAllMedia() {\n    store.resetFilter()\n\n    // 清除详情面板焦点\n    store.clearDetailsFocus()\n\n    console.log('📷 显示所有媒体')\n  }\n\n  /**\n   * 加载标签树\n   */\n  async function loadTagTree() {\n    try {\n      store.setTagsLoading(true)\n      store.setTagsError(null)\n\n      const tagTree = await galleryApi.getTagTree()\n      store.setTags(tagTree)\n    } catch (error) {\n      console.error('Failed to load tag tree:', error)\n      store.setTagsError('加载标签树失败')\n      throw error\n    } finally {\n      store.setTagsLoading(false)\n    }\n  }\n\n  /**\n   * 创建标签\n   */\n  async function createTag(name: string, parentId?: number) {\n    try {\n      console.log('➕ 创建标签:', name, parentId ? `(父标签ID: ${parentId})` : '')\n\n      const result = await galleryApi.createTag({\n        name,\n        parentId,\n      })\n\n      // 重新加载标签树\n      await loadTagTree()\n\n      console.log('✅ 标签创建成功:', result.id)\n      return result.id\n    } catch (error) {\n      console.error('Failed to create tag:', error)\n      throw error\n    }\n  }\n\n  /**\n   * 更新标签\n   */\n  async function updateTag(id: number, name: string) {\n    try {\n      console.log('✏️ 更新标签:', id, name)\n\n      await galleryApi.updateTag({\n        id,\n        name,\n      })\n\n      // 重新加载标签树\n      await loadTagTree()\n\n      console.log('✅ 标签更新成功')\n    } catch (error) {\n      console.error('Failed to update tag:', error)\n      throw error\n    }\n  }\n\n  /**\n   * 删除标签\n   */\n  async function deleteTag(id: number) {\n    try {\n      console.log('🗑️ 删除标签:', id)\n\n      await galleryApi.deleteTag(id)\n\n      // 重新加载标签树\n      await loadTagTree()\n\n      // 如果删除的是当前选中的标签，清除筛选\n      if (selectedTag.value === id) {\n        store.setFilter({ tagIds: [], tagMatchMode: 'any' })\n        store.clearDetailsFocus()\n      }\n\n      console.log('✅ 标签删除成功')\n    } catch (error) {\n      console.error('Failed to delete tag:', error)\n      throw error\n    }\n  }\n\n  return {\n    // 状态（从 store 读取）\n    folders,\n    foldersLoading,\n    foldersError,\n    tags,\n    tagsLoading,\n    tagsError,\n    selectedFolder,\n    selectedTag,\n\n    // UI 交互操作\n    selectFolder,\n    clearFolderFilter,\n    updateFolderDisplayName,\n    openFolderInExplorer,\n    removeFolderWatch,\n    clearTagFilter,\n    selectTag,\n    selectAllMedia,\n    loadTagTree,\n    createTag,\n    updateTag,\n    deleteTag,\n  }\n}\n"
  },
  {
    "path": "web/src/features/gallery/composables/useGalleryView.ts",
    "content": "import { computed } from 'vue'\nimport { useGalleryStore } from '../store'\nimport type { ViewMode, SortBy, SortOrder, AssetFilter } from '../types'\n\n/**\n * 非线性映射\n * 使用平方函数，让小尺寸调整更细腻，大尺寸跳跃更大\n */\nfunction sliderToSize(position: number): number {\n  const min = 100\n  const max = 768\n  const normalized = position / 100\n\n  // 平方函数：前半段变化缓慢，后半段加速\n  const squared = Math.pow(normalized, 2)\n  const size = min + (max - min) * squared\n\n  return Math.round(size)\n}\n\n/**\n * 反向映射\n */\nfunction sizeToSlider(size: number): number {\n  const min = 100\n  const max = 768\n  const normalized = (size - min) / (max - min)\n\n  // 开平方（平方的逆运算）\n  const position = Math.sqrt(Math.max(0, Math.min(1, normalized))) * 100\n\n  return Math.round(position)\n}\n\n/**\n * Gallery视图管理 Composable\n * 负责视图模式切换、排序、筛选等视图相关逻辑\n */\nexport function useGalleryView() {\n  const store = useGalleryStore()\n\n  // ============= 视图状态 =============\n  const viewConfig = computed(() => store.viewConfig)\n  const viewMode = computed(() => store.viewConfig.mode)\n  const viewSize = computed(() => store.viewConfig.size)\n  const filter = computed(() => store.filter)\n  const sortBy = computed(() => store.sortBy)\n  const sortOrder = computed(() => store.sortOrder)\n  const includeSubfolders = computed(() => store.includeSubfolders)\n\n  // ============= 视图操作 =============\n\n  /**\n   * 设置视图模式\n   */\n  function setViewMode(mode: ViewMode) {\n    store.setViewConfig({ mode })\n    console.log('🎯 视图模式切换:', mode)\n  }\n\n  /**\n   * 设置视图大小（从 slider 位置设置）\n   * @param sliderPosition - Slider位置 (0-100)\n   */\n  function setViewSizeFromSlider(sliderPosition: number) {\n    const size = sliderToSize(sliderPosition)\n    const validSize = Math.max(100, Math.min(768, size))\n    store.setViewConfig({ size: validSize })\n    console.log('📏 视图大小调整:', validSize, 'px (slider:', sliderPosition, '%)')\n  }\n\n  /**\n   * 直接设置视图大小（从实际px值设置）\n   * @param size - 实际尼寸 (100-768px)\n   */\n  function setViewSize(size: number) {\n    const validSize = Math.max(100, Math.min(768, size))\n    store.setViewConfig({ size: validSize })\n    console.log('📏 视图大小调整:', validSize, 'px')\n  }\n\n  /**\n   * 获取当前尺寸对应的 slider 位置\n   */\n  function getSliderPosition(): number {\n    return sizeToSlider(viewSize.value)\n  }\n\n  /**\n   * 增加视图大小（键盘快捷键）\n   */\n  function increaseSize() {\n    const currentSlider = getSliderPosition()\n    if (currentSlider < 100) {\n      // 每次增加 5% slider 位置\n      setViewSizeFromSlider(Math.min(100, currentSlider + 5))\n    }\n  }\n\n  /**\n   * 减少视图大小（键盘快捷键）\n   */\n  function decreaseSize() {\n    const currentSlider = getSliderPosition()\n    if (currentSlider > 0) {\n      // 每次减少 5% slider 位置\n      setViewSizeFromSlider(Math.max(0, currentSlider - 5))\n    }\n  }\n\n  /**\n   * 设置排序\n   */\n  function setSorting(newSortBy: SortBy, newSortOrder: SortOrder) {\n    store.setSorting(newSortBy, newSortOrder)\n    console.log('🔄 排序设置:', { sortBy: newSortBy, sortOrder: newSortOrder })\n  }\n\n  /**\n   * 切换排序方向\n   */\n  function toggleSortOrder() {\n    const newOrder = sortOrder.value === 'asc' ? 'desc' : 'asc'\n    setSorting(sortBy.value, newOrder)\n  }\n\n  /**\n   * 设置筛选条件\n   */\n  function setFilter(newFilter: Partial<AssetFilter>) {\n    store.setFilter(newFilter)\n    console.log('🔍 筛选条件更新:', newFilter)\n  }\n\n  /**\n   * 清空筛选条件\n   */\n  function clearFilter() {\n    store.resetFilter()\n    console.log('🧹 筛选条件已清空')\n  }\n\n  /**\n   * 设置搜索关键词\n   */\n  function setSearchQuery(query: string) {\n    setFilter({ searchQuery: query.trim() || undefined })\n  }\n\n  /**\n   * 设置类型筛选\n   */\n  function setTypeFilter(type: AssetFilter['type']) {\n    setFilter({ type })\n  }\n\n  /**\n   * 设置颜色筛选\n   */\n  function setColorFilter(colorHex?: string) {\n    setFilter({ colorHex: colorHex || undefined })\n  }\n\n  /**\n   * 设置是否包含子文件夹\n   */\n  function setIncludeSubfolders(include: boolean) {\n    store.setIncludeSubfolders(include)\n    console.log('📁 包含子文件夹设置:', include)\n  }\n\n  // ============= 视图模式预设 =============\n\n  /**\n   * 网格视图预设\n   */\n  function setGridView() {\n    setViewMode('grid')\n  }\n\n  /**\n   * 瀑布流视图预设\n   */\n  function setMasonryView() {\n    setViewMode('masonry')\n  }\n\n  /**\n   * 列表视图预设\n   */\n  function setListView() {\n    setViewMode('list')\n  }\n\n  /**\n   * 自适应视图预设\n   */\n  function setAdaptiveView() {\n    setViewMode('adaptive')\n  }\n\n  return {\n    // 状态\n    viewConfig,\n    viewMode,\n    viewSize,\n    filter,\n    sortBy,\n    sortOrder,\n    includeSubfolders,\n\n    // 视图操作\n    setViewMode,\n    setViewSize,\n    setViewSizeFromSlider,\n    getSliderPosition,\n    increaseSize,\n    decreaseSize,\n\n    // 排序操作\n    setSorting,\n    toggleSortOrder,\n\n    // 筛选操作\n    setFilter,\n    clearFilter,\n    setSearchQuery,\n    setTypeFilter,\n    setColorFilter,\n    setIncludeSubfolders,\n\n    // 视图模式预设\n    setGridView,\n    setMasonryView,\n    setListView,\n    setAdaptiveView,\n  }\n}\n"
  },
  {
    "path": "web/src/features/gallery/composables/useGridVirtualizer.ts",
    "content": "import { computed, watch, ref, type Ref } from 'vue'\nimport { useVirtualizer } from '@tanstack/vue-virtual'\nimport { useGalleryStore } from '../store'\nimport { useGalleryData } from './useGalleryData'\nimport type { Asset } from '../types'\n\nexport interface UseGridVirtualizerOptions {\n  containerRef: Ref<HTMLElement | null>\n  columns: Ref<number>\n  containerWidth: Ref<number>\n}\n\nexport interface VirtualRow {\n  index: number\n  assets: (Asset | null)[]\n  start: number\n  size: number\n}\n\nexport function useGridVirtualizer(options: UseGridVirtualizerOptions) {\n  const { containerRef, columns, containerWidth } = options\n\n  const store = useGalleryStore()\n  const galleryData = useGalleryData()\n\n  const isTimelineMode = computed(() => store.isTimelineMode && store.viewConfig.mode === 'grid')\n  const totalCount = computed(() =>\n    isTimelineMode.value ? store.timelineTotalCount : store.totalCount\n  )\n\n  const totalRows = computed(() => Math.ceil(totalCount.value / columns.value))\n\n  const estimatedRowHeight = computed(() => {\n    const width = containerWidth.value || containerRef.value?.clientWidth || 0\n    if (width === 0) return 200\n\n    const gap = 16\n    const cardWidth = Math.floor((width - (columns.value - 1) * gap) / columns.value)\n    return cardWidth + gap\n  })\n\n  const virtualizer = useVirtualizer({\n    get count() {\n      return totalRows.value\n    },\n    getScrollElement: () => containerRef.value,\n    estimateSize: () => estimatedRowHeight.value,\n    paddingStart: 0,\n    paddingEnd: 16,\n    overscan: 10,\n  })\n\n  const virtualRows = ref<VirtualRow[]>([])\n  const loadingPages = ref<Set<number>>(new Set())\n\n  function syncVirtualRows(\n    items: ReturnType<typeof virtualizer.value.getVirtualItems>,\n    cols: number,\n    total: number\n  ) {\n    if (items.length === 0) {\n      virtualRows.value = []\n      store.setVisibleRange(undefined, undefined)\n      return\n    }\n\n    const firstVisibleRow = items[0]!\n    const lastVisibleRow = items[items.length - 1]!\n    store.setVisibleRange(\n      Math.max(0, firstVisibleRow.index * cols),\n      Math.min(total - 1, (lastVisibleRow.index + 1) * cols - 1)\n    )\n\n    virtualRows.value = items.map((virtualRow) => {\n      const startIndex = virtualRow.index * cols\n      const endIndex = Math.min(startIndex + cols - 1, total - 1)\n      return {\n        index: virtualRow.index,\n        assets: store.getAssetsInRange(startIndex, endIndex),\n        start: virtualRow.start,\n        size: virtualRow.size,\n      }\n    })\n  }\n\n  async function loadMissingData(\n    items: ReturnType<typeof virtualizer.value.getVirtualItems>,\n    cols: number,\n    total: number\n  ): Promise<void> {\n    if (items.length === 0) return\n\n    const visibleIndexes: number[] = []\n    items.forEach((item) => {\n      const start = item.index * cols\n      const end = Math.min(start + cols, total)\n      for (let i = start; i < end; i++) visibleIndexes.push(i)\n    })\n\n    const neededPages = new Set(visibleIndexes.map((idx) => Math.floor(idx / store.perPage) + 1))\n    const loadPromises: Promise<void>[] = []\n\n    neededPages.forEach((pageNum) => {\n      if (!store.isPageLoaded(pageNum) && !loadingPages.value.has(pageNum)) {\n        loadingPages.value.add(pageNum)\n        const loadPromise = galleryData.loadPage(pageNum).finally(() => {\n          loadingPages.value.delete(pageNum)\n        })\n        loadPromises.push(loadPromise)\n      }\n    })\n\n    if (loadPromises.length > 0) {\n      await Promise.all(loadPromises)\n    }\n  }\n\n  watch(\n    () => ({\n      items: virtualizer.value.getVirtualItems(),\n      columns: columns.value,\n      totalCount: totalCount.value,\n      paginatedAssetsVersion: store.paginatedAssetsVersion,\n    }),\n    async ({ items, columns: cols, totalCount: total }) => {\n      syncVirtualRows(items, cols, total)\n      await loadMissingData(items, cols, total)\n      syncVirtualRows(virtualizer.value.getVirtualItems(), columns.value, totalCount.value)\n    }\n  )\n\n  async function init() {\n    const hasReusableCache = store.totalCount > 0 && store.paginatedAssets.size > 0\n    const hasReusableTimelineCache = store.timelineBuckets.length > 0 && hasReusableCache\n\n    // 跨路由回到 gallery 时，优先复用已有分页缓存，避免先 replace 成 page1 再补回邻页。\n    if (isTimelineMode.value ? hasReusableTimelineCache : hasReusableCache) {\n      return\n    }\n\n    if (isTimelineMode.value) {\n      await galleryData.loadTimelineData()\n    } else {\n      await galleryData.loadAllAssets()\n    }\n  }\n\n  watch(estimatedRowHeight, () => {\n    if (virtualRows.value.length > 0) virtualizer.value.measure()\n  })\n\n  watch(columns, () => {\n    if (virtualRows.value.length > 0) virtualizer.value.measure()\n  })\n\n  return {\n    virtualizer,\n    virtualRows,\n    totalRows,\n    estimatedRowHeight,\n    init,\n  }\n}\n"
  },
  {
    "path": "web/src/features/gallery/composables/useHeroTransition.ts",
    "content": "import { ref } from 'vue'\n\nconst LIGHTBOX_TOOLBAR_HEIGHT = 61\nconst LIGHTBOX_FILMSTRIP_HEIGHT = 101\nconst LIGHTBOX_VIEWPORT_PADDING = 32\n\ninterface HeroSource {\n  rect: DOMRect\n  thumbnailUrl: string\n  width: number\n  height: number\n}\n\ninterface ReverseHeroSource {\n  fromRect: DOMRect\n  toRect: DOMRect\n  thumbnailUrl: string\n}\n\nlet pendingHero: HeroSource | null = null\nlet pendingReverseHero: ReverseHeroSource | null = null\nexport const heroAnimating = ref(false)\n\nexport function prepareHero(rect: DOMRect, thumbnailUrl: string, width: number, height: number) {\n  pendingHero = { rect, thumbnailUrl, width, height }\n  heroAnimating.value = true\n}\n\nexport function consumeHero(): HeroSource | null {\n  const h = pendingHero\n  pendingHero = null\n  return h\n}\n\nexport function prepareReverseHero(fromRect: DOMRect, toRect: DOMRect, thumbnailUrl: string) {\n  pendingReverseHero = { fromRect, toRect, thumbnailUrl }\n}\n\nexport function computeLightboxHeroRect(\n  containerRect: DOMRect | DOMRectReadOnly,\n  width: number,\n  height: number,\n  showFilmstrip: boolean\n) {\n  const contentWidth = Math.max(containerRect.width - LIGHTBOX_VIEWPORT_PADDING * 2, 1)\n  const filmstripHeight = showFilmstrip ? LIGHTBOX_FILMSTRIP_HEIGHT : 0\n  const contentHeight = Math.max(\n    containerRect.height -\n      LIGHTBOX_TOOLBAR_HEIGHT -\n      filmstripHeight -\n      LIGHTBOX_VIEWPORT_PADDING * 2,\n    1\n  )\n  const imageWidth = Math.max(width, 1)\n  const imageHeight = Math.max(height, 1)\n  const scale = Math.min(contentWidth / imageWidth, contentHeight / imageHeight, 1)\n  const targetWidth = imageWidth * scale\n  const targetHeight = imageHeight * scale\n  const targetX = containerRect.left + (containerRect.width - targetWidth) / 2\n  const targetY =\n    containerRect.top +\n    LIGHTBOX_TOOLBAR_HEIGHT +\n    (contentHeight + LIGHTBOX_VIEWPORT_PADDING * 2 - targetHeight) / 2\n\n  return new DOMRect(targetX, targetY, targetWidth, targetHeight)\n}\n\nexport function consumeReverseHero(): ReverseHeroSource | null {\n  const h = pendingReverseHero\n  pendingReverseHero = null\n  return h\n}\n\nexport function endHeroAnimation() {\n  heroAnimating.value = false\n}\n"
  },
  {
    "path": "web/src/features/gallery/composables/useListVirtualizer.ts",
    "content": "import { computed, ref, watch, type Ref } from 'vue'\nimport { useVirtualizer } from '@tanstack/vue-virtual'\nimport { useGalleryStore } from '../store'\nimport { useGalleryData } from './useGalleryData'\nimport type { Asset } from '../types'\n\n/**\n * 列表视图虚拟化 Composable\n * 将分页加载与虚拟滚动结合：可见区域之外的行不渲染 DOM，\n * 滚动进入视口时按需加载对应分页数据。\n */\n\nexport interface UseListVirtualizerOptions {\n  /** 滚动容器元素引用 */\n  containerRef: Ref<HTMLElement | null>\n  /** 每行高度（px），由外部根据 viewSize 动态计算后传入 */\n  rowHeight: Ref<number>\n  /** 虚拟滚动的顶部偏移量，用于跳过固定表头（px） */\n  scrollPaddingStart?: Ref<number>\n}\n\nexport interface VirtualListItem {\n  /** 资产在完整列表中的全局索引 */\n  index: number\n  /** 对应资产数据；未加载时为 null，渲染骨架屏 */\n  asset: Asset | null\n  /** 该行距滚动容器顶部的偏移量（px） */\n  start: number\n  /** 该行的实际高度（px） */\n  size: number\n}\n\nexport function useListVirtualizer(options: UseListVirtualizerOptions) {\n  const { containerRef, rowHeight, scrollPaddingStart } = options\n\n  const store = useGalleryStore()\n  const galleryData = useGalleryData()\n\n  const totalCount = computed(() => store.totalCount)\n  // 正在加载中的页码集合，防止同一页被并发重复请求\n  const loadingPages = ref<Set<number>>(new Set())\n  const virtualItems = ref<VirtualListItem[]>([])\n\n  const virtualizer = useVirtualizer<HTMLElement, HTMLElement>({\n    get count() {\n      return totalCount.value\n    },\n    getScrollElement: () => containerRef.value,\n    estimateSize: () => rowHeight.value,\n    paddingStart: 0,\n    paddingEnd: 16,\n    // scrollPaddingStart 使 scrollToIndex 时跳过固定表头，避免表头遮挡目标行\n    get scrollPaddingStart() {\n      return scrollPaddingStart?.value ?? 0\n    },\n    overscan: 14,\n  })\n\n  /**\n   * 将 virtualizer 返回的虚拟项映射为带资产数据的 VirtualListItem，\n   * 并通知 store 更新当前可见索引范围（用于分页预判）。\n   */\n  function syncVirtualItems(\n    items: ReturnType<typeof virtualizer.value.getVirtualItems>,\n    total: number\n  ) {\n    if (items.length === 0) {\n      virtualItems.value = []\n      store.setVisibleRange(undefined, undefined)\n      return\n    }\n\n    const indexes = items.map((item) => item.index)\n    store.setVisibleRange(\n      Math.max(0, Math.min(...indexes)),\n      Math.min(Math.max(0, total - 1), Math.max(...indexes))\n    )\n\n    virtualItems.value = items.map((item) => {\n      const [asset] = store.getAssetsInRange(item.index, item.index)\n      return {\n        index: item.index,\n        asset: asset ?? null,\n        start: item.start,\n        size: item.size,\n      }\n    })\n  }\n\n  /**\n   * 根据当前可见项，找出尚未加载的分页并并发请求。\n   * 通过 loadingPages 集合避免同一页被重复触发。\n   */\n  async function loadMissingData(\n    items: ReturnType<typeof virtualizer.value.getVirtualItems>\n  ): Promise<void> {\n    if (items.length === 0) return\n\n    const neededPages = new Set(items.map((item) => Math.floor(item.index / store.perPage) + 1))\n    const loadPromises: Promise<void>[] = []\n\n    neededPages.forEach((pageNum) => {\n      if (!store.isPageLoaded(pageNum) && !loadingPages.value.has(pageNum)) {\n        loadingPages.value.add(pageNum)\n        const loadPromise = galleryData.loadPage(pageNum).finally(() => {\n          loadingPages.value.delete(pageNum)\n        })\n        loadPromises.push(loadPromise)\n      }\n    })\n\n    if (loadPromises.length > 0) {\n      await Promise.all(loadPromises)\n    }\n  }\n\n  /** 初始化：加载总数及第一页数据 */\n  async function init() {\n    const hasReusableCache = store.totalCount > 0 && store.paginatedAssets.size > 0\n    // 从其它页面切回 gallery 时，若缓存已可用则不做全量刷新，避免 loadedPages 抖动。\n    if (hasReusableCache) {\n      return\n    }\n\n    await galleryData.loadAllAssets()\n  }\n\n  // 监听虚拟项变化（滚动、数据更新、行高变化）：\n  // 1. 先用现有数据立即渲染（未加载项显示骨架屏）\n  // 2. 异步加载缺失分页\n  // 3. 加载完成后再次同步，将骨架屏替换为真实内容\n  watch(\n    () => ({\n      items: virtualizer.value.getVirtualItems(),\n      totalCount: totalCount.value,\n      paginatedAssetsVersion: store.paginatedAssetsVersion,\n      rowHeight: rowHeight.value,\n    }),\n    async ({ items, totalCount: total }) => {\n      syncVirtualItems(items, total)\n      await loadMissingData(items)\n      syncVirtualItems(virtualizer.value.getVirtualItems(), totalCount.value)\n    }\n  )\n\n  // 行高变化时通知 virtualizer 重新测量，避免布局错位\n  watch(rowHeight, () => {\n    if (virtualItems.value.length > 0) virtualizer.value.measure()\n  })\n\n  return {\n    virtualizer,\n    virtualItems,\n    init,\n  }\n}\n"
  },
  {
    "path": "web/src/features/gallery/composables/useMasonryVirtualizer.ts",
    "content": "import { computed, ref, watch, type Ref } from 'vue'\nimport { useVirtualizer } from '@tanstack/vue-virtual'\nimport { useGalleryStore } from '../store'\nimport { useGalleryData } from './useGalleryData'\nimport type { Asset } from '../types'\n\n/**\n * 瀑布流视图虚拟化 Composable\n * 基于 @tanstack/vue-virtual 的 lanes（多列）模式实现瀑布流布局，\n * 结合分页加载：仅渲染可见列的 DOM，数据按需加载。\n *\n * 与 useGridVirtualizer 的区别：\n * - Grid 按行分组，每行高度固定（正方形卡片）\n * - Masonry 每项独立入列，高度由图片原始宽高比决定，需要实测（measureElement）\n */\n\n/** 列间距（px），与 CSS gap 保持一致 */\nconst MASONRY_GAP = 16\n\nexport interface UseMasonryVirtualizerOptions {\n  /** 滚动容器元素引用 */\n  containerRef: Ref<HTMLElement | null>\n  /** 当前列数，由外部根据容器宽度和 viewSize 动态计算 */\n  columns: Ref<number>\n  /** 容器宽度（px），用于计算单列宽度 */\n  containerWidth: Ref<number>\n}\n\nexport interface VirtualMasonryItem {\n  /** 资产在完整列表中的全局索引 */\n  index: number\n  /** 对应资产数据；未加载时为 null，渲染骨架屏 */\n  asset: Asset | null\n  /** 该项在其所在列中的顶部偏移量（px） */\n  start: number\n  /** 该项的实际高度（px），由 measureElement 实测后更新 */\n  size: number\n  /** 所在列的索引（0-based） */\n  lane: number\n}\n\n/**\n * 根据资产原始尺寸和列宽计算卡片渲染高度。\n * 未知尺寸时回退为正方形（columnWidth），最小高度为 80px。\n */\nfunction getAssetHeight(asset: Asset | null, columnWidth: number): number {\n  if (columnWidth <= 0) return 200\n\n  if (!asset || !asset.width || !asset.height || asset.width <= 0 || asset.height <= 0) {\n    return columnWidth\n  }\n\n  return Math.max(80, Math.round((columnWidth * asset.height) / asset.width))\n}\n\nexport function useMasonryVirtualizer(options: UseMasonryVirtualizerOptions) {\n  const { containerRef, columns, containerWidth } = options\n\n  const store = useGalleryStore()\n  const galleryData = useGalleryData()\n\n  const totalCount = computed(() => store.totalCount)\n  // 正在加载中的页码集合，防止同一页被并发重复请求\n  const loadingPages = ref<Set<number>>(new Set())\n  const virtualItems = ref<VirtualMasonryItem[]>([])\n\n  // 单列宽度 = (容器宽度 - 列间总间距) / 列数\n  const columnWidth = computed(() => {\n    const width = containerWidth.value || containerRef.value?.clientWidth || 0\n    if (width <= 0) return store.viewConfig.size\n\n    const totalGap = Math.max(0, columns.value - 1) * MASONRY_GAP\n    return Math.max(1, Math.floor((width - totalGap) / Math.max(columns.value, 1)))\n  })\n\n  const itemStartByIndex = computed(() => {\n    const startMap = new Map<number, number>()\n    const laneCount = Math.max(1, columns.value)\n    const laneHeights = new Array<number>(laneCount).fill(0)\n\n    for (let index = 0; index < totalCount.value; index++) {\n      let lane = 0\n      for (let i = 1; i < laneCount; i++) {\n        const laneHeight = laneHeights[i] ?? 0\n        const currentMinLaneHeight = laneHeights[lane] ?? 0\n        if (laneHeight < currentMinLaneHeight) {\n          lane = i\n        }\n      }\n\n      const start = laneHeights[lane] ?? 0\n      startMap.set(index, start)\n\n      const [asset] = store.getAssetsInRange(index, index)\n      const itemHeight = getAssetHeight(asset ?? null, columnWidth.value)\n      laneHeights[lane] = start + itemHeight + MASONRY_GAP\n    }\n\n    return startMap\n  })\n\n  // 预估高度：virtualizer 初次渲染时使用，后续由 measureElement 实测覆盖\n  function estimateSize(index: number): number {\n    const [asset] = store.getAssetsInRange(index, index)\n    return getAssetHeight(asset ?? null, columnWidth.value)\n  }\n\n  const virtualizer = useVirtualizer<HTMLElement, HTMLElement>({\n    get count() {\n      return totalCount.value\n    },\n    getScrollElement: () => containerRef.value,\n    estimateSize,\n    // measureElement 实测已渲染 DOM 的真实高度，修正瀑布流列布局\n    measureElement: (element) => element.getBoundingClientRect().height,\n    gap: MASONRY_GAP,\n    get lanes() {\n      return columns.value\n    },\n    paddingStart: 0,\n    paddingEnd: 16,\n    overscan: 12,\n  })\n\n  /** 计算指定列的水平偏移量（translateX），用于定位绝对布局的卡片 */\n  function getLaneOffset(lane: number): number {\n    return lane * (columnWidth.value + MASONRY_GAP)\n  }\n\n  /**\n   * 将 virtualizer 返回的虚拟项映射为带资产数据的 VirtualMasonryItem，\n   * 并通知 store 更新当前可见索引范围。\n   */\n  function syncVirtualItems(\n    items: ReturnType<typeof virtualizer.value.getVirtualItems>,\n    total: number\n  ) {\n    if (items.length === 0) {\n      virtualItems.value = []\n      store.setVisibleRange(undefined, undefined)\n      return\n    }\n\n    const indexes = items.map((item) => item.index)\n    store.setVisibleRange(\n      Math.max(0, Math.min(...indexes)),\n      Math.min(Math.max(0, total - 1), Math.max(...indexes))\n    )\n\n    virtualItems.value = items.map((item) => {\n      const [asset] = store.getAssetsInRange(item.index, item.index)\n      return {\n        index: item.index,\n        asset: asset ?? null,\n        start: item.start,\n        size: item.size,\n        lane: item.lane,\n      }\n    })\n  }\n\n  /**\n   * 根据当前可见项，找出尚未加载的分页并并发请求。\n   * 通过 loadingPages 集合避免同一页被重复触发。\n   */\n  async function loadMissingData(\n    items: ReturnType<typeof virtualizer.value.getVirtualItems>\n  ): Promise<void> {\n    if (items.length === 0) return\n\n    const neededPages = new Set(items.map((item) => Math.floor(item.index / store.perPage) + 1))\n    const loadPromises: Promise<void>[] = []\n\n    neededPages.forEach((pageNum) => {\n      if (!store.isPageLoaded(pageNum) && !loadingPages.value.has(pageNum)) {\n        loadingPages.value.add(pageNum)\n        const loadPromise = galleryData.loadPage(pageNum).finally(() => {\n          loadingPages.value.delete(pageNum)\n        })\n        loadPromises.push(loadPromise)\n      }\n    })\n\n    if (loadPromises.length > 0) {\n      await Promise.all(loadPromises)\n    }\n  }\n\n  /** 初始化：按当前排序语义加载对应数据源（时间线/普通） */\n  async function init() {\n    const hasReusableCache = store.totalCount > 0 && store.paginatedAssets.size > 0\n    const hasReusableTimelineCache = store.timelineBuckets.length > 0 && hasReusableCache\n\n    if (store.isTimelineMode ? hasReusableTimelineCache : hasReusableCache) {\n      return\n    }\n\n    if (store.isTimelineMode) {\n      await galleryData.loadTimelineData()\n      return\n    }\n\n    await galleryData.loadAllAssets()\n  }\n\n  /**\n   * 供模板 :ref 回调使用，将真实 DOM 元素交给 virtualizer 实测高度。\n   * 瀑布流布局依赖实测高度来精确定位各列，不可省略。\n   */\n  function measureElement(element: Element | null) {\n    virtualizer.value.measureElement(element as HTMLElement | null)\n  }\n\n  // 监听虚拟项变化（滚动、数据更新、列数/列宽变化）：\n  // 1. 先用现有数据立即渲染（未加载项显示骨架屏）\n  // 2. 异步加载缺失分页\n  // 3. 加载完成后再次同步，将骨架屏替换为真实内容\n  watch(\n    () => ({\n      items: virtualizer.value.getVirtualItems(),\n      totalCount: totalCount.value,\n      paginatedAssetsVersion: store.paginatedAssetsVersion,\n      columns: columns.value,\n      width: columnWidth.value,\n    }),\n    async ({ items, totalCount: total }) => {\n      syncVirtualItems(items, total)\n      await loadMissingData(items)\n      syncVirtualItems(virtualizer.value.getVirtualItems(), totalCount.value)\n    }\n  )\n\n  // 列数或列宽变化时重新测量，避免布局错位\n  watch([columns, columnWidth], () => {\n    if (virtualItems.value.length > 0) virtualizer.value.measure()\n  })\n\n  return {\n    virtualizer,\n    virtualItems,\n    columnWidth,\n    gap: MASONRY_GAP,\n    init,\n    measureElement,\n    getLaneOffset,\n    getAssetHeight: (asset: Asset | null) => getAssetHeight(asset, columnWidth.value),\n    itemStartByIndex,\n  }\n}\n"
  },
  {
    "path": "web/src/features/gallery/index.ts",
    "content": "// Gallery 功能模块统一导出\n// Feature-First 架构 - 高内聚模块\n\n// 类型定义\nexport type * from './types'\n\n// API 层\nexport { galleryApi } from './api'\n\n// 状态管理\nexport { useGalleryStore } from './store'\n\n// 业务逻辑层 (Composables)\nexport { useGalleryData, useGalleryView } from './composables'\n\n// 路由配置\nexport { default as routes } from './routes'\n"
  },
  {
    "path": "web/src/features/gallery/pages/GalleryPage.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed, ref, watch, onMounted, onUnmounted } from 'vue'\nimport { useDebounceFn } from '@vueuse/core'\nimport { on as onRpc, off as offRpc } from '@/core/rpc'\nimport { Split } from '@/components/ui/split'\nimport { useGalleryLayout } from '../composables'\nimport { useGalleryData } from '../composables/useGalleryData'\nimport { useSettingsStore } from '@/features/settings/store'\nimport GallerySidebar from '../components/shell/GallerySidebar.vue'\nimport GalleryViewer from '../components/shell/GalleryViewer.vue'\nimport GalleryDetails from '../components/shell/GalleryDetails.vue'\nimport InfinityNikkiGuidePanel from '../components/infinity_nikki/InfinityNikkiGuidePanel.vue'\n\nconst LEFT_MIN_SIZE = '180px'\nconst RIGHT_MIN_SIZE = '180px'\nconst LEFT_MIN_PX = 180\nconst RIGHT_MIN_PX = 180\nconst COLLAPSED_SIZE = '0px'\nconst COLLAPSE_TRIGGER_PX = 40\nconst GALLERY_REFRESH_DEBOUNCE_MS = 400\n\n// 使用布局管理\nconst {\n  isSidebarOpen,\n  isDetailsOpen,\n  leftSidebarSize,\n  rightDetailsSize,\n  leftSidebarOpenSize,\n  rightDetailsOpenSize,\n  setSidebarOpen,\n  setDetailsOpen,\n} = useGalleryLayout()\nconst galleryData = useGalleryData()\nconst settingsStore = useSettingsStore()\n\n// 引导面板显示条件（无限暖暖拓展已启用、配置了游戏目录、且尚未看过引导）\nconst showInfinityNikkiGuide = computed(() => {\n  const config = settingsStore.appSettings.extensions.infinityNikki\n  return config.enable && Boolean(config.gameDir.trim()) && !config.galleryGuideSeen\n})\n\nlet isUnmounted = false\nlet refreshInFlight = false\nlet refreshQueued = false\n\ntype SplitSize = number | string\n\nfunction parsePixelSize(size: SplitSize): number | null {\n  if (typeof size !== 'string' || !size.trim().endsWith('px')) {\n    return null\n  }\n\n  const value = parseFloat(size)\n  return Number.isFinite(value) ? value : null\n}\n\nfunction normalizeOpenSize(size: SplitSize, minPx: number, fallback: string): string {\n  const px = parsePixelSize(size)\n  if (px === null || px <= 0) {\n    return fallback\n  }\n  return `${Math.max(minPx, Math.round(px))}px`\n}\n\nfunction isAtMinSize(size: SplitSize, minPx: number): boolean {\n  const px = parsePixelSize(size)\n  if (px === null) {\n    return false\n  }\n  return px <= minPx + 0.5\n}\n\nconst leftMinSize = computed(() => (isSidebarOpen.value ? LEFT_MIN_SIZE : COLLAPSED_SIZE))\nconst rightMinSize = computed(() => (isDetailsOpen.value ? RIGHT_MIN_SIZE : COLLAPSED_SIZE))\n\nwatch(\n  isSidebarOpen,\n  (open) => {\n    if (open) {\n      const restoredSize = normalizeOpenSize(leftSidebarOpenSize.value, LEFT_MIN_PX, '200px')\n      leftSidebarSize.value = restoredSize\n      leftSidebarOpenSize.value = restoredSize\n      return\n    }\n\n    const currentSize = parsePixelSize(leftSidebarSize.value)\n    if (currentSize !== null && currentSize > 0) {\n      leftSidebarOpenSize.value = `${Math.round(currentSize)}px`\n    }\n    leftSidebarSize.value = COLLAPSED_SIZE\n  },\n  { immediate: true }\n)\n\nwatch(\n  isDetailsOpen,\n  (open) => {\n    if (open) {\n      const restoredSize = normalizeOpenSize(rightDetailsOpenSize.value, RIGHT_MIN_PX, '256px')\n      rightDetailsSize.value = restoredSize\n      rightDetailsOpenSize.value = restoredSize\n      return\n    }\n\n    const currentSize = parsePixelSize(rightDetailsSize.value)\n    if (currentSize !== null && currentSize > 0) {\n      rightDetailsOpenSize.value = `${Math.round(currentSize)}px`\n    }\n    rightDetailsSize.value = COLLAPSED_SIZE\n  },\n  { immediate: true }\n)\n\nwatch(leftSidebarSize, (size) => {\n  if (!isSidebarOpen.value) {\n    if (size !== COLLAPSED_SIZE) {\n      leftSidebarSize.value = COLLAPSED_SIZE\n    }\n    return\n  }\n\n  const px = parsePixelSize(size)\n  if (px !== null && px >= LEFT_MIN_PX) {\n    leftSidebarOpenSize.value = `${Math.round(px)}px`\n  }\n})\n\nwatch(rightDetailsSize, (size) => {\n  if (!isDetailsOpen.value) {\n    if (size !== COLLAPSED_SIZE) {\n      rightDetailsSize.value = COLLAPSED_SIZE\n    }\n    return\n  }\n\n  const px = parsePixelSize(size)\n  if (px !== null && px >= RIGHT_MIN_PX) {\n    rightDetailsOpenSize.value = `${Math.round(px)}px`\n  }\n})\n\n// 拖拽起点记录（用于判断“超出最小宽度阈值后收起”）\nconst leftDragStartX = ref<number | null>(null)\nconst leftDragStartSizePx = ref<number | null>(null)\nconst leftCollapsedByDrag = ref(false)\nconst rightDragStartX = ref<number | null>(null)\nconst rightDragStartSizePx = ref<number | null>(null)\nconst rightCollapsedByDrag = ref(false)\n\nfunction handleLeftDragStart(e: MouseEvent) {\n  leftDragStartX.value = e.clientX\n  leftDragStartSizePx.value = parsePixelSize(leftSidebarSize.value)\n  leftCollapsedByDrag.value = false\n}\n\nfunction handleLeftDrag(e: MouseEvent) {\n  if (leftDragStartX.value === null || leftDragStartSizePx.value === null) {\n    return\n  }\n\n  const moveToCollapseDirection = leftDragStartX.value - e.clientX\n  const distanceToMin = Math.max(0, leftDragStartSizePx.value - LEFT_MIN_PX)\n  const overshoot = moveToCollapseDirection - distanceToMin\n  const currentDragSizePx = leftDragStartSizePx.value - moveToCollapseDirection\n\n  if (isSidebarOpen.value) {\n    if (isAtMinSize(leftSidebarSize.value, LEFT_MIN_PX) && overshoot >= COLLAPSE_TRIGGER_PX) {\n      setSidebarOpen(false)\n      leftCollapsedByDrag.value = true\n      leftSidebarSize.value = COLLAPSED_SIZE\n    }\n    return\n  }\n\n  if (!leftCollapsedByDrag.value) {\n    return\n  }\n\n  // 同一次拖拽中，如果回拉到收起阈值以内，自动恢复显示\n  if (overshoot <= COLLAPSE_TRIGGER_PX) {\n    const restoredSize = `${Math.max(LEFT_MIN_PX, Math.round(currentDragSizePx))}px`\n    leftSidebarOpenSize.value = restoredSize\n    setSidebarOpen(true)\n    leftCollapsedByDrag.value = false\n  }\n}\n\nfunction handleRightDragStart(e: MouseEvent) {\n  rightDragStartX.value = e.clientX\n  rightDragStartSizePx.value = parsePixelSize(rightDetailsSize.value)\n  rightCollapsedByDrag.value = false\n}\n\nfunction handleRightDrag(e: MouseEvent) {\n  if (rightDragStartX.value === null || rightDragStartSizePx.value === null) {\n    return\n  }\n\n  const moveToCollapseDirection = e.clientX - rightDragStartX.value\n  const distanceToMin = Math.max(0, rightDragStartSizePx.value - RIGHT_MIN_PX)\n  const overshoot = moveToCollapseDirection - distanceToMin\n  const currentDragSizePx = rightDragStartSizePx.value - moveToCollapseDirection\n\n  if (isDetailsOpen.value) {\n    if (isAtMinSize(rightDetailsSize.value, RIGHT_MIN_PX) && overshoot >= COLLAPSE_TRIGGER_PX) {\n      setDetailsOpen(false)\n      rightCollapsedByDrag.value = true\n      rightDetailsSize.value = COLLAPSED_SIZE\n    }\n    return\n  }\n\n  if (!rightCollapsedByDrag.value) {\n    return\n  }\n\n  // 同一次拖拽中，如果回拉到收起阈值以内，自动恢复显示\n  if (overshoot <= COLLAPSE_TRIGGER_PX) {\n    const restoredSize = `${Math.max(RIGHT_MIN_PX, Math.round(currentDragSizePx))}px`\n    rightDetailsOpenSize.value = restoredSize\n    setDetailsOpen(true)\n    rightCollapsedByDrag.value = false\n  }\n}\n\nfunction handleLeftDragEnd() {\n  leftDragStartX.value = null\n  leftDragStartSizePx.value = null\n  leftCollapsedByDrag.value = false\n}\n\nfunction handleRightDragEnd() {\n  rightDragStartX.value = null\n  rightDragStartSizePx.value = null\n  rightCollapsedByDrag.value = false\n}\n\nasync function refreshGalleryFromNotification() {\n  if (refreshInFlight) {\n    refreshQueued = true\n    return\n  }\n\n  refreshInFlight = true\n  do {\n    refreshQueued = false\n    try {\n      await galleryData.loadFolderTree({ silent: true })\n      await galleryData.refreshCurrentQuery()\n    } catch (error) {\n      console.error('Failed to refresh gallery after notification:', error)\n    }\n  } while (refreshQueued)\n\n  refreshInFlight = false\n}\n\nconst scheduleGalleryRefresh = useDebounceFn(() => {\n  if (isUnmounted) {\n    return\n  }\n  void refreshGalleryFromNotification()\n}, GALLERY_REFRESH_DEBOUNCE_MS)\n\nconst galleryChangedHandler = () => {\n  void scheduleGalleryRefresh()\n}\n\nonMounted(() => {\n  onRpc('gallery.changed', galleryChangedHandler)\n})\n\nonUnmounted(() => {\n  isUnmounted = true\n  offRpc('gallery.changed', galleryChangedHandler)\n})\n</script>\n\n<template>\n  <!-- 引导面板：占满整个画廊区域，隐藏三栏布局 -->\n  <InfinityNikkiGuidePanel v-if=\"showInfinityNikkiGuide\" />\n\n  <!-- 左中右三区域布局 -->\n  <div v-else class=\"h-full w-full border-t\">\n    <!-- 第一层分割：左侧 + (中右) -->\n    <Split\n      v-model:size=\"leftSidebarSize\"\n      direction=\"horizontal\"\n      :min=\"leftMinSize\"\n      :max=\"0.3\"\n      :disabled=\"!isSidebarOpen\"\n      @drag-start=\"handleLeftDragStart\"\n      @drag=\"handleLeftDrag\"\n      @drag-end=\"handleLeftDragEnd\"\n    >\n      <!-- 左侧区域 - 侧边栏 -->\n      <template #1>\n        <GallerySidebar v-if=\"isSidebarOpen\" />\n      </template>\n\n      <!-- 中右区域 -->\n      <template #2>\n        <!-- 第二层分割：中间 + 右侧 -->\n        <Split\n          v-model:size=\"rightDetailsSize\"\n          direction=\"horizontal\"\n          reverse\n          :min=\"rightMinSize\"\n          :max=\"0.5\"\n          :disabled=\"!isDetailsOpen\"\n          @drag-start=\"handleRightDragStart\"\n          @drag=\"handleRightDrag\"\n          @drag-end=\"handleRightDragEnd\"\n        >\n          <!-- 中间区域 - 主要内容 -->\n          <template #1>\n            <GalleryViewer />\n          </template>\n\n          <!-- 右侧区域 - 详情面板 -->\n          <template #2>\n            <GalleryDetails v-if=\"isDetailsOpen\" />\n          </template>\n        </Split>\n      </template>\n    </Split>\n  </div>\n</template>\n"
  },
  {
    "path": "web/src/features/gallery/queryFilters.ts",
    "content": "import type { AssetFilter, QueryAssetsFilters } from './types'\n\nexport function toQueryAssetsFilters(\n  filter: AssetFilter,\n  includeSubfolders: boolean\n): QueryAssetsFilters {\n  return {\n    folderId: filter.folderId ? Number(filter.folderId) : undefined,\n    includeSubfolders,\n    type: filter.type,\n    search: filter.searchQuery,\n    rating: filter.rating,\n    reviewFlag: filter.reviewFlag,\n    tagIds: filter.tagIds,\n    tagMatchMode: filter.tagMatchMode,\n    clothIds: filter.clothIds,\n    clothMatchMode: filter.clothMatchMode,\n    colorHexes: filter.colorHex ? [filter.colorHex] : undefined,\n  }\n}\n"
  },
  {
    "path": "web/src/features/gallery/routes.ts",
    "content": "import type { RouteRecordRaw } from 'vue-router'\n\n// Gallery 路由配置（与根路由共用，见 web/src/router/index.ts）\nexport default [\n  {\n    path: '/gallery',\n    name: 'gallery',\n    component: () => import('./pages/GalleryPage.vue'),\n    meta: {\n      title: '图库',\n      icon: 'gallery',\n      requiresAuth: false,\n    },\n  },\n] as RouteRecordRaw[]\n"
  },
  {
    "path": "web/src/features/gallery/store/index.ts",
    "content": "import { defineStore } from 'pinia'\nimport { useStorage } from '@vueuse/core'\nimport { computed } from 'vue'\nimport { createDefaultGallerySettings, GALLERY_SETTINGS_STORAGE_KEY } from './persistence'\nimport { createQuerySlice } from './querySlice'\nimport { createNavigationSlice } from './navigationSlice'\nimport { createInteractionSlice } from './interactionSlice'\nimport { createLayoutSlice } from './layoutSlice'\n\n/**\n * Gallery Pinia Store\n *\n * 数据流设计:\n * - Store 是单一数据来源，组件应直接从这里读取状态\n * - Composable 只负责协调 API 调用和调用 Store Actions\n *\n * 拆分说明:\n * - index.ts 只负责“装配”，不承载具体业务细节\n * - query/navigation/layout/interaction 四个 slice 负责各自领域状态\n * - 对外仍然暴露同一个 store，避免调用方心智负担上升\n */\nexport const useGalleryStore = defineStore('gallery', () => {\n  // 持久化层保持单一根对象：gallerySettings 是 gallery 偏好的唯一入口。\n  const gallerySettings = useStorage(\n    GALLERY_SETTINGS_STORAGE_KEY,\n    createDefaultGallerySettings(),\n    localStorage\n  )\n\n  // 查询与缓存层：负责结果集、分页、时间线、并发刷新版本。\n  const querySlice = createQuerySlice()\n  // 导航与筛选层：负责 folder/tag 树、展开态、排序筛选、视图配置。\n  const navigationSlice = createNavigationSlice({\n    settings: gallerySettings,\n  })\n  // 布局层：负责三栏布局的完整真相源与本地持久化。\n  const layoutSlice = createLayoutSlice({\n    settings: gallerySettings,\n  })\n  // 交互层：负责 selection/lightbox/details focus，并依赖 query 结果做就地 patch。\n  const interactionSlice = createInteractionSlice({\n    totalCount: querySlice.totalCount,\n    paginatedAssets: querySlice.paginatedAssets,\n    bumpPaginatedAssetsVersion: () => {\n      querySlice.paginatedAssetsVersion.value += 1\n    },\n  })\n\n  // timeline mode 本质是按 createdAt 排序的特化表现，保持历史语义兼容。\n  const isTimelineMode = computed(() => navigationSlice.sortBy.value === 'createdAt')\n\n  // reset 只保留一个入口，避免“某个 slice 忘记重置”的问题。\n  function reset() {\n    querySlice.resetQueryState()\n    navigationSlice.resetNavigationState()\n    layoutSlice.resetLayoutState()\n    interactionSlice.resetInteractionState()\n  }\n\n  return {\n    // 展开顺序代表心智顺序：先数据查询，再导航筛选，再布局，最后交互 UI。\n    ...querySlice,\n    ...navigationSlice,\n    ...layoutSlice,\n    ...interactionSlice,\n    gallerySettings,\n    isTimelineMode,\n    reset,\n  }\n})\n"
  },
  {
    "path": "web/src/features/gallery/store/interactionSlice.ts",
    "content": "import { reactive, computed, type Ref } from 'vue'\nimport type { Asset, SelectionState, LightboxState, DetailsPanelFocus } from '../types'\nimport { LIGHTBOX_MAX_ZOOM, LIGHTBOX_MIN_ZOOM } from './persistence'\n\ninterface InteractionSliceArgs {\n  // interaction 依赖 query 结果集做局部 patch 与 lightbox 边界裁剪。\n  totalCount: Ref<number>\n  paginatedAssets: Ref<Map<number, Asset[]>>\n  // 由 index 注入，确保 interaction 修改缓存后能触发观察者更新。\n  bumpPaginatedAssetsVersion: () => void\n}\n\n/**\n * Interaction Slice\n *\n * 关注点:\n * - 用户交互态：selection / lightbox / details focus\n * - 与交互强耦合的“本地即时 patch”（评分、描述）\n */\nexport function createInteractionSlice(args: InteractionSliceArgs) {\n  const { totalCount, paginatedAssets, bumpPaginatedAssetsVersion } = args\n\n  // selection 的语义分层：\n  // - selectedIds: 多选集合\n  // - anchorIndex: 范围选择锚点\n  // - activeIndex: 当前结果集位置（可能随查询变化失效）\n  // - activeAssetId: 当前聚焦资产身份（跨查询变化保持语义）\n  const selection = reactive<SelectionState>({\n    selectedIds: new Set<number>(),\n    anchorIndex: undefined,\n    // activeIndex 是当前结果集里的位置缓存；筛选/排序变化后可能失效，需要重定位。\n    activeIndex: undefined,\n    // activeAssetId 才是“当前聚焦资产”的身份真相源，用来跨结果集变化保留语义。\n    activeAssetId: undefined,\n  })\n\n  // lightbox 只保存“展示控制态”，真实资产数据仍来自 query 缓存。\n  const lightbox = reactive<LightboxState>({\n    isOpen: false,\n    isClosing: false,\n    isImmersive: false,\n    showFilmstrip: true,\n    zoom: 1.0,\n    fitMode: 'contain',\n  })\n\n  // detailsPanel 是右侧详情“当前焦点类型”的真相源。\n  const detailsPanel: DetailsPanelFocus = reactive<DetailsPanelFocus>({\n    type: 'none',\n  })\n\n  const selectedCount = computed(() => selection.selectedIds.size)\n  const hasSelection = computed(() => selectedCount.value > 0)\n\n  function patchAssetsReviewState(\n    assetIds: number[],\n    updates: Partial<Pick<Asset, 'rating' | 'reviewFlag'>>\n  ) {\n    if (assetIds.length === 0) {\n      return\n    }\n\n    const assetIdSet = new Set(assetIds)\n\n    // 审片操作是高频交互，这里直接原地 patch 当前已加载页面，避免每次按键都整页重载。\n    // 注意：这里只保证“已加载页”即时一致，其余页由后续查询刷新补齐。\n    paginatedAssets.value.forEach((pageAssets, pageNum) => {\n      let hasPageChange = false\n      const nextPageAssets = pageAssets.map((asset) => {\n        if (!assetIdSet.has(asset.id)) {\n          return asset\n        }\n\n        hasPageChange = true\n        return {\n          ...asset,\n          ...(updates.rating !== undefined ? { rating: updates.rating } : {}),\n          ...(updates.reviewFlag !== undefined ? { reviewFlag: updates.reviewFlag } : {}),\n        }\n      })\n\n      if (hasPageChange) {\n        paginatedAssets.value.set(pageNum, nextPageAssets)\n        // Map 原地更新后手动 bump，确保依赖 paginatedAssetsVersion 的渲染及时更新。\n        bumpPaginatedAssetsVersion()\n      }\n    })\n\n    // 详情面板若正聚焦某个被 patch 的资产，也要同步更新，避免左右视图状态分叉。\n    if (detailsPanel.type === 'asset' && assetIdSet.has(detailsPanel.asset.id)) {\n      detailsPanel.asset = {\n        ...detailsPanel.asset,\n        ...(updates.rating !== undefined ? { rating: updates.rating } : {}),\n        ...(updates.reviewFlag !== undefined ? { reviewFlag: updates.reviewFlag } : {}),\n      }\n    }\n  }\n\n  function patchAssetDescription(assetId: number, description?: string) {\n    paginatedAssets.value.forEach((pageAssets, pageNum) => {\n      let hasPageChange = false\n      const nextPageAssets = pageAssets.map((asset) => {\n        if (asset.id !== assetId) {\n          return asset\n        }\n\n        hasPageChange = true\n        return {\n          ...asset,\n          description,\n        }\n      })\n\n      if (hasPageChange) {\n        paginatedAssets.value.set(pageNum, nextPageAssets)\n        bumpPaginatedAssetsVersion()\n      }\n    })\n\n    if (detailsPanel.type === 'asset' && detailsPanel.asset.id === assetId) {\n      detailsPanel.asset = {\n        ...detailsPanel.asset,\n        description,\n      }\n    }\n  }\n\n  function selectAsset(id: number, selected: boolean, multi = false) {\n    // 单选默认清空旧选中；多选由调用方显式传 multi=true。\n    if (!multi) {\n      selection.selectedIds.clear()\n    }\n\n    if (selected) {\n      selection.selectedIds.add(id)\n    } else {\n      selection.selectedIds.delete(id)\n    }\n  }\n\n  function clearSelection() {\n    selection.selectedIds.clear()\n    selection.anchorIndex = undefined\n  }\n\n  function replaceSelection(ids: number[]) {\n    selection.selectedIds.clear()\n    ids.forEach((id) => selection.selectedIds.add(id))\n  }\n\n  function setSelectionAnchor(index?: number) {\n    selection.anchorIndex = index\n  }\n\n  function setSelectionActive(index?: number) {\n    selection.activeIndex = index\n  }\n\n  function setActiveAsset(assetId: number, index?: number) {\n    // 始终同时更新 identity 与 position，降低调用方维护一致性的负担。\n    selection.activeAssetId = assetId\n    selection.activeIndex = index\n  }\n\n  function setActiveAssetId(assetId?: number) {\n    selection.activeAssetId = assetId\n  }\n\n  function clearActiveAsset() {\n    selection.activeAssetId = undefined\n    selection.activeIndex = undefined\n  }\n\n  function resetLightboxView() {\n    // 只重置缩放与适配，不改变 open/close 状态。\n    lightbox.zoom = 1.0\n    lightbox.fitMode = 'contain'\n  }\n\n  function openLightbox() {\n    resetLightboxView()\n    lightbox.isClosing = false\n    lightbox.isOpen = true\n  }\n\n  function setLightboxClosing(closing: boolean) {\n    lightbox.isClosing = closing\n  }\n\n  function closeLightbox() {\n    lightbox.isOpen = false\n    lightbox.isClosing = false\n    lightbox.isImmersive = false\n    resetLightboxView()\n  }\n\n  function goToLightboxIndex(index: number) {\n    if (lightbox.isOpen) {\n      // 统一做边界裁剪，防止调用方传入越界索引。\n      const validIndex = Math.max(0, Math.min(index, totalCount.value - 1))\n      selection.activeIndex = validIndex\n    }\n  }\n\n  function goToPreviousLightbox() {\n    const currentIndex = selection.activeIndex ?? 0\n    if (lightbox.isOpen && currentIndex > 0) {\n      selection.activeIndex = currentIndex - 1\n    }\n  }\n\n  function goToNextLightbox() {\n    const currentIndex = selection.activeIndex ?? 0\n    if (lightbox.isOpen && currentIndex < totalCount.value - 1) {\n      selection.activeIndex = currentIndex + 1\n    }\n  }\n\n  function setLightboxImmersive(immersive: boolean) {\n    lightbox.isImmersive = immersive\n  }\n\n  function toggleLightboxImmersive() {\n    setLightboxImmersive(!lightbox.isImmersive)\n  }\n\n  function toggleLightboxFilmstrip() {\n    lightbox.showFilmstrip = !lightbox.showFilmstrip\n  }\n\n  function setLightboxZoom(zoom: number) {\n    lightbox.zoom = Math.max(LIGHTBOX_MIN_ZOOM, Math.min(LIGHTBOX_MAX_ZOOM, zoom))\n  }\n\n  function setLightboxFitMode(mode: LightboxState['fitMode']) {\n    lightbox.fitMode = mode\n  }\n\n  function setDetailsFocus(focus: DetailsPanelFocus) {\n    // 用 assign 保持 reactive 对象引用不变，减少依赖断联风险。\n    Object.assign(detailsPanel, focus)\n  }\n\n  function clearDetailsFocus() {\n    detailsPanel.type = 'none'\n  }\n\n  function resetInteractionState() {\n    // 只重置交互域。query/navigation 的 reset 由主入口统一调度。\n    selection.selectedIds.clear()\n    selection.anchorIndex = undefined\n    selection.activeIndex = undefined\n    selection.activeAssetId = undefined\n\n    lightbox.isOpen = false\n    lightbox.isClosing = false\n    lightbox.isImmersive = false\n    lightbox.showFilmstrip = true\n    resetLightboxView()\n\n    clearDetailsFocus()\n  }\n\n  return {\n    selection,\n    lightbox,\n    detailsPanel,\n    selectedCount,\n    hasSelection,\n    patchAssetsReviewState,\n    patchAssetDescription,\n    selectAsset,\n    clearSelection,\n    replaceSelection,\n    setSelectionAnchor,\n    setSelectionActive,\n    setActiveAsset,\n    setActiveAssetId,\n    clearActiveAsset,\n    resetLightboxView,\n    openLightbox,\n    setLightboxClosing,\n    closeLightbox,\n    goToLightboxIndex,\n    goToPreviousLightbox,\n    goToNextLightbox,\n    setLightboxImmersive,\n    toggleLightboxImmersive,\n    toggleLightboxFilmstrip,\n    setLightboxZoom,\n    setLightboxFitMode,\n    setDetailsFocus,\n    clearDetailsFocus,\n    resetInteractionState,\n  }\n}\n"
  },
  {
    "path": "web/src/features/gallery/store/layoutSlice.ts",
    "content": "import { computed, type Ref } from 'vue'\nimport { createDefaultGallerySettings, type GallerySettings } from './persistence'\n\ninterface LayoutSliceArgs {\n  settings: Ref<GallerySettings>\n}\n\n/**\n * Layout Slice\n *\n * 关注点:\n * - 画廊三栏布局的真相源\n * - 左右面板开关状态与持久化宽度\n */\nexport function createLayoutSlice(args: LayoutSliceArgs) {\n  const { settings } = args\n  const sidebarOpen = computed(() => settings.value.layout.sidebarOpen)\n  const detailsOpen = computed(() => settings.value.layout.detailsOpen)\n  const leftSidebarSize = computed(() => settings.value.layout.leftSidebarSize)\n  const rightDetailsSize = computed(() => settings.value.layout.rightDetailsSize)\n  const leftSidebarOpenSize = computed(() => settings.value.layout.leftSidebarOpenSize)\n  const rightDetailsOpenSize = computed(() => settings.value.layout.rightDetailsOpenSize)\n\n  function setSidebarOpen(open: boolean) {\n    settings.value.layout.sidebarOpen = open\n  }\n\n  function setDetailsOpen(open: boolean) {\n    settings.value.layout.detailsOpen = open\n  }\n\n  function setLeftSidebarSize(size: string) {\n    settings.value.layout.leftSidebarSize = size\n  }\n\n  function setRightDetailsSize(size: string) {\n    settings.value.layout.rightDetailsSize = size\n  }\n\n  function setLeftSidebarOpenSize(size: string) {\n    settings.value.layout.leftSidebarOpenSize = size\n  }\n\n  function setRightDetailsOpenSize(size: string) {\n    settings.value.layout.rightDetailsOpenSize = size\n  }\n\n  function resetLayoutState() {\n    const defaults = createDefaultGallerySettings()\n    settings.value.layout = { ...defaults.layout }\n  }\n\n  return {\n    sidebarOpen,\n    detailsOpen,\n    leftSidebarSize,\n    rightDetailsSize,\n    leftSidebarOpenSize,\n    rightDetailsOpenSize,\n    setSidebarOpen,\n    setDetailsOpen,\n    setLeftSidebarSize,\n    setRightDetailsSize,\n    setLeftSidebarOpenSize,\n    setRightDetailsOpenSize,\n    resetLayoutState,\n  }\n}\n"
  },
  {
    "path": "web/src/features/gallery/store/navigationSlice.ts",
    "content": "import { ref, computed, type Ref } from 'vue'\nimport type {\n  ViewConfig,\n  AssetFilter,\n  SortBy,\n  SortOrder,\n  FolderTreeNode,\n  TagTreeNode,\n} from '../types'\nimport { collectTreeIds, createDefaultGallerySettings, type GallerySettings } from './persistence'\n\ninterface NavigationSliceArgs {\n  // 由入口注入 gallerySettings 根对象，slice 自行消费所需子域。\n  settings: Ref<GallerySettings>\n}\n\n/**\n * Navigation Slice\n *\n * 关注点:\n * - 左侧导航相关状态（folder/tag tree + expanded）\n * - 查询维度（filter/sort/includeSubfolders）\n * - 视图配置（view mode / size）\n */\nexport function createNavigationSlice(args: NavigationSliceArgs) {\n  const { settings } = args\n\n  // 导航数据树（由 useGalleryData/useGallerySidebar 驱动加载）。\n  const folders = ref<FolderTreeNode[]>([])\n  const foldersLoading = ref(false)\n  const foldersError = ref<string | null>(null)\n\n  const tags = ref<TagTreeNode[]>([])\n  const tagsLoading = ref(false)\n  const tagsError = ref<string | null>(null)\n\n  const viewConfig = ref<ViewConfig>({\n    mode: settings.value.view.mode,\n    size: settings.value.view.size,\n  })\n  // 这里的 filter 是“查询输入”，不是 UI 临时态。\n  const filter = ref<AssetFilter>({})\n  const sortBy = ref<SortBy>('createdAt')\n  const sortOrder = ref<SortOrder>('desc')\n  const includeSubfolders = ref(true)\n\n  // 便于侧边栏“全部文件夹/全部标签”节点展示统计。\n  const foldersAssetTotalCount = computed(() => {\n    return folders.value.reduce((sum, folder) => sum + folder.assetCount, 0)\n  })\n\n  const tagsAssetTotalCount = computed(() => {\n    return tags.value.reduce((sum, tag) => sum + tag.assetCount, 0)\n  })\n\n  // 读取层用 Set 提升查询效率，持久化层仍保持数组便于序列化。\n  const expandedFolderIdSet = computed(() => new Set(settings.value.navigation.expandedFolderIds))\n  const expandedTagIdSet = computed(() => new Set(settings.value.navigation.expandedTagIds))\n\n  function setFolderExpanded(folderId: number, expanded: boolean) {\n    const nextExpandedIds = new Set(settings.value.navigation.expandedFolderIds)\n    if (expanded) {\n      nextExpandedIds.add(folderId)\n    } else {\n      nextExpandedIds.delete(folderId)\n    }\n    settings.value.navigation.expandedFolderIds = [...nextExpandedIds]\n  }\n\n  function toggleFolderExpanded(folderId: number) {\n    setFolderExpanded(folderId, !isFolderExpanded(folderId))\n  }\n\n  function isFolderExpanded(folderId: number): boolean {\n    return expandedFolderIdSet.value.has(folderId)\n  }\n\n  function setTagExpanded(tagId: number, expanded: boolean) {\n    const nextExpandedIds = new Set(settings.value.navigation.expandedTagIds)\n    if (expanded) {\n      nextExpandedIds.add(tagId)\n    } else {\n      nextExpandedIds.delete(tagId)\n    }\n    settings.value.navigation.expandedTagIds = [...nextExpandedIds]\n  }\n\n  function toggleTagExpanded(tagId: number) {\n    setTagExpanded(tagId, !isTagExpanded(tagId))\n  }\n\n  function isTagExpanded(tagId: number): boolean {\n    return expandedTagIdSet.value.has(tagId)\n  }\n\n  function setFolders(newFolders: FolderTreeNode[]) {\n    folders.value = newFolders\n\n    // 树重载后把已不存在的节点 id 裁掉，避免 localStorage 越积越脏。\n    const validFolderIds = new Set(collectTreeIds(newFolders))\n    settings.value.navigation.expandedFolderIds =\n      settings.value.navigation.expandedFolderIds.filter((id) => validFolderIds.has(id))\n  }\n\n  function setFoldersLoading(loading: boolean) {\n    foldersLoading.value = loading\n  }\n\n  function setFoldersError(errorMessage: string | null) {\n    foldersError.value = errorMessage\n  }\n\n  function setTags(newTags: TagTreeNode[]) {\n    tags.value = newTags\n\n    // 标签树和文件夹树一样，刷新后同步清理失效展开状态。\n    const validTagIds = new Set(collectTreeIds(newTags))\n    settings.value.navigation.expandedTagIds = settings.value.navigation.expandedTagIds.filter(\n      (id) => validTagIds.has(id)\n    )\n  }\n\n  function setTagsLoading(loading: boolean) {\n    tagsLoading.value = loading\n  }\n\n  function setTagsError(errorMessage: string | null) {\n    tagsError.value = errorMessage\n  }\n\n  function setViewConfig(config: Partial<ViewConfig>) {\n    const merged = { ...viewConfig.value, ...config }\n    viewConfig.value = merged\n    settings.value.view.size = viewConfig.value.size\n    settings.value.view.mode = viewConfig.value.mode\n  }\n\n  function setFilter(newFilter: Partial<AssetFilter>) {\n    // 使用 merge 而不是覆盖，允许 composable 按字段增量更新筛选项。\n    filter.value = { ...filter.value, ...newFilter }\n  }\n\n  function resetFilter() {\n    filter.value = {}\n  }\n\n  function setSorting(newSortBy: SortBy, newSortOrder: SortOrder) {\n    sortBy.value = newSortBy\n    sortOrder.value = newSortOrder\n  }\n\n  function setIncludeSubfolders(include: boolean) {\n    includeSubfolders.value = include\n  }\n\n  function resetNavigationState() {\n    const defaults = createDefaultGallerySettings()\n\n    // 只重置导航/筛选域，不触碰查询缓存与交互态。\n    folders.value = []\n    foldersLoading.value = false\n    foldersError.value = null\n\n    tags.value = []\n    tagsLoading.value = false\n    tagsError.value = null\n\n    settings.value.navigation.expandedFolderIds = []\n    settings.value.navigation.expandedTagIds = []\n\n    viewConfig.value = { ...defaults.view }\n    settings.value.view = { ...defaults.view }\n    resetFilter()\n    sortBy.value = 'createdAt'\n    sortOrder.value = 'desc'\n    includeSubfolders.value = true\n  }\n\n  return {\n    folders,\n    foldersLoading,\n    foldersError,\n    tags,\n    tagsLoading,\n    tagsError,\n    viewConfig,\n    filter,\n    sortBy,\n    sortOrder,\n    includeSubfolders,\n    foldersAssetTotalCount,\n    tagsAssetTotalCount,\n    setFolderExpanded,\n    toggleFolderExpanded,\n    isFolderExpanded,\n    setTagExpanded,\n    toggleTagExpanded,\n    isTagExpanded,\n    setFolders,\n    setFoldersLoading,\n    setFoldersError,\n    setTags,\n    setTagsLoading,\n    setTagsError,\n    setViewConfig,\n    setFilter,\n    resetFilter,\n    setSorting,\n    setIncludeSubfolders,\n    resetNavigationState,\n  }\n}\n"
  },
  {
    "path": "web/src/features/gallery/store/persistence.ts",
    "content": "import type { ViewMode } from '../types'\n\nexport interface GallerySettings {\n  view: {\n    size: number\n    mode: ViewMode\n  }\n  navigation: {\n    expandedFolderIds: number[]\n    expandedTagIds: number[]\n  }\n  layout: {\n    sidebarOpen: boolean\n    detailsOpen: boolean\n    leftSidebarSize: string\n    rightDetailsSize: string\n    leftSidebarOpenSize: string\n    rightDetailsOpenSize: string\n  }\n}\n\nexport const GALLERY_SETTINGS_STORAGE_KEY = 'spinningmomo.gallery.settings'\n\nexport function createDefaultGallerySettings(): GallerySettings {\n  return {\n    view: {\n      size: 128,\n      mode: 'grid' satisfies ViewMode,\n    },\n    navigation: {\n      expandedFolderIds: [],\n      expandedTagIds: [],\n    },\n    layout: {\n      sidebarOpen: true,\n      detailsOpen: true,\n      leftSidebarSize: '200px',\n      rightDetailsSize: '256px',\n      leftSidebarOpenSize: '200px',\n      rightDetailsOpenSize: '256px',\n    },\n  }\n}\n\nexport const LIGHTBOX_MIN_ZOOM = 0.05\nexport const LIGHTBOX_MAX_ZOOM = 5\n\nexport function collectTreeIds<T extends { id: number; children: T[] }>(nodes: T[]): number[] {\n  const ids: number[] = []\n\n  for (const node of nodes) {\n    ids.push(node.id)\n    ids.push(...collectTreeIds(node.children))\n  }\n\n  return ids\n}\n"
  },
  {
    "path": "web/src/features/gallery/store/querySlice.ts",
    "content": "import { ref, reactive } from 'vue'\nimport type { Asset, TimelineBucket } from '../types'\n\n/**\n * Query Slice\n *\n * 关注点:\n * - 面向“当前查询结果集”的状态，不关心具体交互（selection/lightbox）\n * - 提供分页缓存与时间线元数据，供虚拟列表和数据加载层复用\n */\nexport function createQuerySlice() {\n  // 全局查询态：加载、错误、总量、当前页。\n  const isLoading = ref(false)\n  const isInitialLoading = ref(false)\n  const error = ref<string | null>(null)\n  const totalCount = ref(0)\n  const currentPage = ref(1)\n  const hasNextPage = ref(false)\n  const isRefreshing = ref(false)\n  const queryVersion = ref(0)\n\n  // ============= 分页缓存状态（普通模式使用） =============\n  // paginatedAssets: 只缓存已加载页，避免一次性加载全量资产。\n  const paginatedAssets = ref<Map<number, Asset[]>>(new Map()) // key: pageNumber\n  // 显式 version 用于触发依赖 Map 结构变化的更新（Map 原地改动不总能被外层感知）。\n  const paginatedAssetsVersion = ref(0)\n  const perPage = ref(100) // 每页数量\n  // 可见区由虚拟列表回传，用于决定“优先加载哪些页”。\n  const visibleRange = reactive<{\n    startIndex?: number\n    endIndex?: number\n  }>({\n    startIndex: undefined,\n    endIndex: undefined,\n  })\n\n  // ============= 时间线数据状态 =============\n  // buckets 仅保存月份元信息，不保存每月资产明细（明细仍走分页查询）。\n  const timelineBuckets = ref<TimelineBucket[]>([])\n  const timelineTotalCount = ref(0)\n\n  function setLoading(loading: boolean) {\n    isLoading.value = loading\n  }\n\n  function setInitialLoading(loading: boolean) {\n    isInitialLoading.value = loading\n  }\n\n  function setError(errorMessage: string | null) {\n    error.value = errorMessage\n  }\n\n  function setPagination(total: number, page: number, hasNext: boolean) {\n    totalCount.value = total\n    currentPage.value = page\n    hasNextPage.value = hasNext\n  }\n\n  function beginQueryRefresh(): number {\n    // 版本号是并发请求裁决核心：后到的旧响应不会覆盖新查询。\n    queryVersion.value += 1\n    isRefreshing.value = true\n    return queryVersion.value\n  }\n\n  function finishQueryRefresh(version: number) {\n    if (queryVersion.value === version) {\n      isRefreshing.value = false\n    }\n  }\n\n  function isQueryVersionCurrent(version: number): boolean {\n    return queryVersion.value === version\n  }\n\n  function setPerPage(count: number) {\n    perPage.value = count\n  }\n\n  /**\n   * 获取指定索引范围的资产（用于虚拟列表）\n   * @returns Asset[] | null[] - null 表示该位置数据未加载\n   */\n  function getAssetsInRange(startIndex: number, endIndex: number): (Asset | null)[] {\n    const result: (Asset | null)[] = []\n\n    for (let i = startIndex; i <= endIndex; i++) {\n      // 全局索引 -> 页号 + 页内索引\n      const pageNum = Math.floor(i / perPage.value) + 1\n      const indexInPage = i % perPage.value\n      const page = paginatedAssets.value.get(pageNum)\n\n      result.push(page?.[indexInPage] ?? null)\n    }\n\n    return result\n  }\n\n  function isPageLoaded(pageNum: number): boolean {\n    return paginatedAssets.value.has(pageNum)\n  }\n\n  function setPageAssets(pageNum: number, pageAssets: Asset[]) {\n    paginatedAssets.value.set(pageNum, pageAssets)\n    paginatedAssetsVersion.value += 1\n  }\n\n  function replacePaginatedAssets(pages: Map<number, Asset[]>) {\n    paginatedAssets.value = new Map(pages)\n    paginatedAssetsVersion.value += 1\n  }\n\n  function clearPaginatedAssets() {\n    // 这里不替换 ref 对象本身，保持引用稳定；通过 version 告知外部“缓存已失效”。\n    paginatedAssets.value.clear()\n    paginatedAssetsVersion.value += 1\n  }\n\n  function setVisibleRange(startIndex?: number, endIndex?: number) {\n    visibleRange.startIndex = startIndex\n    visibleRange.endIndex = endIndex\n  }\n\n  function setTimelineBuckets(buckets: TimelineBucket[]) {\n    timelineBuckets.value = buckets\n  }\n\n  function setTimelineTotalCount(count: number) {\n    timelineTotalCount.value = count\n  }\n\n  function clearTimelineData() {\n    timelineBuckets.value = []\n    timelineTotalCount.value = 0\n  }\n\n  function resetQueryState() {\n    // query slice 的 reset 只负责“查询域”，不触碰筛选与交互态。\n    isLoading.value = false\n    isInitialLoading.value = false\n    error.value = null\n    totalCount.value = 0\n    currentPage.value = 1\n    hasNextPage.value = false\n    isRefreshing.value = false\n    queryVersion.value = 0\n\n    clearTimelineData()\n    clearPaginatedAssets()\n    setVisibleRange(undefined, undefined)\n  }\n\n  return {\n    isLoading,\n    isInitialLoading,\n    error,\n    totalCount,\n    currentPage,\n    hasNextPage,\n    isRefreshing,\n    queryVersion,\n    paginatedAssets,\n    paginatedAssetsVersion,\n    perPage,\n    visibleRange,\n    timelineBuckets,\n    timelineTotalCount,\n    setLoading,\n    setInitialLoading,\n    setError,\n    setPagination,\n    beginQueryRefresh,\n    finishQueryRefresh,\n    isQueryVersionCurrent,\n    setPerPage,\n    getAssetsInRange,\n    isPageLoaded,\n    setPageAssets,\n    replacePaginatedAssets,\n    clearPaginatedAssets,\n    setVisibleRange,\n    setTimelineBuckets,\n    setTimelineTotalCount,\n    clearTimelineData,\n    resetQueryState,\n  }\n}\n"
  },
  {
    "path": "web/src/features/gallery/types.ts",
    "content": "// Gallery模块类型定义 - Vue版本\n// 基于 React 版本，去掉 React 特定的 Props 类型\n\n// ============= 核心数据类型 =============\n\nexport interface Asset {\n  id: number\n  name: string\n  path: string\n  type: AssetType // photo, video, live_photo, unknown\n  dominantColorHex?: string\n  rating: number\n  reviewFlag: ReviewFlag\n\n  // 基本信息\n  width?: number\n  height?: number\n  size?: number // 文件大小（字节）\n  mimeType?: string\n  hash?: string\n  rootId?: number\n  relativePath?: string\n  folderId?: number\n  description?: string\n  extension?: string\n\n  // 时间信息（统一使用时间戳）\n  fileCreatedAt?: number\n  fileModifiedAt?: number\n  createdAt: number\n  updatedAt: number\n}\n\n// 资产类型枚举\nexport type AssetType = 'photo' | 'video' | 'live_photo' | 'unknown'\n\nexport type ReviewFlag = 'none' | 'picked' | 'rejected'\n\n// 文件夹类型\nexport interface Folder {\n  id: number\n  path: string\n  parentId?: number\n  name: string\n  displayName?: string\n  coverAssetId?: number\n  sortOrder: number\n  isHidden: boolean\n  createdAt: number\n  updatedAt: number\n}\n\n// 文件夹树节点类型（用于侧边栏导航）\nexport interface FolderTreeNode {\n  id: number\n  path: string\n  parentId?: number\n  name: string\n  displayName?: string\n  coverAssetId?: number\n  sortOrder: number\n  isHidden: boolean\n  createdAt: number\n  updatedAt: number\n  assetCount: number // 包含所有子文件夹的 assets 总数\n  children: FolderTreeNode[]\n}\n\n// 标签类型\nexport interface Tag {\n  id: number\n  name: string\n  parentId?: number\n  sortOrder: number\n  createdAt: number\n  updatedAt: number\n}\n\n// 标签树节点类型（用于侧边栏导航）\nexport interface TagTreeNode {\n  id: number\n  name: string\n  parentId?: number\n  sortOrder: number\n  createdAt: number\n  updatedAt: number\n  assetCount: number // 包含所有子标签的 assets 总数\n  children: TagTreeNode[]\n}\n\nexport type {\n  // 资产动作/标签动作参数（RPC DTO）\n  UpdateFolderDisplayNameParams,\n  CreateTagParams,\n  UpdateTagParams,\n  AddTagsToAssetParams,\n  RemoveTagsFromAssetParams,\n  UpdateAssetsReviewStateParams,\n  MoveAssetsToFolderParams,\n  UpdateAssetDescriptionParams,\n  SetInfinityNikkiUserRecordParams,\n  InfinityNikkiUserRecordCodeType,\n  GetAssetTagsParams,\n  // 统计/展示数据（仅由 API 返回）\n  TagStats,\n  HomeStats,\n  // 颜色/关联数据\n  AssetMainColor,\n  AssetTag,\n  // 扫描忽略规则（用于扫描对话框）\n  IgnoreRule,\n  ScanIgnoreRule,\n} from './api/dto'\n\n// ============= 视图配置类型 =============\n\n// 视图模式\nexport type ViewMode = 'masonry' | 'grid' | 'list' | 'adaptive'\n\n// 排序选项\nexport type SortBy = 'createdAt' | 'name' | 'size' | 'resolution'\nexport type SortOrder = 'asc' | 'desc'\n\n// 筛选器\nexport interface AssetFilter {\n  type?: AssetType // photo, video, live_photo, unknown\n  searchQuery?: string\n  folderId?: string\n  rating?: number\n  reviewFlag?: ReviewFlag\n  tagIds?: number[]\n  tagMatchMode?: 'any' | 'all'\n  clothIds?: number[]\n  clothMatchMode?: 'any' | 'all'\n  colorHex?: string\n}\n\n// 视图配置\nexport interface ViewConfig {\n  mode: ViewMode\n  size: number // 缩略图目标尺寸（px）\n}\n\nexport type {\n  // 扫描/可达性\n  OperationResult,\n  ScanAssetsParams,\n  ScanAssetsResult,\n  StartScanAssetsResult,\n  AssetReachability,\n  // 查询（timeline / grid / adaptive 共用过滤器语义）\n  QueryAssetsFilters,\n  QueryAssetsParams,\n  QueryAssetsResponse,\n  AssetLayoutMetaItem,\n  QueryAssetLayoutMetaParams,\n  QueryAssetLayoutMetaResponse,\n  AdaptiveLayoutRowItem,\n  AdaptiveLayoutRow,\n  QueryPhotoMapPointsParams,\n  PhotoMapPoint,\n  // Infinity Nikki 解析结果\n  InfinityNikkiExtractedParams,\n  InfinityNikkiUserRecord,\n  InfinityNikkiDetails,\n  GetInfinityNikkiMetadataNamesParams,\n  InfinityNikkiMetadataNames,\n  // 时间线桶与月视图\n  TimelineBucket,\n  GetTimelineBucketsParams,\n  TimelineBucketsResponse,\n  GetAssetsByMonthParams,\n  GetAssetsByMonthResponse,\n} from './api/dto'\n\n// ============= UI状态类型 =============\n\n// 选择状态\nexport interface SelectionState {\n  selectedIds: Set<number>\n  anchorIndex?: number\n  activeIndex?: number\n  activeAssetId?: number\n}\n\n// Lightbox状态\nexport interface LightboxState {\n  isOpen: boolean\n  /** 关闭动画阶段：为 true 时仍可认为灯箱打开，但 gallery 层已开始淡入 */\n  isClosing: boolean\n  /** 沉浸模式：仅页面内 Teleport + 固定层铺满视口，不调用系统/浏览器全屏 */\n  isImmersive: boolean\n  showFilmstrip: boolean\n  zoom: number\n  fitMode: 'contain' | 'cover' | 'actual'\n}\n\n// 侧边栏状态\nexport interface SidebarState {\n  isOpen: boolean\n}\n\n// 详情面板焦点状态\nexport type DetailsPanelFocus =\n  | { type: 'none' }\n  | { type: 'folder'; folder: FolderTreeNode }\n  | { type: 'tag'; tag: TagTreeNode }\n  | { type: 'asset'; asset: Asset }\n  | { type: 'batch' }\n\n// ============= 错误类型 =============\n\nexport interface AssetError {\n  code: string\n  message: string\n  details?: unknown\n}\n\n// 自定义错误类\nexport class GalleryError extends Error {\n  code: string\n  details?: unknown\n\n  constructor(code: string, message: string, details?: unknown) {\n    super(message)\n    this.name = 'GalleryError'\n    this.code = code\n    this.details = details\n  }\n}\n"
  },
  {
    "path": "web/src/features/home/pages/HomePage.vue",
    "content": "<script setup lang=\"ts\">\nimport { ref, computed, onMounted, onUnmounted } from 'vue'\nimport { FolderOpen } from 'lucide-vue-next'\nimport { on as onRpc, off as offRpc } from '@/core/rpc'\nimport { useI18n } from '@/composables/useI18n'\nimport { useToast } from '@/composables/useToast'\nimport { galleryApi } from '@/features/gallery/api'\nimport type { HomeStats } from '@/features/gallery/types'\nimport { formatFileSize } from '@/lib/utils'\nimport { featuresApi } from '@/features/settings/featuresApi'\nimport { useSettingsStore } from '@/features/settings/store'\nimport { resolveBackgroundImageUrl } from '@/features/settings/backgroundPath'\nimport momoOutlineSvg from '@/assets/momo-outline.svg?raw'\n\nconst { t, locale } = useI18n()\nconst { toast } = useToast()\nconst settingsStore = useSettingsStore()\n\nconst showMomoOutline = computed(\n  () => !resolveBackgroundImageUrl(settingsStore.appSettings.ui.background)\n)\n\nconst HOME_STATS_REFRESH_DEBOUNCE_MS = 400\n\nconst isOpening = ref(false)\nconst hasLoadedHomeStats = ref(false)\nconst homeStats = ref<HomeStats>({\n  totalCount: 0,\n  photoCount: 0,\n  videoCount: 0,\n  livePhotoCount: 0,\n  totalSize: 0,\n  todayAddedCount: 0,\n})\n\nconst photoCount = computed(() => homeStats.value.photoCount + homeStats.value.livePhotoCount)\nconst videoCount = computed(() => homeStats.value.videoCount)\n\nconst numberFormatter = computed(() => new Intl.NumberFormat(locale.value))\n\nconst formatCount = (value: number): string => {\n  return numberFormatter.value.format(Math.max(0, value))\n}\n\nconst formattedPhotoCount = computed(() => formatCount(photoCount.value))\nconst formattedVideoCount = computed(() => formatCount(videoCount.value))\nconst formattedTotalSize = computed(() => formatFileSize(homeStats.value.totalSize))\nconst formattedTodayAdded = computed(() => {\n  const value = Math.max(0, homeStats.value.todayAddedCount)\n  const formatted = formatCount(value)\n  return value > 0 ? `+${formatted}` : formatted\n})\n\nlet isUnmounted = false\nlet refreshInFlight = false\nlet refreshQueued = false\nlet refreshTimer: ReturnType<typeof setTimeout> | null = null\n\nconst clearRefreshTimer = () => {\n  if (refreshTimer !== null) {\n    clearTimeout(refreshTimer)\n    refreshTimer = null\n  }\n}\n\nconst refreshHomeStats = async () => {\n  if (refreshInFlight) {\n    refreshQueued = true\n    return\n  }\n\n  refreshInFlight = true\n  do {\n    refreshQueued = false\n    try {\n      const stats = await galleryApi.getHomeStats()\n      if (isUnmounted) break\n      homeStats.value = stats\n      hasLoadedHomeStats.value = true\n    } catch (error) {\n      console.error('Failed to refresh home stats:', error)\n    }\n  } while (refreshQueued)\n\n  refreshInFlight = false\n}\n\nconst scheduleHomeStatsRefresh = () => {\n  clearRefreshTimer()\n  refreshTimer = setTimeout(() => {\n    refreshTimer = null\n    if (isUnmounted) return\n    void refreshHomeStats()\n  }, HOME_STATS_REFRESH_DEBOUNCE_MS)\n}\n\nconst galleryChangedHandler = () => {\n  scheduleHomeStatsRefresh()\n}\n\nconst handleOpenOutputDirectory = async () => {\n  if (isOpening.value) return\n\n  isOpening.value = true\n  try {\n    await featuresApi.invoke('output.open_folder')\n  } catch (error) {\n    console.error('Failed to open output directory:', error)\n    toast.error(t('home.outputDir.openFailed'))\n  } finally {\n    isOpening.value = false\n  }\n}\n\nonMounted(() => {\n  void refreshHomeStats()\n  onRpc('gallery.changed', galleryChangedHandler)\n})\n\nonUnmounted(() => {\n  isUnmounted = true\n  clearRefreshTimer()\n  offRpc('gallery.changed', galleryChangedHandler)\n})\n</script>\n\n<template>\n  <div class=\"relative h-full w-full overflow-x-hidden\">\n    <div\n      v-if=\"showMomoOutline\"\n      class=\"pointer-events-none absolute top-10 right-10 bottom-6 z-10 w-[min(46vw,580px)] max-w-full text-white select-none dark:text-white/50\"\n      aria-hidden=\"true\"\n    >\n      <div\n        class=\"flex h-full w-full items-center justify-end [&_svg]:h-full [&_svg]:w-auto [&_svg]:max-w-none [&_svg]:shrink-0\"\n        v-html=\"momoOutlineSvg\"\n      ></div>\n    </div>\n\n    <div\n      v-if=\"hasLoadedHomeStats\"\n      class=\"pointer-events-none absolute bottom-8 left-8 z-20 animate-in duration-600 fade-in-0\"\n    >\n      <div\n        class=\"relative overflow-hidden rounded-sm border border-border/30 shadow-sm backdrop-blur-md\"\n      >\n        <!-- Base Backgrounds -->\n        <div class=\"app-background-overlay pointer-events-none absolute inset-0 z-0\"></div>\n        <div class=\"surface-middle pointer-events-none absolute inset-0 z-0 opacity-90\"></div>\n\n        <!-- Subtle Inner Border for Premium Feel -->\n        <div\n          class=\"pointer-events-none absolute inset-[1px] z-10 rounded-sm border border-foreground/5\"\n        ></div>\n\n        <div class=\"relative z-20 flex min-w-[240px] flex-col p-6\">\n          <!-- Brand Header -->\n          <div class=\"mb-4 flex flex-col\">\n            <h2 class=\"text-xs font-medium tracking-[0.3em] text-foreground/90 uppercase\">\n              Spinning Momo\n            </h2>\n            <p class=\"mt-1 text-[0.65rem] font-light tracking-[0.2em] text-foreground/50 uppercase\">\n              Infinity Record\n            </p>\n          </div>\n\n          <!-- Divider -->\n          <div class=\"mb-5 h-[1px] w-full bg-foreground/10\"></div>\n\n          <!-- Stats Grid -->\n          <div class=\"grid grid-cols-2 gap-x-6 gap-y-4\">\n            <div class=\"flex flex-col gap-0.5\">\n              <span class=\"text-[0.65rem] font-light tracking-widest text-foreground/40 uppercase\"\n                >Photos</span\n              >\n              <span class=\"text-sm font-medium tracking-wider text-foreground/90\">{{\n                formattedPhotoCount\n              }}</span>\n            </div>\n\n            <div class=\"flex flex-col gap-0.5\">\n              <span class=\"text-[0.65rem] font-light tracking-widest text-foreground/40 uppercase\"\n                >Storage</span\n              >\n              <span class=\"text-sm font-medium tracking-wider text-foreground/90\">{{\n                formattedTotalSize\n              }}</span>\n            </div>\n\n            <div v-if=\"videoCount > 0\" class=\"flex flex-col gap-0.5\">\n              <span class=\"text-[0.65rem] font-light tracking-widest text-foreground/40 uppercase\"\n                >Videos</span\n              >\n              <span class=\"text-sm font-medium tracking-wider text-foreground/90\">{{\n                formattedVideoCount\n              }}</span>\n            </div>\n\n            <div class=\"flex flex-col gap-0.5\">\n              <span class=\"text-[0.65rem] font-light tracking-widest text-foreground/40 uppercase\"\n                >Today</span\n              >\n              <span\n                class=\"text-sm font-medium tracking-wider\"\n                :class=\"homeStats.todayAddedCount > 0 ? 'text-primary/90' : 'text-foreground/90'\"\n              >\n                {{ formattedTodayAdded }}\n              </span>\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n\n    <div class=\"group absolute right-8 bottom-8 z-20 flex flex-col items-end gap-3\">\n      <!-- Tooltip Label -->\n      <div\n        class=\"pointer-events-none rounded-sm border border-border/30 px-3 py-1.5 opacity-0 backdrop-blur-md transition-all duration-300 group-hover:-translate-y-1 group-hover:opacity-100\"\n      >\n        <div\n          class=\"app-background-overlay pointer-events-none absolute inset-0 z-0 rounded-sm\"\n        ></div>\n        <div\n          class=\"surface-middle pointer-events-none absolute inset-0 z-0 rounded-sm opacity-90\"\n        ></div>\n        <span\n          class=\"relative z-10 text-[0.65rem] font-medium tracking-widest text-foreground/80 uppercase\"\n        >\n          Open Folder\n        </span>\n      </div>\n\n      <!-- Square Shutter Button -->\n      <button\n        class=\"relative flex h-[52px] w-[52px] cursor-pointer items-center justify-center overflow-hidden rounded-sm border border-border/30 shadow-sm backdrop-blur-md transition-all duration-300 hover:shadow-md focus:outline-none active:scale-[0.97]\"\n        :disabled=\"isOpening\"\n        @click=\"handleOpenOutputDirectory\"\n      >\n        <!-- Base Backgrounds -->\n        <div class=\"app-background-overlay pointer-events-none absolute inset-0 z-0\"></div>\n        <div class=\"surface-middle pointer-events-none absolute inset-0 z-0 opacity-90\"></div>\n\n        <!-- Subtle Inner Border -->\n        <div\n          class=\"pointer-events-none absolute inset-[1px] z-10 rounded-sm border border-foreground/5\"\n        ></div>\n\n        <!-- Hover Overlay -->\n        <div\n          class=\"pointer-events-none absolute inset-0 z-10 bg-foreground/5 opacity-0 transition-opacity duration-300 group-hover:opacity-100\"\n        ></div>\n\n        <!-- Icon -->\n        <FolderOpen\n          class=\"relative z-20 h-5 w-5 text-foreground/70 transition-all duration-300 group-hover:text-foreground\"\n          :class=\"isOpening ? 'animate-pulse' : ''\"\n          stroke-width=\"1.5\"\n        />\n      </button>\n    </div>\n  </div>\n</template>\n"
  },
  {
    "path": "web/src/features/map/README.md",
    "content": "# 地图模块（Map）维护说明\n\n面向后续开发与 AI 助手的简版架构与现状说明。详细构建方式见仓库根目录 `AGENTS.md`。\n\n## 产品定位\n\n- 在 **内嵌 WebView/浏览器 iframe** 中加载官方工具站地图：`MAP_URL` / `MAP_ORIGIN` 定义在 [`bridge/protocol.ts`](./bridge/protocol.ts)（`myl.nuanpaper.com`）。\n- 本应用不托管地图，而是通过 **postMessage** 把「图库中照片的坐标 + 展示配置」同步到 iframe 内由注入脚本在 Leaflet 上画标点、聚合、悬停卡片，并支持从卡片跳转图库。\n\n## 前端目录职责\n\n| 路径                            | 作用                                                                                                                 |\n| ------------------------------- | -------------------------------------------------------------------------------------------------------------------- |\n| `pages/MapPage.vue`             | 地图页 UI 与 `useMapScene` 初始化                                                                                    |\n| `store.ts`                      | `markers`、`renderOptions`、`runtimeOptions` 等轻量状态                                                              |\n| `composables/useMapScene.ts`    | 监听图库筛选/排序/语言，拉取点位、写入 `mapStore`                                                                    |\n| `composables/useMapBridge.ts`   | iframe `postMessage`：prod 为 `SYNC_RUNTIME`；dev 为 `EVAL_SCRIPT` + 入站（打开图库/标点显隐）                       |\n| `bridge/protocol.ts`            | 与 iframe 的 action 常量、payload 类型；生产同步为 `SPINNING_MOMO_SYNC_RUNTIME`，Vite dev 另发 `EVAL_SCRIPT`（见下） |\n| `injection/mapDevEvalScript.ts` | **仅 dev**：把当前 store 快照拼成 iframe 内 eval 用整段脚本                                                          |\n| `domain/*`                      | 与 Vue 无关的纯逻辑：坐标、默认配置、**PhotoMapPoint → MapMarker**                                                   |\n| `api.ts`                        | 对 `gallery.queryPhotoMapPoints` 的 feature 内门面，避免页面直接依赖 gallery API                                     |\n| `components/MapIframeHost.vue`  | 全局布局里常驻的 iframe 容器，避免切路由时地图白屏重载；内部极薄，委托 `useMapBridge`                                |\n\n## 数据模型（宿主 → iframe 必须可结构化克隆）\n\n`MapMarker` 为 **扁平** 结构（无嵌套 `popup`），见 [`store.ts`](./store.ts)：\n\n- 坐标：`lat` / `lng`（已按本模块约定从游戏坐标换算为地图坐标）\n- 展示：`cardTitle`（悬停标题，已按 i18n 在 mapper 中格式化）\n- 资源与跳转：`assetId` / `assetIndex` / `thumbnailUrl`（与图库、lightbox 一致）\n- 悬停总开关在 **`MapRuntimeOptions.hoverCardEnabled`**（单点 + 聚合同一语义；不再使用第二套 `openPopupOnHover`）\n- 延时/移出行为在 **`MapRenderOptions`**（如 `popupOpenDelayMs`、`closePopupOnMouseOut` 等），仅表示交互参数，不重复表达「开不开悬停」\n\n`useMapBridge` 出站前会把 Pinia 状态**手工展开为纯对象**再 `postMessage`，避免 `DataCloneError`。\n\n## iframe 内注入（核心）\n\n- **源码**：`injection/source/*.js` 以「字符串模板」拼进 `runtimeCore`；修改后**必须**从仓库根目录执行：  \n  `node scripts/generate-map-injection-cpp.js`  \n  以更新 C++ 嵌入的 [`map_injection_script.ixx`](../../../src/extensions/infinity_nikki/generated/map_injection_script.ixx)（供打包后的 Win32 注入用）。\n- **prod**：宿主 → iframe 仅 `SPINNING_MOMO_SYNC_RUNTIME`，`payload` 为 `{ markers, renderOptions, runtimeOptions }`（与 `useMapBridge` 序列化一致）。\n- **Vite dev**：同一 payload 由 [`injection/mapDevEvalScript.ts`](./injection/mapDevEvalScript.ts) 拼成整段 IIFE，经 `EVAL_SCRIPT` 在 iframe 内 `new Function` 执行，便于**不重新生成 C++ 嵌入串**即可热更 `injection/source`；仅当注入脚本里 `__ALLOW_DEV_EVAL__` 被 C++ 换为 `true`（Debug 构建）时生效，Release 为 `false`。\n- **桥接入口**：[`injection/source/bridgeScript.js`](./injection/source/bridgeScript.js) 只拼两层：**[`iframeBootstrap.js`](./injection/source/iframeBootstrap.js)**（脚本加载后即可跑的页壳，当前含侧栏自动收起）+ **`runtimeCore`**（`mountOrUpdateMapRuntime` 及其子 snippet）。Vite dev 的 `EVAL_SCRIPT` 包（[`devEvalRuntimeScript.js`](./injection/source/devEvalRuntimeScript.js)）**不含** bootstrap，只热更 `runtimeCore`。\n- **拼入顺序**（[`runtimeCore.js`](./injection/source/runtimeCore.js)）：`paneStyle` → `popup`（悬停层、计时、escapeHtml）→ `photoCardHtml` → `cluster` → `toolbar` → `render`。\n\n### 子模块分工（注入侧）\n\n- **paneStyle.js**：地图容器上自定义标点共用的 `spinning-momo-photo-pane` 与弹层 CSS（含单图 `thumbnail-image` 的 max 尺寸，横竖图适配）。\n- **photoCardHtml.js**：`buildPhotoThumbCellHtml`（**聚合**方格，1:1 + cover）；`buildSinglePhotoHoverHtml`（**单点**，恢复 `thumbnail-block` / `thumbnail-image` 类以沿用 CSS）。\n- **popup.js**：悬停卡片容器定位、`scheduleOpenHoverCard` / `showHoverCard`、`bindPopupCardClickBridge`（`data-sm-open-asset-id` 点击 → `SPINNING_MOMO_OPEN_GALLERY_ASSET`）；`activeHoverCardContext` 含 **`latLng`**，供 **`refreshActiveHoverCardPosition`** 在内容变高后重算锚点（聚合展开等场景）。\n- **render.js**：单点 Leaflet 标点与 hover 绑定（受 `hoverCardEnabled` 控制）。\n- **cluster.js**：网格聚合；预览网格带 **`data-sm-cluster-grid-root`**，卡片根带 **`data-sm-cluster-card`**。点「+N 更多」时 **增量 DOM**：去掉 `[data-sm-cluster-expand]`、向同一 grid **append** 剩余缩略图、再包 **`data-sm-cluster-scroll`** 与滚轮穿透处理，**不**整卡 `innerHTML` 替换，避免预览缩略图闪烁；`smClusterExpanded` 防重复展开。\n- **iframeBootstrap.js**：与 **map 实例无关** 的第三方页壳（侧栏收起等）；**toolbar.js**：在 **`mountOrUpdateMapRuntime`** 内挂按钮，需读 `runtime` / 与标点显隐同步。二者均属脆弱 DOM 适配。\n\n## 与图库联动\n\n- 地图点来自 RPC（经 `api.ts`）：仍基于当前图库筛选/排序，保证 `assetIndex` 与图库 lightbox 一致。\n- 卡片内点击经 `useMapBridge` 收到后：`galleryStore` 设活跃资产、打开 lightbox、路由到 gallery。\n\n## 常见修改点\n\n- 只改数据形状：先改 `store` + `domain/markerMapper.ts` + `useMapBridge` 序列化，再改注入里读取字段。\n- 只改单图/聚合格式：先改 `photoCardHtml.js` 或 `paneStyle.js`；改聚合展开/预览逻辑看 **`cluster.js`**；记得跑 **generate-map-injection**。\n- 不要恢复「`popup` 存在才绑 hover」这类隐式条件；单点与聚合的开关统一用 `hoverCardEnabled`。\n"
  },
  {
    "path": "web/src/features/map/api.ts",
    "content": "import { queryPhotoMapPoints as queryPhotoMapPointsFromGallery } from '@/features/gallery/api'\nimport type { QueryPhotoMapPointsParams, PhotoMapPoint } from '@/features/gallery/types'\n\nexport async function queryPhotoMapPoints(\n  params: QueryPhotoMapPointsParams\n): Promise<PhotoMapPoint[]> {\n  return queryPhotoMapPointsFromGallery(params)\n}\n"
  },
  {
    "path": "web/src/features/map/bridge/protocol.ts",
    "content": "import type { MapMarker, MapRenderOptions, MapRuntimeOptions } from '@/features/map/store'\n\nexport const MAP_URL = 'https://myl.nuanpaper.com/tools/map'\nexport const MAP_ORIGIN = 'https://myl.nuanpaper.com'\n\nexport const ACTION_SYNC_RUNTIME = 'SPINNING_MOMO_SYNC_RUNTIME'\n/** 仅 Debug 注入脚本内为 true 时由 iframe 处理；Vite dev 用于热更 `injection/source` 整包 */\nexport const ACTION_EVAL_SCRIPT = 'EVAL_SCRIPT'\nexport const ACTION_OPEN_GALLERY_ASSET = 'SPINNING_MOMO_OPEN_GALLERY_ASSET'\nexport const ACTION_SET_MARKERS_VISIBLE = 'SPINNING_MOMO_SET_MARKERS_VISIBLE'\n\nexport type SyncRuntimePayload = {\n  markers: MapMarker[]\n  renderOptions: MapRenderOptions\n  runtimeOptions: MapRuntimeOptions\n}\n\nexport type SyncRuntimeMessage = {\n  action: typeof ACTION_SYNC_RUNTIME\n  payload: SyncRuntimePayload\n}\n\nexport type OpenGalleryAssetMessage = {\n  action: typeof ACTION_OPEN_GALLERY_ASSET\n  payload?: {\n    assetId?: number\n    assetIndex?: number\n  }\n}\n\nexport type SetMarkersVisibleMessage = {\n  action: typeof ACTION_SET_MARKERS_VISIBLE\n  payload?: {\n    markersVisible?: boolean\n  }\n}\n\nexport type MapInboundMessage = OpenGalleryAssetMessage | SetMarkersVisibleMessage\n"
  },
  {
    "path": "web/src/features/map/components/MapIframeHost.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed, onMounted, onUnmounted, ref, watch } from 'vue'\nimport { useRoute, useRouter } from 'vue-router'\nimport { useGalleryStore } from '@/features/gallery/store'\nimport { MAP_URL } from '@/features/map/bridge/protocol'\nimport { useMapBridge } from '@/features/map/composables/useMapBridge'\nimport { useMapStore } from '@/features/map/store'\n\nconst route = useRoute()\nconst router = useRouter()\nconst galleryStore = useGalleryStore()\nconst mapStore = useMapStore()\nconst mapIframe = ref<HTMLIFrameElement | null>(null)\n\nconst isMapRoute = computed(() => route.name === 'map')\nconst { postRuntimeSync, handleMapMessage } = useMapBridge({\n  mapIframe,\n  mapStore,\n  galleryStore,\n  router,\n})\n\nfunction handleIframeLoad() {\n  postRuntimeSync()\n}\n\nwatch(\n  () => mapStore.markers,\n  () => {\n    postRuntimeSync()\n  },\n  { deep: true }\n)\n\nwatch(\n  () => mapStore.renderOptions,\n  () => {\n    postRuntimeSync()\n  },\n  { deep: true }\n)\n\nwatch(\n  () => mapStore.runtimeOptions,\n  () => {\n    postRuntimeSync()\n  },\n  { deep: true }\n)\n\nwatch(isMapRoute, (visible) => {\n  if (visible) {\n    postRuntimeSync()\n  }\n})\n\nonMounted(() => {\n  window.addEventListener('message', handleMapMessage)\n})\n\nonUnmounted(() => {\n  window.removeEventListener('message', handleMapMessage)\n})\n</script>\n\n<template>\n  <div\n    v-show=\"isMapRoute\"\n    class=\"absolute inset-x-0 top-[calc(var(--titlebar-height,20px)+3rem)] bottom-0 z-0\"\n  >\n    <iframe\n      ref=\"mapIframe\"\n      :src=\"MAP_URL\"\n      class=\"absolute inset-0 h-full w-full border-none\"\n      allowfullscreen\n      @load=\"handleIframeLoad\"\n    ></iframe>\n  </div>\n</template>\n"
  },
  {
    "path": "web/src/features/map/composables/useMapBridge.ts",
    "content": "import type { Ref } from 'vue'\nimport type { Router } from 'vue-router'\nimport { useGalleryStore } from '@/features/gallery/store'\nimport {\n  ACTION_EVAL_SCRIPT,\n  ACTION_OPEN_GALLERY_ASSET,\n  ACTION_SET_MARKERS_VISIBLE,\n  ACTION_SYNC_RUNTIME,\n  MAP_ORIGIN,\n  type MapInboundMessage,\n  type SyncRuntimeMessage,\n  type SyncRuntimePayload,\n} from '@/features/map/bridge/protocol'\nimport { buildMapDevEvalScript } from '@/features/map/injection/mapDevEvalScript'\nimport { useMapStore } from '@/features/map/store'\n\ntype UseMapBridgeOptions = {\n  mapIframe: Ref<HTMLIFrameElement | null>\n  mapStore: ReturnType<typeof useMapStore>\n  galleryStore: ReturnType<typeof useGalleryStore>\n  router: Router\n}\n\nfunction buildSerializableRuntimePayload(\n  mapStore: ReturnType<typeof useMapStore>\n): SyncRuntimePayload {\n  const markerIconSize = mapStore.renderOptions.markerIconSize\n    ? ([...mapStore.renderOptions.markerIconSize] as [number, number])\n    : undefined\n  const markerIconAnchor = mapStore.renderOptions.markerIconAnchor\n    ? ([...mapStore.renderOptions.markerIconAnchor] as [number, number])\n    : undefined\n\n  return {\n    markers: mapStore.markers.map((marker) => ({\n      assetId: marker.assetId,\n      assetIndex: marker.assetIndex,\n      name: marker.name,\n      lat: marker.lat,\n      lng: marker.lng,\n      cardTitle: marker.cardTitle,\n      thumbnailUrl: marker.thumbnailUrl,\n      fileCreatedAt: marker.fileCreatedAt,\n    })),\n    renderOptions: {\n      mapBackgroundColor: mapStore.renderOptions.mapBackgroundColor,\n      markerPinBackgroundUrl: mapStore.renderOptions.markerPinBackgroundUrl,\n      markerIconUrl: mapStore.renderOptions.markerIconUrl,\n      markerIconSize,\n      markerIconAnchor,\n      closePopupOnMouseOut: mapStore.renderOptions.closePopupOnMouseOut,\n      popupOpenDelayMs: mapStore.renderOptions.popupOpenDelayMs,\n      popupCloseDelayMs: mapStore.renderOptions.popupCloseDelayMs,\n      keepPopupVisibleOnHover: mapStore.renderOptions.keepPopupVisibleOnHover,\n    },\n    runtimeOptions: {\n      clusterEnabled: mapStore.runtimeOptions.clusterEnabled,\n      clusterRadius: mapStore.runtimeOptions.clusterRadius,\n      hoverCardEnabled: mapStore.runtimeOptions.hoverCardEnabled,\n      markersVisible: mapStore.runtimeOptions.markersVisible,\n      thumbnailBaseUrl: mapStore.runtimeOptions.thumbnailBaseUrl,\n      clusterTitleTemplate: mapStore.runtimeOptions.clusterTitleTemplate,\n    },\n  }\n}\n\nexport function useMapBridge(options: UseMapBridgeOptions) {\n  const { mapIframe, mapStore, galleryStore, router } = options\n\n  function postRuntimeSync() {\n    const contentWindow = mapIframe.value?.contentWindow\n    if (!contentWindow) {\n      return\n    }\n\n    const payload = buildSerializableRuntimePayload(mapStore)\n\n    if (import.meta.env.DEV) {\n      const script = buildMapDevEvalScript(payload)\n      try {\n        contentWindow.postMessage(\n          {\n            action: ACTION_EVAL_SCRIPT,\n            payload: { script },\n          },\n          MAP_ORIGIN\n        )\n      } catch (error) {\n        console.error('[MapBridge] Failed to post dev eval script:', error)\n      }\n      return\n    }\n\n    const message: SyncRuntimeMessage = {\n      action: ACTION_SYNC_RUNTIME,\n      payload,\n    }\n    try {\n      contentWindow.postMessage(message, MAP_ORIGIN)\n    } catch (error) {\n      console.error('[MapBridge] Failed to post runtime payload:', error)\n    }\n  }\n\n  async function handleMapMessage(event: MessageEvent<unknown>) {\n    if (event.origin !== MAP_ORIGIN) {\n      return\n    }\n\n    const data = event.data as MapInboundMessage\n    if (!data) {\n      return\n    }\n\n    if (data.action === ACTION_SET_MARKERS_VISIBLE) {\n      const markersVisible = data.payload?.markersVisible\n      if (typeof markersVisible !== 'boolean') {\n        return\n      }\n\n      mapStore.patchRuntimeOptions({ markersVisible })\n      return\n    }\n\n    if (data.action !== ACTION_OPEN_GALLERY_ASSET) {\n      return\n    }\n\n    const assetId = Number(data.payload?.assetId)\n    if (!Number.isFinite(assetId)) {\n      return\n    }\n\n    const assetIndex = Number(data.payload?.assetIndex)\n    const normalizedAssetIndex = Number.isFinite(assetIndex) ? assetIndex : 0\n    galleryStore.setActiveAsset(assetId, normalizedAssetIndex)\n    galleryStore.openLightbox()\n\n    try {\n      await router.push({\n        name: 'gallery',\n      })\n    } catch {\n      galleryStore.closeLightbox()\n    }\n  }\n\n  return {\n    postRuntimeSync,\n    handleMapMessage,\n  }\n}\n"
  },
  {
    "path": "web/src/features/map/composables/useMapScene.ts",
    "content": "import { computed, ref, watch } from 'vue'\nimport { useI18n } from '@/composables/useI18n'\nimport { toQueryAssetsFilters } from '@/features/gallery/queryFilters'\nimport { useGalleryStore } from '@/features/gallery/store'\nimport type { PhotoMapPoint } from '@/features/gallery/types'\nimport { queryPhotoMapPoints } from '@/features/map/api'\nimport {\n  createDefaultMapRenderOptions,\n  createDefaultMapRuntimeOptions,\n} from '@/features/map/domain/defaults'\nimport { toMapMarkers } from '@/features/map/domain/markerMapper'\nimport { useMapStore } from '@/features/map/store'\n\nexport function useMapScene() {\n  const { t, locale } = useI18n()\n  const galleryStore = useGalleryStore()\n  const mapStore = useMapStore()\n  const mapPoints = ref<PhotoMapPoint[]>([])\n\n  function syncMapRuntimeI18n() {\n    mapStore.patchRuntimeOptions({\n      clusterTitleTemplate: t('map.cluster.title'),\n    })\n  }\n\n  function initializeMapDefaults() {\n    mapStore.setRenderOptions(createDefaultMapRenderOptions())\n    mapStore.patchRuntimeOptions(createDefaultMapRuntimeOptions(t('map.cluster.title')))\n  }\n\n  async function loadMapPoints() {\n    mapStore.setLoading(true)\n\n    try {\n      const filters = toQueryAssetsFilters(galleryStore.filter, galleryStore.includeSubfolders)\n      mapPoints.value = await queryPhotoMapPoints({\n        filters,\n        sortBy: galleryStore.sortBy,\n        sortOrder: galleryStore.sortOrder,\n      })\n\n      mapStore.replaceMarkers(\n        toMapMarkers(mapPoints.value, {\n          locale: locale.value,\n          thumbnailBaseUrl: mapStore.runtimeOptions.thumbnailBaseUrl,\n          cardTitleFallback: t('map.popup.fallbackTitle'),\n        })\n      )\n    } catch (error) {\n      console.error('Failed to load map points:', error)\n      mapPoints.value = []\n      mapStore.replaceMarkers([])\n    } finally {\n      mapStore.setLoading(false)\n    }\n  }\n\n  watch(\n    () => [\n      galleryStore.filter,\n      galleryStore.includeSubfolders,\n      galleryStore.sortBy,\n      galleryStore.sortOrder,\n    ],\n    async () => {\n      await loadMapPoints()\n    },\n    { deep: true, immediate: true }\n  )\n\n  watch(locale, () => {\n    syncMapRuntimeI18n()\n    void loadMapPoints()\n  })\n\n  return {\n    mapPoints: computed(() => mapPoints.value),\n    initializeMapDefaults,\n    syncMapRuntimeI18n,\n    loadMapPoints,\n  }\n}\n"
  },
  {
    "path": "web/src/features/map/domain/coordinates.ts",
    "content": "import type { PhotoMapPoint } from '@/features/gallery/types'\n\n// 模型A：mapX = kx * gameX + bx，mapY = ky * gameY + by\nconst MAP_X_SCALE = 1.00010613\nconst MAP_X_BIAS = 756015.585\nconst MAP_Y_SCALE = 1.00001142\nconst MAP_Y_BIAS = 392339.507\n\nexport function transformGameToMapCoordinates(\n  point: Pick<PhotoMapPoint, 'nikkiLocX' | 'nikkiLocY'>\n): {\n  lat: number\n  lng: number\n} {\n  const mapX = point.nikkiLocX * MAP_X_SCALE + MAP_X_BIAS\n  const mapY = point.nikkiLocY * MAP_Y_SCALE + MAP_Y_BIAS\n\n  return {\n    // 官方地图经纬度约定与采集数据轴向相反，这里交换 x/y。\n    lat: mapY,\n    lng: mapX,\n  }\n}\n"
  },
  {
    "path": "web/src/features/map/domain/defaults.ts",
    "content": "import type { MapRenderOptions, MapRuntimeOptions } from '@/features/map/store'\n\nexport function createDefaultMapRenderOptions(): MapRenderOptions {\n  return {\n    mapBackgroundColor: '#C7BFA7',\n    markerPinBackgroundUrl:\n      'https://assets.papegames.com/nikkiweb/infinitynikki/infinitynikki-map/img/58ca045d59db0f9cd8ad.png',\n    markerIconUrl:\n      'https://webstatic.papegames.com/a6f47b49876cbaff/images/bg/EzoioGtm0TN1V9Ua.png',\n    markerIconSize: [32, 32],\n    markerIconAnchor: [14, 14],\n    closePopupOnMouseOut: true,\n    popupOpenDelayMs: 180,\n    popupCloseDelayMs: 260,\n    keepPopupVisibleOnHover: true,\n  }\n}\n\nexport function createDefaultMapRuntimeOptions(clusterTitleTemplate: string): MapRuntimeOptions {\n  return {\n    clusterEnabled: true,\n    clusterRadius: 44,\n    hoverCardEnabled: true,\n    markersVisible: true,\n    thumbnailBaseUrl: 'http://127.0.0.1:51206',\n    clusterTitleTemplate,\n  }\n}\n"
  },
  {
    "path": "web/src/features/map/domain/markerMapper.ts",
    "content": "import type { Locale } from '@/core/i18n/types'\nimport type { PhotoMapPoint } from '@/features/gallery/types'\nimport { transformGameToMapCoordinates } from '@/features/map/domain/coordinates'\nimport type { MapMarker } from '@/features/map/store'\n\ntype MarkerMapperContext = {\n  locale: Locale\n  thumbnailBaseUrl: string\n  cardTitleFallback: string\n}\n\nconst FILENAME_DATE_PREFIX_RE = /^(\\d{4})_(\\d{2})_(\\d{2})_(\\d{2})_(\\d{2})_(\\d{2})/\n\nfunction formatPopupTitleFromFilename(\n  fileName: string,\n  currentLocale: Locale,\n  fallbackTitle: string\n): string {\n  const base = fileName.replace(/^.*[/\\\\]/, '')\n  const matched = base.match(FILENAME_DATE_PREFIX_RE)\n  if (!matched) {\n    return fallbackTitle\n  }\n\n  const year = Number(matched[1])\n  const month = Number(matched[2])\n  const day = Number(matched[3])\n  if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) {\n    return fallbackTitle\n  }\n\n  if (currentLocale === 'zh-CN') {\n    return `${year}年${month}月${day}日`\n  }\n\n  try {\n    return new Date(year, month - 1, day).toLocaleDateString('en-US', {\n      year: 'numeric',\n      month: 'long',\n      day: 'numeric',\n    })\n  } catch {\n    return fallbackTitle\n  }\n}\n\nfunction buildThumbnailUrl(point: PhotoMapPoint, thumbnailBaseUrl: string): string {\n  if (!point.hash) {\n    return ''\n  }\n\n  const prefix1 = point.hash.slice(0, 2)\n  const prefix2 = point.hash.slice(2, 4)\n  const relativePath = `${prefix1}/${prefix2}/${point.hash}.webp`\n  const normalizedBaseUrl = thumbnailBaseUrl.replace(/\\/+$/, '')\n  return `${normalizedBaseUrl}/static/assets/thumbnails/${relativePath}`\n}\n\nexport function toMapMarkers(points: PhotoMapPoint[], context: MarkerMapperContext): MapMarker[] {\n  return points.map((point) => {\n    const { lat, lng } = transformGameToMapCoordinates(point)\n    const thumbnailUrl = buildThumbnailUrl(point, context.thumbnailBaseUrl)\n\n    return {\n      assetId: point.assetId,\n      assetIndex: point.assetIndex,\n      name: point.name,\n      lat,\n      lng,\n      thumbnailUrl,\n      fileCreatedAt: point.fileCreatedAt,\n      cardTitle: formatPopupTitleFromFilename(\n        point.name,\n        context.locale,\n        context.cardTitleFallback\n      ),\n    }\n  })\n}\n"
  },
  {
    "path": "web/src/features/map/injection/mapDevEvalScript.ts",
    "content": "import type { SyncRuntimePayload } from '@/features/map/bridge/protocol'\nimport { buildMapDevEvalScriptFromPayload } from '@/features/map/injection/source'\n\nexport function buildMapDevEvalScript(payload: SyncRuntimePayload): string {\n  const serializedPayload = JSON.stringify(payload).replace(/</g, '\\\\u003c')\n  return buildMapDevEvalScriptFromPayload(serializedPayload)\n}\n"
  },
  {
    "path": "web/src/features/map/injection/source/bridgeScript.js",
    "content": "import { buildIframeBootstrapSnippet } from './iframeBootstrap.js'\nimport { buildRuntimeCoreSnippet } from './runtimeCore.js'\n\nexport function buildMapBridgeScriptTemplate() {\n  return `\nif (window.location.hostname === 'myl.nuanpaper.com') {\n    let innerL = undefined;\n    window.__SPINNING_MOMO_ALLOW_DEV_EVAL__ = __ALLOW_DEV_EVAL__;\n    window.__SPINNING_MOMO_PENDING_MARKERS__ = [];\n    window.__SPINNING_MOMO_RENDER_OPTIONS__ = {};\n    window.__SPINNING_MOMO_CLUSTER_OPTIONS__ = {};\n\n${buildIframeBootstrapSnippet()}\n${buildRuntimeCoreSnippet()}\n\n    const normalizeRenderOptions = (options) => {\n        if (!options || typeof options !== 'object') {\n            return {};\n        }\n\n        const normalized = { ...options };\n\n        if (!Array.isArray(normalized.markerIconSize) || normalized.markerIconSize.length !== 2) {\n            normalized.markerIconSize = undefined;\n        }\n        if (!Array.isArray(normalized.markerIconAnchor) || normalized.markerIconAnchor.length !== 2) {\n            normalized.markerIconAnchor = undefined;\n        }\n\n        return normalized;\n    };\n\n    const setRenderOptions = (options) => {\n        window.__SPINNING_MOMO_RENDER_OPTIONS__ = normalizeRenderOptions(options);\n    };\n\n    const setClusterOptions = (options) => {\n        if (!options || typeof options !== 'object') {\n            window.__SPINNING_MOMO_CLUSTER_OPTIONS__ = {};\n            return;\n        }\n        window.__SPINNING_MOMO_CLUSTER_OPTIONS__ = { ...options };\n    };\n\n    const maybeMountRuntime = (payload = {}) => {\n        const map = window.__SPINNING_MOMO_MAP__;\n        const L = window.L;\n        if (!map || !L || typeof mountOrUpdateMapRuntime !== 'function') {\n            return;\n        }\n\n        mountOrUpdateMapRuntime({\n            L,\n            map,\n            markers: window.__SPINNING_MOMO_PENDING_MARKERS__,\n            renderOptions: window.__SPINNING_MOMO_RENDER_OPTIONS__ || {},\n            runtimeOptions: window.__SPINNING_MOMO_CLUSTER_OPTIONS__ || {},\n            flyToFirst: payload.flyToFirst === true,\n        });\n    };\n\n    Object.defineProperty(window, 'L', {\n        get: function() { return innerL; },\n        set: function(val) {\n            innerL = val;\n            if (innerL && innerL.Map && !innerL.Map.__SPINNING_MOMO_PATCHED__) {\n                const OriginalMapClass = innerL.Map;\n                innerL.Map = function(...args) {\n                    const mapInstance = new OriginalMapClass(...args);\n                    window.__SPINNING_MOMO_MAP__ = mapInstance;\n                    maybeMountRuntime();\n                    return mapInstance;\n                };\n                innerL.Map.prototype = OriginalMapClass.prototype;\n                Object.assign(innerL.Map, OriginalMapClass);\n                innerL.Map.__SPINNING_MOMO_PATCHED__ = true;\n            }\n        },\n        configurable: true,\n        enumerable: true\n    });\n\n    window.addEventListener('message', (event) => {\n        if (!event.data) {\n            return;\n        }\n\n        if (event.data.action === 'SPINNING_MOMO_SYNC_RUNTIME') {\n            const runtimePayload = event.data.payload || {};\n            const markers = Array.isArray(runtimePayload.markers) ? runtimePayload.markers : [];\n            window.__SPINNING_MOMO_PENDING_MARKERS__ = markers;\n            setRenderOptions(runtimePayload.renderOptions || {});\n            setClusterOptions(runtimePayload.runtimeOptions || {});\n            maybeMountRuntime();\n            return;\n        }\n\n        if (event.data.action === 'EVAL_SCRIPT') {\n            if (!window.__SPINNING_MOMO_ALLOW_DEV_EVAL__) {\n                return;\n            }\n\n            const scriptPayload = event.data.payload || {};\n            if (typeof scriptPayload.script !== 'string' || scriptPayload.script.length === 0) {\n                return;\n            }\n\n            try {\n                const runScript = new Function(scriptPayload.script);\n                runScript();\n            } catch (error) {\n                console.error('[SpinningMomo] Failed to eval dev script:', error);\n            }\n            return;\n        }\n\n        if (event.data.action === 'ADD_MARKER') {\n            const marker = event.data.payload;\n            if (!marker) {\n                return;\n            }\n\n            const pendingMarkers = window.__SPINNING_MOMO_PENDING_MARKERS__;\n            window.__SPINNING_MOMO_PENDING_MARKERS__ = [...pendingMarkers, marker];\n            maybeMountRuntime({ flyToFirst: true });\n        }\n    });\n}\n`\n}\n"
  },
  {
    "path": "web/src/features/map/injection/source/cluster.js",
    "content": "export function buildClusterSnippet() {\n  return `\n  const buildClusterHoverGridHtml = (items, gridColumns, cellPx) => {\n    const cols = Math.min(3, Math.max(1, gridColumns));\n    return (\n      '<div data-sm-cluster-grid-root=\"1\" style=\"display:grid;grid-template-columns:repeat(' +\n      cols +\n      ',' +\n      cellPx +\n      'px);grid-auto-rows:' +\n      cellPx +\n      'px;gap:6px;\">' +\n      items.join('') +\n      '</div>'\n    );\n  };\n\n  const buildClusterHoverHtml = (clusterMarkers) => {\n    const totalCount = clusterMarkers.length;\n    const maxGridCells = 9;\n    const previewCount = totalCount > maxGridCells ? maxGridCells - 1 : Math.min(totalCount, maxGridCells);\n    const remainingCount = Math.max(0, totalCount - previewCount);\n\n    const cellPx = 96;\n    const previewItems = clusterMarkers.slice(0, previewCount).map((item) => buildPhotoThumbCellHtml(item));\n\n    if (remainingCount > 0) {\n      previewItems.push(\n        '<div data-sm-cluster-expand=\"1\" style=\"width:100%;height:100%;aspect-ratio:1/1;border-radius:6px;background:rgba(17,24,39,0.9);display:flex;align-items:center;justify-content:center;color:#e5e7eb;font-size:13px;font-weight:600;cursor:pointer;\">+' +\n          remainingCount +\n          ' 更多</div>'\n      );\n    }\n    const gridColumns = Math.min(3, Math.max(1, previewItems.length));\n    const titleTpl = runtimeOptions.clusterTitleTemplate || '{count} 张照片';\n    const clusterHeader = String(titleTpl).replace(/\\\\{count\\\\}/g, String(clusterMarkers.length));\n\n    return '<div data-sm-cluster-card=\"1\" style=\"padding:0.75rem;\">' +\n      '<div class=\"spinning-momo-popup-title\">' + clusterHeader + '</div>' +\n      buildClusterHoverGridHtml(previewItems, gridColumns, cellPx) +\n    '</div>';\n  };\n\n  const renderClusterMarker = (clusterMarkers) => {\n    const lat = clusterMarkers.reduce((sum, item) => sum + Number(item.lat || 0), 0) / clusterMarkers.length;\n    const lng = clusterMarkers.reduce((sum, item) => sum + Number(item.lng || 0), 0) / clusterMarkers.length;\n    const count = clusterMarkers.length;\n    const clusterOwnerId = clusterMarkers\n      .map((item) => String(item.assetId ?? item.name ?? (String(item.lat) + ',' + String(item.lng))))\n      .join('|');\n\n    const cellPx = 96;\n\n    const applyClusterHoverIncrementalExpand = (hoverCardRoot) => {\n      const totalCount = clusterMarkers.length;\n      const maxGridCells = 9;\n      const previewCount =\n        totalCount > maxGridCells ? maxGridCells - 1 : Math.min(totalCount, maxGridCells);\n      const remainingCount = Math.max(0, totalCount - previewCount);\n      if (remainingCount <= 0) {\n        return;\n      }\n\n      const grid = hoverCardRoot.querySelector('[data-sm-cluster-grid-root]');\n      if (!grid || grid.dataset.smClusterExpanded === 'true') {\n        return;\n      }\n\n      const expandEl = grid.querySelector('[data-sm-cluster-expand]');\n      if (!expandEl) {\n        return;\n      }\n\n      expandEl.remove();\n\n      const restHtml = clusterMarkers\n        .slice(previewCount)\n        .map((item) => buildPhotoThumbCellHtml(item))\n        .join('');\n      if (restHtml) {\n        grid.insertAdjacentHTML('beforeend', restHtml);\n      }\n\n      grid.style.gridTemplateColumns = 'repeat(3,' + cellPx + 'px)';\n\n      const cardShell = grid.closest('[data-sm-cluster-card]');\n      if (cardShell) {\n        cardShell.style.display = 'inline-block';\n        cardShell.style.maxWidth = 'calc(100vw - 32px)';\n      }\n\n      const parentEl = grid.parentElement;\n      if (parentEl && parentEl.getAttribute('data-sm-cluster-scroll') !== '1') {\n        const sc = document.createElement('div');\n        sc.setAttribute('data-sm-cluster-scroll', '1');\n        sc.style.maxHeight = 'min(60vh, 420px)';\n        sc.style.overflowY = 'auto';\n        sc.style.overscrollBehavior = 'contain';\n        parentEl.insertBefore(sc, grid);\n        sc.appendChild(grid);\n      }\n\n      grid.dataset.smClusterExpanded = 'true';\n\n      bindPopupCardClickBridge(hoverCardRoot);\n      const scrollRoot = hoverCardRoot.querySelector('[data-sm-cluster-scroll]');\n      if (scrollRoot && scrollRoot.dataset.smClusterScrollWheelBound !== 'true') {\n        scrollRoot.dataset.smClusterScrollWheelBound = 'true';\n        scrollRoot.addEventListener(\n          'wheel',\n          (event) => {\n            event.stopPropagation();\n          },\n          { passive: true }\n        );\n      }\n\n      refreshActiveHoverCardPosition();\n    };\n\n    const clusterIcon = buildClusterMarkerIcon(count);\n    const marker = L.marker([lat, lng], {\n      icon: clusterIcon,\n      pane: photoPaneName,\n      interactive: true,\n    }).addTo(runtime.clusterLayer);\n\n    const hoverState = {\n      markerHovered: false,\n      popupHovered: false,\n      openTimer: null,\n      closeTimer: null,\n    };\n\n    const bindClusterHoverCardInteractions = (rootElement) => {\n      if (!rootElement || !rootElement.querySelector) {\n        return;\n      }\n\n      const expandTarget = rootElement.querySelector('[data-sm-cluster-expand]');\n      if (expandTarget && !expandTarget.dataset.smClusterExpandBound) {\n        expandTarget.dataset.smClusterExpandBound = 'true';\n        expandTarget.addEventListener('click', (event) => {\n          event.preventDefault();\n          event.stopPropagation();\n          hoverState.popupHovered = true;\n          const hoverCardRoot = runtime.hoverCardRoot;\n          if (!hoverCardRoot) {\n            return;\n          }\n          applyClusterHoverIncrementalExpand(hoverCardRoot);\n        });\n      }\n\n      const scrollRoot = rootElement.querySelector('[data-sm-cluster-scroll]');\n      if (scrollRoot && scrollRoot.dataset.smClusterScrollWheelBound !== 'true') {\n        scrollRoot.dataset.smClusterScrollWheelBound = 'true';\n        scrollRoot.addEventListener(\n          'wheel',\n          (event) => {\n            event.stopPropagation();\n          },\n          { passive: true }\n        );\n      }\n    };\n\n    marker.on('mouseover', () => {\n      hoverState.markerHovered = true;\n      const iconElement = marker.getElement ? marker.getElement() : null;\n      if (iconElement) {\n        iconElement.style.cursor = 'pointer';\n      }\n      if (!hoverCardEnabled) return;\n      scheduleOpenHoverCard(hoverState, {\n        ownerId: clusterOwnerId,\n        latLng: [lat, lng],\n        contentHtml: buildClusterHoverHtml(clusterMarkers),\n        afterOpen: bindClusterHoverCardInteractions,\n      });\n    });\n\n    marker.on('mouseout', () => {\n      hoverState.markerHovered = false;\n      if (hoverCardEnabled) {\n        if (!hoverState.popupHovered) {\n          scheduleClose(hoverState, () => {\n            hideHoverCard(clusterOwnerId);\n          });\n        }\n      }\n    });\n\n    marker.on('click', () => {\n      const nextZoom = Math.min((map.getZoom ? map.getZoom() : 6) + 2, map.getMaxZoom ? map.getMaxZoom() : 18);\n      if (map.flyTo) {\n        map.flyTo([lat, lng], nextZoom);\n      }\n    });\n  };\n`\n}\n"
  },
  {
    "path": "web/src/features/map/injection/source/devEvalRuntimeScript.js",
    "content": "// 仅用于 Vite dev：宿主 postMessage `EVAL_SCRIPT` 时在 iframe 内执行的整段脚本。\n// 与 C++ 嵌入的 `bridgeScript` 无关；改此处后刷新地图即可验证，无需重链 native。\n// 页壳逻辑（如 sidebar 自动收起）只在 `bridgeScript.js` 里执行一次，此处不再重复。\n\nimport { buildRuntimeCoreSnippet } from './runtimeCore.js'\n\nexport function buildMapDevEvalScriptFromPayload(serializedPayload) {\n  return `\n(() => {\n  if (window.location.hostname !== 'myl.nuanpaper.com') return;\n${buildRuntimeCoreSnippet()}\n  const L = window.L;\n  const map = window.__SPINNING_MOMO_MAP__;\n  if (!L || !map) return;\n\n  const payload = ${serializedPayload};\n  mountOrUpdateMapRuntime({\n    L,\n    map,\n    markers: Array.isArray(payload.markers) ? payload.markers : [],\n    renderOptions: payload.renderOptions || {},\n    runtimeOptions: payload.runtimeOptions || {},\n  });\n})();\n`\n}\n"
  },
  {
    "path": "web/src/features/map/injection/source/iframeBootstrap.js",
    "content": "// 地图 iframe 文档加载后、在劫持 L / 收 postMessage 之前即可运行的页壳逻辑（不依赖 map 实例）。\n// 与 runtimeCore 内各 snippet 的区分：后者在 mountOrUpdateMapRuntime 内执行，可访问 runtime / map。\n\nexport function buildIframeBootstrapSnippet() {\n  return `\n  const autoCollapseSidebarOnce = () => {\n    if (window.__SPINNING_MOMO_MAP_SIDEBAR_COLLAPSED__) {\n      return;\n    }\n\n    const toggleSelector =\n      '#infinitynikki-map-oversea + div > div > div:nth-child(2) > div:first-child';\n    const collapse = () => {\n      const toggle = document.querySelector(toggleSelector);\n      if (!toggle) return false;\n      toggle.click();\n      window.__SPINNING_MOMO_MAP_SIDEBAR_COLLAPSED__ = true;\n      return true;\n    };\n\n    if (collapse()) {\n      return;\n    }\n\n    let attemptCount = 0;\n    const maxAttempts = 40;\n    const timer = setInterval(() => {\n      attemptCount += 1;\n      if (collapse() || attemptCount >= maxAttempts) {\n        clearInterval(timer);\n      }\n    }, 100);\n  };\n\n  autoCollapseSidebarOnce();\n`\n}\n"
  },
  {
    "path": "web/src/features/map/injection/source/index.d.ts",
    "content": "export function buildMapDevEvalScriptFromPayload(serializedPayload: string): string\nexport function buildMapBridgeScriptTemplate(): string\n"
  },
  {
    "path": "web/src/features/map/injection/source/index.js",
    "content": "export { buildMapDevEvalScriptFromPayload } from './devEvalRuntimeScript.js'\nexport { buildMapBridgeScriptTemplate } from './bridgeScript.js'\nexport { buildRuntimeCoreSnippet } from './runtimeCore.js'\n"
  },
  {
    "path": "web/src/features/map/injection/source/paneStyle.js",
    "content": "export function buildPaneStyleSnippet() {\n  return `\n  const photoPaneName = 'spinning-momo-photo-pane';\n\n  const ensureScopedPopupStyles = () => {\n    if (document.getElementById('spinning-momo-popup-style')) {\n      return;\n    }\n    const style = document.createElement('style');\n    style.id = 'spinning-momo-popup-style';\n    style.textContent = [\n      '.spinning-momo-hover-card-root {',\n      '  position: absolute;',\n      '  left: 0;',\n      '  top: 0;',\n      '  z-index: 1200;',\n      '  pointer-events: auto;',\n      '}',\n      '.spinning-momo-hover-card-root.is-hidden {',\n      '  display: none;',\n      '}',\n      '.spinning-momo-hover-card-shell {',\n      '  position: relative;',\n      '  display: block;',\n      '  width: max-content;',\n      '  max-width: 320px;',\n      '  border-radius: 12px;',\n      '  background: linear-gradient(rgb(240, 222, 208), rgb(245, 236, 227));',\n      '  color: rgb(123, 93, 74);',\n      '  box-shadow: 0 16px 40px rgba(15, 23, 42, 0.22);',\n      '  cursor: default !important;',\n      '  will-change: opacity, transform;',\n      '}',\n      '.spinning-momo-hover-card-root[data-placement=\"top\"] .spinning-momo-hover-card-shell {',\n      '  transform-origin: center bottom;',\n      '  animation: spinning-momo-hover-card-enter-from-bottom 160ms cubic-bezier(0.22, 1, 0.36, 1);',\n      '}',\n      '.spinning-momo-hover-card-root[data-placement=\"bottom\"] .spinning-momo-hover-card-shell {',\n      '  transform-origin: center top;',\n      '  animation: spinning-momo-hover-card-enter-from-top 160ms cubic-bezier(0.22, 1, 0.36, 1);',\n      '}',\n      '.spinning-momo-hover-card-inner {',\n      '  position: relative;',\n      '  z-index: 1;',\n      '  border-radius: 12px;',\n      '  overflow: hidden;',\n      '}',\n      '.spinning-momo-hover-card-caret {',\n      '  position: absolute;',\n      '  left: 50%;',\n      '  width: 14px;',\n      '  height: 14px;',\n      '  border-radius: 2px;',\n      '  background: linear-gradient(rgb(240, 222, 208), rgb(245, 236, 227));',\n      '  transform: translateX(-50%) rotate(45deg);',\n      '}',\n      '.spinning-momo-hover-card-root[data-placement=\"top\"] .spinning-momo-hover-card-caret {',\n      '  bottom: -7px;',\n      '}',\n      '.spinning-momo-hover-card-root[data-placement=\"bottom\"] .spinning-momo-hover-card-caret {',\n      '  top: -7px;',\n      '}',\n      '.spinning-momo-popup-body {',\n      '  display: block;',\n      '  box-sizing: border-box;',\n      '  width: auto;',\n      '  max-width: 320px;',\n      '  max-height: 320px;',\n      '  padding: 0.75rem;',\n      '}',\n      '.spinning-momo-popup-title {',\n      '  font-size: 13px;',\n      '  font-weight: 600;',\n      '  line-height: 1.5;',\n      '  margin-bottom: 4px;',\n      '  color: rgb(123, 93, 74);',\n      \"  font-family: 'Helvetica Neue', Arial, Helvetica, sans-serif;\",\n      '}',\n      '.spinning-momo-popup-thumbnail-block {',\n      '  margin-top: 8px;',\n      '  max-width: 100%;',\n      '}',\n      '.spinning-momo-popup-thumbnail-link {',\n      '  display: block;',\n      '  max-width: 296px;',\n      '  margin: 0;',\n      '}',\n      '.spinning-momo-popup-thumbnail-image {',\n      '  display: block;',\n      '  width: auto;',\n      '  height: auto;',\n      '  max-width: 296px;',\n      '  max-height: calc(320px - 4rem);',\n      '  border-radius: 6px;',\n      '  background: #f2f2f2;',\n      '}',\n      '.spinning-momo-popup-thumbnail-fallback {',\n      '  font-size: 12px;',\n      '  color: #888;',\n      '}',\n      '@keyframes spinning-momo-hover-card-enter-from-bottom {',\n      '  from {',\n      '    opacity: 0;',\n      '    transform: translateY(10px) scale(0.96);',\n      '  }',\n      '  to {',\n      '    opacity: 1;',\n      '    transform: translateY(0) scale(1);',\n      '  }',\n      '}',\n      '@keyframes spinning-momo-hover-card-enter-from-top {',\n      '  from {',\n      '    opacity: 0;',\n      '    transform: translateY(-10px) scale(0.96);',\n      '  }',\n      '  to {',\n      '    opacity: 1;',\n      '    transform: translateY(0) scale(1);',\n      '  }',\n      '}',\n    ].join('\\\\n');\n    document.head.appendChild(style);\n  };\n\n  const ensurePane = (paneName, zIndex) => {\n    let pane = map.getPane ? map.getPane(paneName) : null;\n    if (!pane && map.createPane) pane = map.createPane(paneName);\n    if (pane) {\n      pane.style.zIndex = String(zIndex);\n      pane.style.pointerEvents = 'auto';\n    }\n    return pane;\n  };\n\n  ensurePane(photoPaneName, 975);\n  ensureScopedPopupStyles();\n`\n}\n"
  },
  {
    "path": "web/src/features/map/injection/source/photoCardHtml.js",
    "content": "/**\n * 注入 runtime 内联片段：照片缩略图 cell 与单点悬停卡片 HTML（依赖同作用域内 popup.js 已定义的 escapeHtml）。\n */\nexport function buildPhotoCardSnippet() {\n  return `\n  const buildPhotoThumbCellHtml = (item) => {\n    if (!item || typeof item !== 'object') {\n      return (\n        '<div style=\"width:100%;height:100%;aspect-ratio:1/1;border-radius:6px;background:#1f2937;display:flex;align-items:center;justify-content:center;color:#9ca3af;font-size:11px;\">无图</div>'\n      );\n    }\n\n    const hasAssetId = Number.isFinite(Number(item.assetId));\n    const assetIdAttr = hasAssetId ? ' data-sm-open-asset-id=\"' + String(item.assetId) + '\"' : '';\n    const hasAssetIndex = Number.isFinite(Number(item.assetIndex));\n    const assetIndexAttr = hasAssetIndex\n      ? ' data-sm-open-asset-index=\"' + String(item.assetIndex) + '\"'\n      : '';\n    const cellStyle = hasAssetId ? 'width:100%;height:100%;cursor:pointer;' : 'width:100%;height:100%;';\n\n    if (item.thumbnailUrl) {\n      const innerHtml =\n        '<img src=\"' +\n        escapeHtml(String(item.thumbnailUrl)) +\n        '\" loading=\"lazy\" style=\"width:100%;height:100%;aspect-ratio:1/1;object-fit:cover;border-radius:6px;background:#1f2937;display:block;\" />';\n      return '<div' + assetIdAttr + assetIndexAttr + ' style=\"' + cellStyle + '\">' + innerHtml + '</div>';\n    }\n\n    const fallback =\n      '<div style=\"width:100%;height:100%;aspect-ratio:1/1;border-radius:6px;background:#1f2937;display:flex;align-items:center;justify-content:center;color:#9ca3af;font-size:11px;\">无图</div>';\n    return '<div' + assetIdAttr + assetIndexAttr + ' style=\"' + cellStyle + '\">' + fallback + '</div>';\n  };\n\n  const buildSinglePhotoHoverHtml = (marker) => {\n    const title = escapeHtml(String(marker && marker.cardTitle != null ? marker.cardTitle : ''));\n    const thumbnailUrl = marker && marker.thumbnailUrl ? String(marker.thumbnailUrl) : '';\n    const hasAssetId = marker && Number.isFinite(Number(marker.assetId));\n    const hasAssetIndex = marker && Number.isFinite(Number(marker.assetIndex));\n    const assetIdAttr = hasAssetId ? ' data-sm-open-asset-id=\"' + String(Number(marker.assetId)) + '\"' : '';\n    const assetIndexAttr = hasAssetIndex\n      ? ' data-sm-open-asset-index=\"' + String(Number(marker.assetIndex)) + '\"'\n      : '';\n    const cursorStyle = hasAssetId ? 'cursor:pointer;' : '';\n\n    let thumbBlock = '';\n    if (thumbnailUrl) {\n      thumbBlock =\n        '<div class=\"spinning-momo-popup-thumbnail-block\">' +\n        '<div class=\"spinning-momo-popup-thumbnail-link\" style=\"' +\n        cursorStyle +\n        '\"' +\n        assetIdAttr +\n        assetIndexAttr +\n        '>' +\n        '<img class=\"spinning-momo-popup-thumbnail-image\" src=\"' +\n        escapeHtml(thumbnailUrl) +\n        '\" alt=\"' +\n        title +\n        '\" loading=\"eager\" decoding=\"async\" />' +\n        '</div>' +\n        '</div>';\n    } else {\n      thumbBlock =\n        '<div class=\"spinning-momo-popup-thumbnail-block\">' +\n        '<div class=\"spinning-momo-popup-thumbnail-link\" style=\"' +\n        cursorStyle +\n        '\"' +\n        assetIdAttr +\n        assetIndexAttr +\n        '>' +\n        '<div class=\"spinning-momo-popup-thumbnail-fallback\">无图</div>' +\n        '</div>' +\n        '</div>';\n    }\n\n    return (\n      '<div style=\"line-height: 1.5;\">' +\n      '<div class=\"spinning-momo-popup-body\">' +\n      '<div class=\"spinning-momo-popup-title\">' +\n      title +\n      '</div>' +\n      thumbBlock +\n      '</div>' +\n      '</div>'\n    );\n  };\n`\n}\n"
  },
  {
    "path": "web/src/features/map/injection/source/popup.js",
    "content": "export function buildPopupSnippet() {\n  return `\n  const closePopupOnMouseOut = renderOptions.closePopupOnMouseOut !== false;\n  const popupOpenDelayMs = Math.max(0, Number(renderOptions.popupOpenDelayMs ?? 180));\n  const popupCloseDelayMs = Math.max(0, Number(renderOptions.popupCloseDelayMs ?? 260));\n  const keepPopupVisibleOnHover = renderOptions.keepPopupVisibleOnHover !== false;\n  const hoverCardBottomOffsetPx = 16;\n  const hoverCardTopOffsetPx = 52;\n\n  const scheduleOpen = (state, callback) => {\n    if (state.closeTimer) {\n      clearTimeout(state.closeTimer);\n      state.closeTimer = null;\n    }\n    if (state.openTimer) {\n      clearTimeout(state.openTimer);\n    }\n    state.openTimer = setTimeout(() => {\n      state.openTimer = null;\n      callback();\n    }, popupOpenDelayMs);\n  };\n\n  const scheduleClose = (state, callback) => {\n    if (state.openTimer) {\n      clearTimeout(state.openTimer);\n      state.openTimer = null;\n    }\n    if (state.closeTimer) {\n      clearTimeout(state.closeTimer);\n    }\n    state.closeTimer = setTimeout(() => {\n      state.closeTimer = null;\n      callback();\n    }, popupCloseDelayMs);\n  };\n\n  const invalidatePendingPopupOpen = (state) => {\n    if (state.openTimer) {\n      clearTimeout(state.openTimer);\n      state.openTimer = null;\n    }\n  };\n\n  const escapeHtml = (value) => {\n    return String(value || '')\n      .replace(/&/g, '&amp;')\n      .replace(/</g, '&lt;')\n      .replace(/>/g, '&gt;')\n      .replace(/\"/g, '&quot;')\n      .replace(/'/g, '&#39;');\n  };\n\n  const bindPopupCardClickBridge = (rootElement) => {\n    if (!rootElement || !rootElement.querySelectorAll) return;\n\n    const clickableNodes = rootElement.querySelectorAll('[data-sm-open-asset-id]');\n    clickableNodes.forEach((node) => {\n      const el = node;\n      if (!el || !el.getAttribute) return;\n      const dataset = el.dataset || {};\n      if (dataset.smCardClickBound === 'true') {\n        return;\n      }\n\n      dataset.smCardClickBound = 'true';\n\n      el.addEventListener('click', (event) => {\n        event.preventDefault();\n        event.stopPropagation();\n        const rawAssetId = el.getAttribute('data-sm-open-asset-id');\n        const assetId = Number(rawAssetId);\n        if (!Number.isFinite(assetId)) {\n          return;\n        }\n\n        const rawAssetIndex = el.getAttribute('data-sm-open-asset-index');\n        const hasIndex = rawAssetIndex !== null && rawAssetIndex !== undefined;\n        const assetIndex = hasIndex ? Number(rawAssetIndex) : undefined;\n        if (hasIndex && !Number.isFinite(assetIndex)) {\n          return;\n        }\n\n        if (window.parent && window.parent !== window) {\n          window.parent.postMessage(\n            {\n              action: 'SPINNING_MOMO_OPEN_GALLERY_ASSET',\n              payload: hasIndex ? { assetId, assetIndex } : { assetId },\n            },\n            '*'\n          );\n        }\n      });\n    });\n  };\n\n  const ensureHoverCardRoot = () => {\n    const container = map && map.getContainer ? map.getContainer() : null;\n    if (!container) {\n      return null;\n    }\n\n    if (runtime.hoverCardRoot && runtime.hoverCardRoot.isConnected) {\n      return runtime.hoverCardRoot;\n    }\n\n    const root = document.createElement('div');\n    root.className = 'spinning-momo-hover-card-root is-hidden';\n    root.addEventListener('mouseenter', () => {\n      if (!keepPopupVisibleOnHover) {\n        return;\n      }\n      const activeContext = runtime.activeHoverCardContext;\n      if (!activeContext) {\n        return;\n      }\n      activeContext.state.popupHovered = true;\n      if (activeContext.state.closeTimer) {\n        clearTimeout(activeContext.state.closeTimer);\n        activeContext.state.closeTimer = null;\n      }\n    });\n\n    root.addEventListener('mouseleave', () => {\n      if (!keepPopupVisibleOnHover) {\n        return;\n      }\n      const activeContext = runtime.activeHoverCardContext;\n      if (!activeContext) {\n        return;\n      }\n      activeContext.state.popupHovered = false;\n      if (!activeContext.state.markerHovered && closePopupOnMouseOut) {\n        scheduleClose(activeContext.state, () => hideHoverCard(activeContext.ownerId));\n      }\n    });\n\n    container.appendChild(root);\n    runtime.hoverCardRoot = root;\n    return root;\n  };\n\n  const hideHoverCard = (ownerId) => {\n    if (ownerId && runtime.activeHoverCardOwner && runtime.activeHoverCardOwner !== ownerId) {\n      return;\n    }\n\n    const root = runtime.hoverCardRoot;\n    if (!root) {\n      runtime.activeHoverCardOwner = null;\n      runtime.activeHoverCardContext = null;\n      return;\n    }\n\n    root.innerHTML = '';\n    root.classList.add('is-hidden');\n    root.removeAttribute('data-placement');\n    runtime.activeHoverCardOwner = null;\n    runtime.activeHoverCardContext = null;\n  };\n\n  const bindHoverCardMapCloseBridge = () => {\n    if (runtime.boundHideHoverCardOnMapMove || !map || !map.on) {\n      return;\n    }\n\n    runtime.boundHideHoverCardOnMapMove = () => {\n      hideHoverCard();\n    };\n    map.on('movestart', runtime.boundHideHoverCardOnMapMove);\n    map.on('zoomstart', runtime.boundHideHoverCardOnMapMove);\n    map.on('resize', runtime.boundHideHoverCardOnMapMove);\n  };\n\n  const getHoverCardPlacement = (latLng) => {\n    const container = map && map.getContainer ? map.getContainer() : null;\n    const point = map && map.latLngToContainerPoint ? map.latLngToContainerPoint(latLng) : null;\n    if (!container || !point) {\n      return 'top';\n    }\n\n    const containerHeight = Number(container.clientHeight || 0);\n    if (!Number.isFinite(containerHeight) || containerHeight <= 0) {\n      return 'top';\n    }\n\n    return Number(point.y || 0) < containerHeight / 2 ? 'bottom' : 'top';\n  };\n\n  const positionHoverCardRoot = (root, latLng, placement) => {\n    if (!root || !map || !map.latLngToContainerPoint) {\n      return false;\n    }\n\n    const point = map.latLngToContainerPoint(latLng);\n    if (!point) {\n      return false;\n    }\n\n    root.style.left = String(Number(point.x || 0)) + 'px';\n    if (placement === 'bottom') {\n      root.style.top = String(Number(point.y || 0) + hoverCardBottomOffsetPx) + 'px';\n      root.style.transform = 'translate(-50%, 0)';\n    } else {\n      root.style.top = String(Number(point.y || 0) - hoverCardTopOffsetPx) + 'px';\n      root.style.transform = 'translate(-50%, -100%)';\n    }\n    root.dataset.placement = placement;\n    return true;\n  };\n\n  const refreshActiveHoverCardPosition = () => {\n    const ctx = runtime.activeHoverCardContext;\n    const root = runtime.hoverCardRoot;\n    if (!ctx || !root || !ctx.latLng) {\n      return;\n    }\n    const nextPlacement = getHoverCardPlacement(ctx.latLng);\n    positionHoverCardRoot(root, ctx.latLng, nextPlacement);\n  };\n\n  const showHoverCard = (state, options) => {\n    if (!options || !options.latLng) {\n      return null;\n    }\n\n    bindHoverCardMapCloseBridge();\n    const root = ensureHoverCardRoot();\n    if (!root) {\n      return null;\n    }\n\n    const placement = options.placement || getHoverCardPlacement(options.latLng);\n    root.innerHTML =\n      '<div class=\"spinning-momo-hover-card-shell\">' +\n      '<div class=\"spinning-momo-hover-card-inner\">' +\n      options.contentHtml +\n      '</div>' +\n      '<div class=\"spinning-momo-hover-card-caret\"></div>' +\n      '</div>';\n\n    if (!positionHoverCardRoot(root, options.latLng, placement)) {\n      root.innerHTML = '';\n      return null;\n    }\n\n    runtime.activeHoverCardOwner = options.ownerId || null;\n    runtime.activeHoverCardContext = {\n      ownerId: options.ownerId || null,\n      state,\n      latLng: options.latLng,\n    };\n    root.classList.remove('is-hidden');\n\n    bindPopupCardClickBridge(root);\n    if (typeof options.afterOpen === 'function') {\n      options.afterOpen(root);\n    }\n\n    return root;\n  };\n\n  const scheduleOpenHoverCard = (state, cardOptions) => {\n    if (!cardOptions || !cardOptions.latLng || !cardOptions.contentHtml) {\n      return;\n    }\n    scheduleOpen(state, () => {\n      if (!state.markerHovered && !state.popupHovered) {\n        return;\n      }\n      showHoverCard(state, {\n        ownerId: cardOptions.ownerId,\n        latLng: cardOptions.latLng,\n        contentHtml: cardOptions.contentHtml,\n        afterOpen: cardOptions.afterOpen,\n        placement: cardOptions.placement,\n      });\n    });\n  };\n`\n}\n"
  },
  {
    "path": "web/src/features/map/injection/source/render.js",
    "content": "export function buildRenderSnippet() {\n  return `\n  const applyMapBackground = () => {\n    const container = map && map.getContainer ? map.getContainer() : null;\n    if (!container) return;\n    const color = renderOptions.mapBackgroundColor || '#C7BFA7';\n    container.style.backgroundColor = String(color);\n  };\n\n  const defaultPinBg =\n    'https://assets.papegames.com/nikkiweb/infinitynikki/infinitynikki-map/img/58ca045d59db0f9cd8ad.png';\n  const pinBgUrl = renderOptions.markerPinBackgroundUrl || defaultPinBg;\n  const pinSize = 36;\n  const markerIconSize = renderOptions.markerIconSize || [24, 24];\n  const rawW = Number(markerIconSize[0]);\n  const rawH = Number(markerIconSize[1]);\n  const itemW = Number.isFinite(rawW) && rawW > 0 ? rawW : 24;\n  const itemH = Number.isFinite(rawH) && rawH > 0 ? rawH : itemW;\n\n  const buildCompositePinIcon = (overlayInnerHtml) => {\n    if (!L.divIcon) return null;\n    const html =\n      '<div class=\"spinning-momo-pin-root\" style=\"width:' +\n      pinSize +\n      'px;height:' +\n      pinSize +\n      'px;position:relative;overflow:visible;\">' +\n      '<div style=\"position:absolute;inset:0;background-image:url(\\\\'' +\n      pinBgUrl +\n      '\\\\');background-size:contain;background-repeat:no-repeat;background-position:center;pointer-events:none;\"></div>' +\n      overlayInnerHtml +\n      '</div>';\n    return L.divIcon({\n      className: 'spinning-momo-composite-pin',\n      html: html,\n      iconSize: [pinSize, pinSize],\n      iconAnchor: [pinSize / 2, pinSize],\n    });\n  };\n\n  const buildSingleMarkerIcon = () => {\n    const itemUrl = renderOptions.markerIconUrl || '';\n    const overlay = itemUrl\n      ? '<img src=\"' +\n        itemUrl +\n        '\" alt=\"\" style=\"position:absolute;left:50%;top:40%;transform:translate(calc(-50% - 0.25px),calc(-50% + 1.8px));width:' +\n        itemW +\n        'px;height:' +\n        itemH +\n        'px;object-fit:contain;pointer-events:none;z-index:1;\" />'\n      : '';\n    return buildCompositePinIcon(overlay);\n  };\n\n  const buildClusterMarkerIcon = (count) => {\n    const overlay =\n      '<div style=\"position:absolute;left:0;top:0;right:0;bottom:0;display:flex;align-items:center;justify-content:center;color:#fff;font-weight:700;font-size:15px;line-height:1;text-shadow:0 1px 3px rgba(0,0,0,0.85);pointer-events:none;transform:translateY(-3px);\">' +\n      count +\n      '</div>';\n    return buildCompositePinIcon(overlay);\n  };\n\n  const renderSingleMarker = (markerData) => {\n    const compositeIcon = buildSingleMarkerIcon();\n    const markerOwnerId =\n      'marker:' + String(markerData.assetId ?? markerData.name ?? (String(markerData.lat) + ',' + String(markerData.lng)));\n    const markerOptions = {\n      pane: photoPaneName,\n      interactive: true,\n    };\n    if (compositeIcon) markerOptions.icon = compositeIcon;\n\n    const marker = L.marker([markerData.lat, markerData.lng], markerOptions).addTo(runtime.markerLayer);\n    const hoverState = {\n      markerHovered: false,\n      popupHovered: false,\n      openTimer: null,\n      closeTimer: null,\n    };\n    marker.on('add', () => {\n      const iconElement = marker.getElement ? marker.getElement() : null;\n      if (iconElement) {\n        iconElement.style.cursor = 'pointer';\n        iconElement.style.pointerEvents = 'auto';\n      }\n    });\n\n    if (hoverCardEnabled) {\n      marker.on('mouseover', () => {\n        hoverState.markerHovered = true;\n        scheduleOpenHoverCard(hoverState, {\n          ownerId: markerOwnerId,\n          latLng: [markerData.lat, markerData.lng],\n          contentHtml: buildSinglePhotoHoverHtml(markerData),\n        });\n      });\n      if (closePopupOnMouseOut) {\n        marker.on('mouseout', () => {\n          hoverState.markerHovered = false;\n          invalidatePendingPopupOpen(hoverState);\n          if (!hoverState.popupHovered) {\n            scheduleClose(hoverState, () => hideHoverCard(markerOwnerId));\n          }\n        });\n      }\n    }\n  };\n\n  const render = () => {\n    applyMapBackground();\n    hideHoverCard();\n    runtime.markerLayer.clearLayers();\n    runtime.clusterLayer.clearLayers();\n\n    const markersVisible = runtimeOptions.markersVisible !== false;\n    if (!markersVisible) {\n      return;\n    }\n\n    if (!clusterEnabled || markers.length <= 1 || !map.latLngToLayerPoint) {\n      markers.forEach(renderSingleMarker);\n    } else {\n      const grouped = new Map();\n      for (const item of markers) {\n        if (item.lat === undefined || item.lng === undefined) continue;\n        const point = map.latLngToLayerPoint([item.lat, item.lng]);\n        const gridX = Math.round(point.x / clusterRadius);\n        const gridY = Math.round(point.y / clusterRadius);\n        const key = gridX + ':' + gridY;\n        if (!grouped.has(key)) grouped.set(key, []);\n        grouped.get(key).push(item);\n      }\n\n      grouped.forEach((clusterMarkers) => {\n        if (clusterMarkers.length <= 1) {\n          renderSingleMarker(clusterMarkers[0]);\n          return;\n        }\n        renderClusterMarker(clusterMarkers);\n      });\n    }\n\n    if (shouldFlyToFirst && markers.length > 0) {\n      const firstMarker = markers[0];\n      if (firstMarker?.lat !== undefined && firstMarker?.lng !== undefined && map.flyTo) {\n        map.flyTo([firstMarker.lat, firstMarker.lng], 6);\n      }\n    }\n  };\n\n  runtime.render = render;\n  render();\n\n  if (!runtime.boundRecluster) {\n    runtime.boundRecluster = () => {\n      if (runtime.render) runtime.render();\n    };\n    if (map.on) {\n      map.on('zoomend', runtime.boundRecluster);\n      map.on('moveend', runtime.boundRecluster);\n    }\n  }\n`\n}\n"
  },
  {
    "path": "web/src/features/map/injection/source/runtimeCore.js",
    "content": "import { buildPaneStyleSnippet } from './paneStyle.js'\nimport { buildPopupSnippet } from './popup.js'\nimport { buildPhotoCardSnippet } from './photoCardHtml.js'\nimport { buildClusterSnippet } from './cluster.js'\nimport { buildRenderSnippet } from './render.js'\nimport { buildToolbarSnippet } from './toolbar.js'\n\nexport function buildRuntimeCoreSnippet() {\n  return `\n  const mountOrUpdateMapRuntime = (payload) => {\n    const L = payload && payload.L;\n    const map = payload && payload.map;\n    if (!L || !map) return;\n\n    const markers = Array.isArray(payload.markers) ? payload.markers : [];\n    const renderOptions = payload.renderOptions || {};\n    const runtimeOptions = payload.runtimeOptions || {};\n    const shouldFlyToFirst = payload.flyToFirst === true;\n    const clusterEnabled = runtimeOptions.clusterEnabled !== false;\n    const clusterRadius = Number(runtimeOptions.clusterRadius || 44);\n    const hoverCardEnabled = runtimeOptions.hoverCardEnabled !== false;\n\n${buildPaneStyleSnippet()}\n    if (!window.__SPINNING_MOMO_RUNTIME__) {\n      window.__SPINNING_MOMO_RUNTIME__ = {};\n    }\n    const runtime = window.__SPINNING_MOMO_RUNTIME__;\n\n    if (runtime.boundMap && runtime.boundMap !== map) {\n      if (runtime.boundRecluster && runtime.boundMap.off) {\n        runtime.boundMap.off('zoomend', runtime.boundRecluster);\n        runtime.boundMap.off('moveend', runtime.boundRecluster);\n      }\n      if (runtime.boundHideHoverCardOnMapMove && runtime.boundMap.off) {\n        runtime.boundMap.off('movestart', runtime.boundHideHoverCardOnMapMove);\n        runtime.boundMap.off('zoomstart', runtime.boundHideHoverCardOnMapMove);\n        runtime.boundMap.off('resize', runtime.boundHideHoverCardOnMapMove);\n      }\n      if (runtime.markerLayer && runtime.markerLayer.remove) {\n        runtime.markerLayer.remove();\n      }\n      if (runtime.clusterLayer && runtime.clusterLayer.remove) {\n        runtime.clusterLayer.remove();\n      }\n      if (runtime.hoverCardRoot && runtime.hoverCardRoot.remove) {\n        runtime.hoverCardRoot.remove();\n      }\n      runtime.markerLayer = null;\n      runtime.clusterLayer = null;\n      runtime.boundRecluster = null;\n      runtime.hoverCardRoot = null;\n      runtime.boundHideHoverCardOnMapMove = null;\n      runtime.activeHoverCardOwner = null;\n      runtime.activeHoverCardContext = null;\n    }\n\n    runtime.boundMap = map;\n\n    if (!runtime.markerLayer) {\n      runtime.markerLayer = L.layerGroup().addTo(map);\n    }\n    if (!runtime.clusterLayer) {\n      runtime.clusterLayer = L.layerGroup().addTo(map);\n    }\n\n    runtime.markers = markers;\n    runtime.renderOptions = renderOptions;\n    runtime.runtimeOptions = runtimeOptions;\n\n${buildPopupSnippet()}\n${buildPhotoCardSnippet()}\n${buildClusterSnippet()}\n${buildToolbarSnippet()}\n${buildRenderSnippet()}\n  };\n`\n}\n"
  },
  {
    "path": "web/src/features/map/injection/source/toolbar.js",
    "content": "export function buildToolbarSnippet() {\n  return `\n  const markerToggleHostSelector = '#infinitynikki-map-oversea + div > div > div:nth-child(2)';\n  const markerToggleButtonId = 'spinning-momo-marker-toggle-button';\n  const markerToggleVisibleBg = '#F5DCB1E6';\n  const markerToggleHiddenBg = '#4D3E2AE6';\n\n  const syncMarkerToggleButton = (button) => {\n    if (!button) return;\n\n    const currentRuntimeOptions = runtime.runtimeOptions || {};\n    const currentRenderOptions = runtime.renderOptions || {};\n    const markersVisible = currentRuntimeOptions.markersVisible !== false;\n    const markerIconUrl = currentRenderOptions.markerIconUrl || '';\n\n    button.style.backgroundColor = markersVisible ? markerToggleVisibleBg : markerToggleHiddenBg;\n    button.setAttribute('aria-pressed', markersVisible ? 'true' : 'false');\n    button.title = markersVisible ? '隐藏照片标点' : '显示照片标点';\n\n    let icon = button.querySelector('img');\n    if (markerIconUrl) {\n      if (!icon) {\n        icon = document.createElement('img');\n        icon.alt = '';\n        icon.setAttribute('aria-hidden', 'true');\n        icon.style.width = '32px';\n        icon.style.height = '32px';\n        icon.style.objectFit = 'contain';\n        icon.style.pointerEvents = 'none';\n        button.appendChild(icon);\n      }\n      icon.src = markerIconUrl;\n    } else if (icon && icon.parentNode === button) {\n      icon.remove();\n      icon = null;\n    }\n\n    if (!icon) {\n      button.textContent = '•';\n      button.style.color = '#FFF7EA';\n      button.style.fontSize = '18px';\n      button.style.lineHeight = '1';\n    } else if (button.textContent) {\n      button.textContent = '';\n      button.appendChild(icon);\n    }\n  };\n\n  const mountMarkerToggleButton = () => {\n    const host = document.querySelector(markerToggleHostSelector);\n    if (!host) return false;\n\n    let button = document.getElementById(markerToggleButtonId);\n    if (!button) {\n      button = document.createElement('button');\n      button.id = markerToggleButtonId;\n      button.type = 'button';\n      button.style.display = 'flex';\n      button.style.alignItems = 'center';\n      button.style.justifyContent = 'center';\n      button.style.boxSizing = 'border-box';\n      button.style.width = '39px';\n      button.style.height = '39px';\n      button.style.padding = '8.125px';\n      button.style.margin = '0';\n      button.style.border = 'none';\n      button.style.borderRadius = '6.5px';\n      button.style.cursor = 'pointer';\n      button.style.webkitTapHighlightColor = 'rgba(0, 0, 0, 0)';\n      button.style.fontFamily =\n        'FZYASHJW_ZHUN, system-ui, -apple-system, \"Segoe UI\", Roboto, Ubuntu, Cantarell, \"Noto Sans\", sans-serif, \"STHeiti SC\", \"Microsoft YaHei\", \"Helvetica Neue\", Helvetica, Arial';\n      button.style.fontSize = '14px';\n      button.style.flexShrink = '0';\n      button.addEventListener('click', (event) => {\n        event.preventDefault();\n        event.stopPropagation();\n        const currentRuntimeOptions = runtime.runtimeOptions || {};\n        const nextVisible = currentRuntimeOptions.markersVisible === false;\n\n        if (window.parent && window.parent !== window) {\n          window.parent.postMessage(\n            {\n              action: 'SPINNING_MOMO_SET_MARKERS_VISIBLE',\n              payload: { markersVisible: nextVisible },\n            },\n            '*'\n          );\n        }\n      });\n    }\n\n    if (button.parentElement !== host) {\n      host.appendChild(button);\n    }\n\n    syncMarkerToggleButton(button);\n    return true;\n  };\n\n  const ensureMarkerToggleButton = () => {\n    if (mountMarkerToggleButton()) {\n      return;\n    }\n\n    if (runtime.markerToggleMountTimer) {\n      return;\n    }\n\n    let attemptCount = 0;\n    const maxAttempts = 40;\n    runtime.markerToggleMountTimer = setInterval(() => {\n      attemptCount += 1;\n      if (mountMarkerToggleButton() || attemptCount >= maxAttempts) {\n        clearInterval(runtime.markerToggleMountTimer);\n        runtime.markerToggleMountTimer = null;\n      }\n    }, 100);\n  };\n\n  ensureMarkerToggleButton();\n`\n}\n"
  },
  {
    "path": "web/src/features/map/pages/MapPage.vue",
    "content": "<script setup lang=\"ts\">\nimport { onMounted } from 'vue'\nimport { useMapScene } from '@/features/map/composables/useMapScene'\nimport { useMapStore } from '@/features/map/store'\n\ndefineOptions({\n  name: 'MapPage',\n})\n\nconst mapStore = useMapStore()\nconst { mapPoints, initializeMapDefaults } = useMapScene()\n\nonMounted(() => {\n  initializeMapDefaults()\n})\n</script>\n\n<template>\n  <div class=\"relative flex h-full w-full flex-col bg-background pt-[var(--titlebar-height,20px)]\">\n    <div class=\"z-10 flex h-12 w-full shrink-0 items-center border-b bg-background px-4\">\n      <h1 class=\"text-base font-semibold\">官方地图</h1>\n      <div class=\"ml-auto text-sm text-muted-foreground\">\n        <span v-if=\"mapStore.isLoading\">正在同步照片坐标…</span>\n        <span v-else>当前筛选下 {{ mapPoints.length }} 张照片</span>\n      </div>\n    </div>\n    <div class=\"relative w-full flex-1\"></div>\n  </div>\n</template>\n"
  },
  {
    "path": "web/src/features/map/store.ts",
    "content": "import { defineStore } from 'pinia'\nimport { ref } from 'vue'\n\nexport interface MapMarker {\n  assetId?: number\n  /** 与当前图库查询排序一致的资产序号，用于从地图打开时定位 lightbox */\n  assetIndex?: number\n  name?: string\n  lat: number\n  lng: number\n  /** 悬停卡片标题（宿主侧已按语言格式化） */\n  cardTitle: string\n  thumbnailUrl?: string\n  fileCreatedAt?: number\n}\n\nexport interface MapRenderOptions {\n  /** Leaflet 地图容器底色，用于填充无瓦片区域 */\n  mapBackgroundColor?: string\n  /** 游戏地图标点外框底图（PNG） */\n  markerPinBackgroundUrl?: string\n  markerIconUrl?: string\n  markerIconSize?: [number, number]\n  markerIconAnchor?: [number, number]\n  closePopupOnMouseOut?: boolean\n  popupOpenDelayMs?: number\n  popupCloseDelayMs?: number\n  keepPopupVisibleOnHover?: boolean\n}\n\nexport interface MapRuntimeOptions {\n  clusterEnabled: boolean\n  clusterRadius: number\n  hoverCardEnabled: boolean\n  markersVisible: boolean\n  thumbnailBaseUrl: string\n  /** 聚合 hover 标题模板，含 {count}，由 i18n 注入 */\n  clusterTitleTemplate?: string\n}\n\nexport const useMapStore = defineStore('map', () => {\n  const markers = ref<MapMarker[]>([])\n  const isLoading = ref(false)\n  const renderOptions = ref<MapRenderOptions>({})\n  const runtimeOptions = ref<MapRuntimeOptions>({\n    clusterEnabled: true,\n    clusterRadius: 44,\n    hoverCardEnabled: true,\n    markersVisible: true,\n    thumbnailBaseUrl: 'http://127.0.0.1:51206',\n  })\n\n  const replaceMarkers = (nextMarkers: MapMarker[]) => {\n    markers.value = nextMarkers\n  }\n\n  const setRenderOptions = (nextOptions: MapRenderOptions) => {\n    renderOptions.value = { ...nextOptions }\n  }\n\n  const patchRuntimeOptions = (nextOptions: Partial<MapRuntimeOptions>) => {\n    runtimeOptions.value = {\n      ...runtimeOptions.value,\n      ...nextOptions,\n    }\n  }\n\n  const setLoading = (nextLoading: boolean) => {\n    isLoading.value = nextLoading\n  }\n\n  return {\n    markers,\n    isLoading,\n    renderOptions,\n    runtimeOptions,\n    replaceMarkers,\n    setRenderOptions,\n    patchRuntimeOptions,\n    setLoading,\n  }\n})\n"
  },
  {
    "path": "web/src/features/onboarding/api.ts",
    "content": "import { call } from '@/core/rpc'\nimport { isWebView } from '@/core/env'\nimport type { FileInfoResult, InfinityNikkiGameDirResult } from './types'\n\nexport const onboardingApi = {\n  detectInfinityNikkiGameDirectory: async (): Promise<InfinityNikkiGameDirResult> => {\n    return call<InfinityNikkiGameDirResult>('extensions.infinityNikki.getGameDirectory', {})\n  },\n\n  selectDirectory: async (title: string): Promise<string | null> => {\n    const parentWindowMode = isWebView() ? 1 : 2\n    const result = await call<{ path: string }>(\n      'dialog.openDirectory',\n      {\n        title,\n        parentWindowMode,\n      },\n      0\n    )\n\n    return result.path || null\n  },\n\n  getFileInfo: async (path: string): Promise<FileInfoResult> => {\n    return call<FileInfoResult>('file.getInfo', { path })\n  },\n}\n"
  },
  {
    "path": "web/src/features/onboarding/pages/OnboardingPage.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed, onMounted, ref, watch } from 'vue'\nimport { Button } from '@/components/ui/button'\nimport { Input } from '@/components/ui/input'\nimport WindowTitlePickerButton from '@/components/WindowTitlePickerButton.vue'\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from '@/components/ui/select'\nimport { useSettingsStore } from '@/features/settings/store'\nimport {\n  CURRENT_ONBOARDING_FLOW_VERSION,\n  DARK_FLOATING_WINDOW_COLORS,\n  LIGHT_FLOATING_WINDOW_COLORS,\n  type AppSettings,\n  type FloatingWindowThemeMode,\n  type WebThemeMode,\n} from '@/features/settings/types'\nimport { applyAppearanceToDocument } from '@/features/settings/appearance'\nimport { OVERLAY_PALETTE_PRESETS } from '@/features/settings/overlayPalette'\nimport { useI18n } from '@/composables/useI18n'\nimport { onboardingApi } from '../api'\n\ntype Step = 1 | 2 | 3\n\nconst store = useSettingsStore()\nconst { t, setLocale } = useI18n()\n\nconst step = ref<Step>(1)\nconst direction = ref<'forward' | 'backward'>('forward')\nconst isSubmitting = ref(false)\nconst stepError = ref('')\n\nconst language = ref<string>(store.appSettings.app.language.current)\nconst normalizeOnboardingTheme = (mode: WebThemeMode): 'light' | 'dark' =>\n  mode === 'dark' ? 'dark' : 'light'\n\nconst themeMode = ref<'light' | 'dark'>(\n  normalizeOnboardingTheme(store.appSettings.ui.webTheme.mode)\n)\nconst targetTitle = ref<string>(store.appSettings.window.targetTitle || '')\nconst configuredInfinityNikkiGameDir = ref<string>(\n  store.appSettings.extensions.infinityNikki.gameDir\n)\nconst resolvedInfinityNikkiGameDir = ref<string | null>(null)\n\nconst isFirstStep = computed(() => step.value === 1)\nconst isLastStep = computed(() => step.value === 2)\nconst stepTransitionName = computed(() =>\n  direction.value === 'forward' ? 'ob-step-forward' : 'ob-step-backward'\n)\nconst getDefaultTargetTitle = (lang: string) =>\n  lang === 'en-US' ? 'Infinity Nikki  ' : '无限暖暖  '\nconst isDefaultInfinityNikkiTargetTitle = (title: string) =>\n  title === '无限暖暖  ' || title === 'Infinity Nikki  '\nconst isInfinityNikkiUser = computed(() => resolvedInfinityNikkiGameDir.value !== null)\n\nconst getDefaultOverlayColorsByTheme = (mode: 'light' | 'dark'): string[] => {\n  const firstPreset = OVERLAY_PALETTE_PRESETS.find((preset) => preset.themeMode === mode)\n\n  if (!firstPreset) {\n    return [...store.appSettings.ui.background.overlayColors]\n  }\n\n  return firstPreset.colors.slice(0, firstPreset.mode)\n}\n\nconst getFloatingWindowThemeByWebTheme = (mode: 'light' | 'dark'): FloatingWindowThemeMode => mode\n\nconst getFloatingWindowColorsByWebTheme = (mode: 'light' | 'dark') => {\n  const floatingWindowTheme = getFloatingWindowThemeByWebTheme(mode)\n  return floatingWindowTheme === 'light'\n    ? LIGHT_FLOATING_WINDOW_COLORS\n    : DARK_FLOATING_WINDOW_COLORS\n}\n\nconst syncPreviewAppearance = () => {\n  const previewSettings: AppSettings = {\n    ...store.appSettings,\n    ui: {\n      ...store.appSettings.ui,\n      webTheme: {\n        ...store.appSettings.ui.webTheme,\n        mode: themeMode.value,\n      },\n      background: {\n        ...store.appSettings.ui.background,\n        overlayColors: getDefaultOverlayColorsByTheme(themeMode.value),\n        overlayOpacity: 1,\n      },\n    },\n  }\n  applyAppearanceToDocument(previewSettings)\n}\n\nwatch(\n  () => language.value,\n  async (nextLanguage) => {\n    await setLocale(nextLanguage as 'zh-CN' | 'en-US')\n\n    if (isInfinityNikkiUser.value && isDefaultInfinityNikkiTargetTitle(targetTitle.value)) {\n      targetTitle.value = getDefaultTargetTitle(nextLanguage)\n    }\n  }\n)\n\nwatch(\n  () => themeMode.value,\n  () => {\n    syncPreviewAppearance()\n  },\n  { immediate: true }\n)\n\nconst buildInfinityNikkiExePath = (dir: string) => {\n  const base = dir.replace(/\\\\/g, '/').replace(/\\/+$/, '')\n  return `${base}/InfinityNikki.exe`\n}\n\nconst normalizeDirectoryPath = (dir: string) => dir.trim().replace(/[\\\\/]+$/, '')\n\nconst resolveInfinityNikkiAlbumPath = (dir: string): string => {\n  const base = normalizeDirectoryPath(dir).replace(/\\\\/g, '/')\n  return `${base}/X6Game/ScreenShot`\n}\n\nconst isValidInfinityNikkiGameDir = async (dir: string): Promise<boolean> => {\n  const normalizedDir = normalizeDirectoryPath(dir)\n  if (!normalizedDir) {\n    return false\n  }\n\n  const dirInfo = await onboardingApi.getFileInfo(normalizedDir)\n  if (!dirInfo.exists || !dirInfo.isDirectory) {\n    return false\n  }\n\n  const exeInfo = await onboardingApi.getFileInfo(buildInfinityNikkiExePath(normalizedDir))\n  return exeInfo.exists && exeInfo.isRegularFile\n}\n\nconst resolveConfiguredInfinityNikkiGameDir = async (): Promise<string | null> => {\n  const configuredGameDir = normalizeDirectoryPath(configuredInfinityNikkiGameDir.value)\n  if (!configuredGameDir) {\n    return null\n  }\n\n  const isValidGameDir = await isValidInfinityNikkiGameDir(configuredGameDir)\n  return isValidGameDir ? configuredGameDir : null\n}\n\nconst detectInfinityNikkiGameDir = async (): Promise<string | null> => {\n  try {\n    const result = await onboardingApi.detectInfinityNikkiGameDirectory()\n    if (!result.gameDirFound || !result.gameDir) {\n      return null\n    }\n\n    const detectedGameDir = normalizeDirectoryPath(result.gameDir)\n    const isValidGameDir = await isValidInfinityNikkiGameDir(detectedGameDir)\n    if (!isValidGameDir) {\n      return null\n    }\n\n    configuredInfinityNikkiGameDir.value = detectedGameDir\n    return detectedGameDir\n  } catch (error) {\n    console.error('Failed to detect Infinity Nikki directory:', error)\n    return null\n  }\n}\n\nlet infinityNikkiGameDirPromise: Promise<string | null> | null = null\n\nconst resolveInfinityNikkiGameDir = () => {\n  if (!infinityNikkiGameDirPromise) {\n    infinityNikkiGameDirPromise = (async () => {\n      const configuredGameDir = await resolveConfiguredInfinityNikkiGameDir()\n      if (configuredGameDir) {\n        return configuredGameDir\n      }\n\n      return detectInfinityNikkiGameDir()\n    })()\n  }\n\n  return infinityNikkiGameDirPromise\n}\n\nonMounted(() => {\n  void (async () => {\n    const resolvedGameDir = await resolveInfinityNikkiGameDir()\n    resolvedInfinityNikkiGameDir.value = resolvedGameDir\n\n    if (resolvedGameDir && targetTitle.value.trim() === '') {\n      targetTitle.value = getDefaultTargetTitle(language.value)\n    }\n  })()\n})\n\nconst selectVisibleWindowTitle = (title: string) => {\n  targetTitle.value = title\n  stepError.value = ''\n}\n\nconst goToNextStep = () => {\n  stepError.value = ''\n\n  if (step.value < 2) {\n    direction.value = 'forward'\n    step.value = (step.value + 1) as Step\n  }\n}\n\nconst goToPreviousStep = () => {\n  stepError.value = ''\n  if (step.value > 1) {\n    direction.value = 'backward'\n    step.value = (step.value - 1) as Step\n  }\n}\n\nconst completeOnboarding = async () => {\n  stepError.value = ''\n\n  isSubmitting.value = true\n  try {\n    const nextTargetTitle = targetTitle.value.trim() === '' ? '' : targetTitle.value\n    const resolvedGameDir = await resolveInfinityNikkiGameDir()\n    resolvedInfinityNikkiGameDir.value = resolvedGameDir\n    const externalAlbumPath = resolvedGameDir ? resolveInfinityNikkiAlbumPath(resolvedGameDir) : ''\n\n    await store.updateSettings({\n      ...store.appSettings,\n      app: {\n        ...store.appSettings.app,\n        language: {\n          current: language.value,\n        },\n        onboarding: {\n          completed: true,\n          flowVersion: CURRENT_ONBOARDING_FLOW_VERSION,\n        },\n      },\n      ui: {\n        ...store.appSettings.ui,\n        floatingWindowThemeMode: getFloatingWindowThemeByWebTheme(themeMode.value),\n        floatingWindowColors: getFloatingWindowColorsByWebTheme(themeMode.value),\n        webTheme: {\n          ...store.appSettings.ui.webTheme,\n          mode: themeMode.value,\n        },\n        background: {\n          ...store.appSettings.ui.background,\n          overlayColors: getDefaultOverlayColorsByTheme(themeMode.value),\n          overlayOpacity: 0.8,\n        },\n      },\n      window: {\n        ...store.appSettings.window,\n        targetTitle: nextTargetTitle,\n        centerLockCursor: Boolean(resolvedGameDir),\n      },\n      features: {\n        ...store.appSettings.features,\n        externalAlbumPath,\n      },\n      extensions: {\n        ...store.appSettings.extensions,\n        infinityNikki: {\n          ...store.appSettings.extensions.infinityNikki,\n          enable: Boolean(resolvedGameDir),\n          gameDir: resolvedGameDir ?? '',\n        },\n      },\n    })\n\n    direction.value = 'forward'\n    step.value = 3\n  } catch (error) {\n    stepError.value = t('onboarding.common.saveFailed')\n    console.error('Failed to complete onboarding:', error)\n  } finally {\n    isSubmitting.value = false\n  }\n}\n</script>\n\n<template>\n  <div class=\"onboarding-scroll\">\n    <div class=\"onboarding-shell w-full max-w-[724px]\">\n      <div\n        class=\"surface-middle onboarding-card flex w-full flex-col overflow-hidden rounded-md shadow-sm\"\n      >\n        <div class=\"shrink-0 p-12 pb-6\">\n          <h1 class=\"text-2xl font-semibold text-foreground\">\n            {{ t('onboarding.title') }}\n          </h1>\n          <p class=\"mt-2 text-sm text-muted-foreground\">\n            {{ t('onboarding.description') }}\n          </p>\n\n          <div v-if=\"step < 3\" class=\"mt-8 flex items-center gap-2 text-xs\">\n            <div v-for=\"currentStep in [1, 2]\" :key=\"currentStep\" class=\"flex items-center gap-2\">\n              <div\n                class=\"flex h-6 w-6 items-center justify-center rounded-full border\"\n                :class=\"\n                  step >= currentStep\n                    ? 'border-primary bg-primary text-primary-foreground'\n                    : 'border-border text-muted-foreground'\n                \"\n              >\n                {{ currentStep }}\n              </div>\n              <div v-if=\"currentStep < 2\" class=\"h-px w-10 bg-border\" />\n            </div>\n          </div>\n        </div>\n\n        <div class=\"flex min-h-0 flex-1 flex-col justify-end overflow-hidden px-12 pb-6\">\n          <Transition :name=\"stepTransitionName\" mode=\"out-in\">\n            <div v-if=\"step === 1\" key=\"step-1\" class=\"space-y-6\">\n              <div>\n                <h2 class=\"text-lg font-medium text-foreground\">\n                  {{ t('onboarding.step1.title') }}\n                </h2>\n                <p class=\"mt-1 text-sm text-muted-foreground\">\n                  {{ t('onboarding.step1.description') }}\n                </p>\n              </div>\n\n              <div class=\"grid gap-4 md:grid-cols-2\">\n                <div class=\"space-y-2\">\n                  <p class=\"text-sm text-foreground\">{{ t('common.languageLabelBilingual') }}</p>\n                  <Select\n                    :model-value=\"language\"\n                    @update:model-value=\"(v) => (language = String(v))\"\n                  >\n                    <SelectTrigger>\n                      <SelectValue />\n                    </SelectTrigger>\n                    <SelectContent>\n                      <SelectItem value=\"zh-CN\">{{ t('common.languageZhCn') }}</SelectItem>\n                      <SelectItem value=\"en-US\">{{ t('common.languageEnUs') }}</SelectItem>\n                    </SelectContent>\n                  </Select>\n                </div>\n\n                <div class=\"space-y-2\">\n                  <p class=\"text-sm text-foreground\">{{ t('onboarding.step1.themeLabel') }}</p>\n                  <Select\n                    :model-value=\"themeMode\"\n                    @update:model-value=\"(v) => (themeMode = v as 'light' | 'dark')\"\n                  >\n                    <SelectTrigger>\n                      <SelectValue />\n                    </SelectTrigger>\n                    <SelectContent>\n                      <SelectItem value=\"light\">{{\n                        t('settings.appearance.theme.light')\n                      }}</SelectItem>\n                      <SelectItem value=\"dark\">{{\n                        t('settings.appearance.theme.dark')\n                      }}</SelectItem>\n                    </SelectContent>\n                  </Select>\n                </div>\n              </div>\n            </div>\n\n            <div v-else-if=\"step === 2\" key=\"step-2\" class=\"space-y-6\">\n              <div>\n                <h2 class=\"text-lg font-medium text-foreground\">\n                  {{ t('onboarding.step2.title') }}\n                </h2>\n                <p class=\"mt-1 text-sm text-muted-foreground\">\n                  {{ t('onboarding.step2.description') }}\n                </p>\n              </div>\n\n              <div class=\"space-y-2\">\n                <div class=\"flex gap-2\">\n                  <Input\n                    v-model=\"targetTitle\"\n                    :placeholder=\"t('onboarding.step2.targetTitlePlaceholder')\"\n                    class=\"flex-1\"\n                  />\n\n                  <WindowTitlePickerButton @select=\"selectVisibleWindowTitle\" />\n                </div>\n\n                <p class=\"text-xs text-muted-foreground\">\n                  {{ t('onboarding.step2.targetTitleHint') }}\n                </p>\n              </div>\n            </div>\n\n            <div\n              v-else-if=\"step === 3\"\n              key=\"step-3\"\n              class=\"flex h-full flex-col items-center justify-center space-y-6 text-center\"\n            >\n              <h2 class=\"text-xl font-medium text-emerald-500\">\n                {{ t('onboarding.completed.title') }}\n              </h2>\n              <p class=\"max-w-md text-sm text-muted-foreground\">\n                {{ t('onboarding.completed.description') }}\n              </p>\n            </div>\n          </Transition>\n        </div>\n\n        <div v-if=\"step < 3 || stepError\" class=\"shrink-0 border-t border-border/60 px-12 py-6\">\n          <p v-if=\"stepError\" class=\"text-sm text-red-500\">\n            {{ stepError }}\n          </p>\n\n          <div\n            v-if=\"step < 3\"\n            class=\"flex items-center\"\n            :class=\"[isFirstStep ? 'justify-end' : 'justify-between', stepError && 'mt-4']\"\n          >\n            <Button\n              v-if=\"!isFirstStep\"\n              variant=\"outline\"\n              :disabled=\"isSubmitting\"\n              @click=\"goToPreviousStep\"\n            >\n              {{ t('onboarding.actions.previous') }}\n            </Button>\n\n            <Button v-if=\"!isLastStep\" :disabled=\"isSubmitting\" @click=\"goToNextStep\">\n              {{ t('onboarding.actions.next') }}\n            </Button>\n            <Button v-else :disabled=\"isSubmitting\" @click=\"completeOnboarding\">\n              {{\n                isSubmitting ? t('onboarding.actions.completing') : t('onboarding.actions.complete')\n              }}\n            </Button>\n          </div>\n        </div>\n      </div>\n    </div>\n  </div>\n</template>\n\n<style scoped>\n.onboarding-scroll {\n  min-height: 100%;\n  display: grid;\n  place-items: center;\n  box-sizing: border-box;\n  padding: 2rem;\n}\n\n.onboarding-shell {\n  transform: translateY(-20px);\n}\n\n.onboarding-card {\n  height: min(28rem, calc(100dvh - 7rem));\n}\n\n/* 步骤切换：前进方向，旧内容向左滑出，新内容从右滑入 */\n.ob-step-forward-enter-active,\n.ob-step-forward-leave-active {\n  transition:\n    opacity 0.2s ease,\n    transform 0.2s ease;\n}\n\n.ob-step-forward-enter-from {\n  opacity: 0;\n  transform: translateX(20px);\n}\n\n.ob-step-forward-leave-to {\n  opacity: 0;\n  transform: translateX(-20px);\n}\n\n/* 步骤切换：后退方向，旧内容向右滑出，新内容从左滑入 */\n.ob-step-backward-enter-active,\n.ob-step-backward-leave-active {\n  transition:\n    opacity 0.2s ease,\n    transform 0.2s ease;\n}\n\n.ob-step-backward-enter-from {\n  opacity: 0;\n  transform: translateX(-20px);\n}\n\n.ob-step-backward-leave-to {\n  opacity: 0;\n  transform: translateX(20px);\n}\n</style>\n"
  },
  {
    "path": "web/src/features/onboarding/types.ts",
    "content": "export interface InfinityNikkiGameDirResult {\n  gameDir?: string\n  configFound: boolean\n  gameDirFound: boolean\n  message: string\n}\n\nexport interface FileInfoResult {\n  path: string\n  exists: boolean\n  isDirectory: boolean\n  isRegularFile: boolean\n  isSymlink: boolean\n  size: number\n  extension: string\n  filename: string\n  lastModified: number\n}\n"
  },
  {
    "path": "web/src/features/playground/components/ApiMethodList.vue",
    "content": "<script setup lang=\"ts\">\nimport { ref, computed } from 'vue'\nimport { PlayIcon, SearchIcon } from 'lucide-vue-next'\nimport { Button } from '@/components/ui/button'\nimport { Input } from '@/components/ui/input'\nimport type { ApiMethod } from '../types'\n\ninterface Props {\n  methods: ApiMethod[]\n  loading?: boolean\n  selectedMethod?: string | null\n}\n\ninterface Emits {\n  (e: 'select', method: ApiMethod): void\n  (e: 'refresh'): void\n}\n\nconst props = withDefaults(defineProps<Props>(), {\n  loading: false,\n  selectedMethod: null,\n})\n\nconst emit = defineEmits<Emits>()\n\nconst searchQuery = ref('')\n\n// 按分类分组的方法\nconst methodsByCategory = computed(() => {\n  const grouped: Record<string, ApiMethod[]> = {}\n\n  const filteredMethods = props.methods.filter((method) => {\n    if (!searchQuery.value.trim()) return true\n\n    const query = searchQuery.value.toLowerCase()\n    return (\n      method.name.toLowerCase().includes(query) ||\n      method.category?.toLowerCase().includes(query) ||\n      method.description?.toLowerCase().includes(query)\n    )\n  })\n\n  filteredMethods.forEach((method) => {\n    const category = method.category || '未分类'\n    if (!grouped[category]) {\n      grouped[category] = []\n    }\n    grouped[category].push(method)\n  })\n\n  return grouped\n})\n\n// 所有分类\nconst categories = computed(() => {\n  return Object.keys(methodsByCategory.value).sort()\n})\n\nconst handleSelectMethod = (method: ApiMethod) => {\n  emit('select', method)\n}\n\nconst handleRefresh = () => {\n  emit('refresh')\n}\n</script>\n\n<template>\n  <div class=\"flex h-full flex-col\">\n    <!-- 搜索和刷新 -->\n    <div class=\"space-y-2 border-b p-3\">\n      <div class=\"relative\">\n        <SearchIcon\n          class=\"absolute top-1/2 left-3 size-3 -translate-y-1/2 transform text-muted-foreground\"\n        />\n        <Input v-model=\"searchQuery\" placeholder=\"搜索API方法...\" class=\"h-8 pl-8 text-sm\" />\n      </div>\n      <Button\n        @click=\"handleRefresh\"\n        :disabled=\"loading\"\n        variant=\"outline\"\n        size=\"sm\"\n        class=\"h-7 w-full text-xs\"\n      >\n        <SearchIcon v-if=\"!loading\" class=\"mr-1 size-3\" />\n        <div\n          v-else\n          class=\"mr-1 size-3 animate-spin rounded-full border-2 border-current border-t-transparent\"\n        />\n        {{ loading ? '获取中...' : '刷新' }}\n      </Button>\n    </div>\n\n    <!-- 方法列表 -->\n    <div class=\"flex-1 overflow-y-auto\">\n      <div v-if=\"loading && methods.length === 0\" class=\"flex h-20 items-center justify-center\">\n        <div class=\"text-xs text-muted-foreground\">正在加载API方法...</div>\n      </div>\n\n      <div\n        v-else-if=\"Object.keys(methodsByCategory).length === 0\"\n        class=\"flex h-20 items-center justify-center\"\n      >\n        <div class=\"text-xs text-muted-foreground\">\n          {{ searchQuery ? '未找到匹配的方法' : '暂无API方法' }}\n        </div>\n      </div>\n\n      <div v-else class=\"space-y-2 p-1\">\n        <!-- 按分类显示 -->\n        <div v-for=\"category in categories\" :key=\"category\" class=\"space-y-1\">\n          <!-- 分类标题 -->\n          <div\n            class=\"sticky top-0 bg-background px-2 py-0.5 text-xs font-medium text-muted-foreground\"\n          >\n            {{ category }}\n            <span class=\"ml-1 text-xs text-muted-foreground\"\n              >({{ methodsByCategory[category]?.length || 0 }})</span\n            >\n          </div>\n\n          <!-- 方法列表 - 使用更紧凑的布局 -->\n          <div class=\"space-y-1 px-1\">\n            <div\n              v-for=\"method in methodsByCategory[category]\"\n              :key=\"method.name\"\n              :class=\"[\n                'flex cursor-pointer items-center gap-2 rounded px-2 py-1 text-xs hover:bg-accent/50',\n                selectedMethod === method.name && 'bg-accent',\n              ]\"\n              @click=\"handleSelectMethod(method)\"\n            >\n              <PlayIcon class=\"size-3 flex-shrink-0 text-muted-foreground\" />\n              <div class=\"min-w-0 flex-1 truncate font-medium\">\n                {{ method.name }}\n              </div>\n              <Button\n                variant=\"ghost\"\n                size=\"sm\"\n                class=\"h-5 px-1 text-xs opacity-0 transition-opacity hover:opacity-100\"\n                @click.stop=\"handleSelectMethod(method)\"\n              >\n                测试\n              </Button>\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n  </div>\n</template>\n"
  },
  {
    "path": "web/src/features/playground/components/ApiTestPanel.vue",
    "content": "<script setup lang=\"ts\">\nimport { ref, watch, computed, onMounted, onUnmounted } from 'vue'\nimport { PlayIcon } from 'lucide-vue-next'\nimport { Button } from '@/components/ui/button'\nimport ParamInputPanel from './ParamInputPanel.vue'\nimport JsonResponseViewer from './JsonResponseViewer.vue'\nimport { useApiTest } from '../composables/useApiTest'\nimport type { ApiMethod, ApiTestResponse } from '../types'\n\ninterface Props {\n  method?: ApiMethod | null\n}\n\ninterface Emits {\n  (e: 'response', response: ApiTestResponse): void\n}\n\nconst props = defineProps<Props>()\nconst emit = defineEmits<Emits>()\n\nconst { testApi, loading } = useApiTest()\n\nconst paramInputPanelRef = ref<InstanceType<typeof ParamInputPanel> | null>(null)\nconst currentParams = ref<unknown>({})\nconst lastResponse = ref<ApiTestResponse | null>(null)\n\n// 监听方法变化，重置响应\nwatch(\n  () => props.method,\n  () => {\n    lastResponse.value = null\n  },\n  { immediate: true }\n)\n\n// 添加键盘快捷键支持\nconst handleKeyDown = (event: KeyboardEvent) => {\n  if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {\n    event.preventDefault()\n    executeTest()\n  }\n}\n\n// 在组件挂载时添加键盘事件监听\nonMounted(() => {\n  document.addEventListener('keydown', handleKeyDown)\n})\n\n// 在组件卸载时移除键盘事件监听\nonUnmounted(() => {\n  document.removeEventListener('keydown', handleKeyDown)\n})\n\n// 执行API测试\nconst executeTest = async () => {\n  if (!props.method) return\n\n  // 检查 JSON 格式（如果在 JSON 模式下）\n  if (paramInputPanelRef.value && !paramInputPanelRef.value.isValidJson) {\n    console.error('Invalid JSON format')\n    return\n  }\n\n  try {\n    // 从 ParamInputPanel 获取当前参数\n    const params = paramInputPanelRef.value?.getCurrentParams() ?? currentParams.value\n\n    const response = await testApi({\n      method: props.method.name,\n      params,\n    })\n\n    lastResponse.value = response\n    emit('response', response)\n  } catch (error) {\n    console.error('API test failed:', error)\n  }\n}\n\n// 处理参数更新\nconst handleParamsUpdate = (params: unknown) => {\n  currentParams.value = params\n}\n\n// 检查是否可以执行测试\nconst canExecuteTest = computed(() => {\n  if (!props.method || loading.value) return false\n  if (!paramInputPanelRef.value) return false\n  return paramInputPanelRef.value.isValidJson\n})\n</script>\n\n<template>\n  <div class=\"flex h-full flex-col\">\n    <!-- 方法信息 -->\n    <div v-if=\"method\" class=\"border-b p-4\">\n      <h3 class=\"mb-2 text-lg font-semibold\">{{ method.name }}</h3>\n      <p v-if=\"method.description\" class=\"mb-2 text-sm text-muted-foreground\">\n        {{ method.description }}\n      </p>\n      <div class=\"flex items-center space-x-2\">\n        <span\n          class=\"inline-flex items-center rounded-full bg-blue-100 px-2 py-1 text-xs font-medium text-blue-800 dark:bg-blue-900 dark:text-blue-200\"\n        >\n          {{ method.category || '未分类' }}\n        </span>\n      </div>\n    </div>\n\n    <!-- 无选中方法 -->\n    <div v-else class=\"flex h-32 items-center justify-center text-muted-foreground\">\n      <div class=\"text-center\">\n        <div class=\"text-sm\">请从左侧选择一个API方法</div>\n      </div>\n    </div>\n\n    <!-- 测试面板 -->\n    <div v-if=\"method\" class=\"flex flex-1 flex-col overflow-y-auto\">\n      <!-- 参数输入面板 -->\n      <div class=\"flex-1 overflow-y-auto border-b\">\n        <ParamInputPanel\n          ref=\"paramInputPanelRef\"\n          :method-name=\"method.name\"\n          @update:params=\"handleParamsUpdate\"\n        />\n      </div>\n\n      <!-- 执行按钮 -->\n      <div class=\"border-b p-4\">\n        <div class=\"mb-2 text-xs text-muted-foreground\">提示：按 Ctrl+Enter 快速执行测试</div>\n        <Button @click=\"executeTest\" :disabled=\"!canExecuteTest\" class=\"w-full\">\n          <PlayIcon v-if=\"!loading\" class=\"mr-2 h-4 w-4\" />\n          <div\n            v-else\n            class=\"mr-2 h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent\"\n          />\n          {{ loading ? '执行中...' : '执行测试' }}\n        </Button>\n      </div>\n\n      <!-- 响应结果 -->\n      <div class=\"min-h-0 flex-1 p-4\">\n        <h4 class=\"mb-3 text-sm font-medium\">响应结果</h4>\n        <JsonResponseViewer :response=\"lastResponse\" :loading=\"loading\" />\n      </div>\n    </div>\n  </div>\n</template>\n"
  },
  {
    "path": "web/src/features/playground/components/JsonResponseViewer.vue",
    "content": "<script setup lang=\"ts\">\nimport { ref, computed } from 'vue'\nimport { CopyIcon, CheckIcon, ChevronDownIcon, ChevronRightIcon } from 'lucide-vue-next'\nimport { Button } from '@/components/ui/button'\nimport type { ApiTestResponse } from '../types'\n\ninterface Props {\n  response?: ApiTestResponse | null\n  loading?: boolean\n}\n\nconst props = withDefaults(defineProps<Props>(), {\n  response: null,\n  loading: false,\n})\n\nconst isExpanded = ref(true)\nconst copySuccess = ref(false)\n\n// 格式化的JSON字符串\nconst formattedJson = computed(() => {\n  if (!props.response) return ''\n\n  try {\n    const data = props.response.success ? props.response.data : props.response.error\n    return JSON.stringify(data, null, 2)\n  } catch {\n    return String(props.response.success ? props.response.data : props.response.error)\n  }\n})\n\n// 复制到剪贴板\nconst copyToClipboard = async () => {\n  try {\n    await navigator.clipboard.writeText(formattedJson.value)\n    copySuccess.value = true\n    setTimeout(() => {\n      copySuccess.value = false\n    }, 2000)\n  } catch (error) {\n    console.error('Failed to copy to clipboard:', error)\n  }\n}\n\n// 切换展开状态\nconst toggleExpanded = () => {\n  isExpanded.value = !isExpanded.value\n}\n\n// 格式化时间\nconst formatTime = (timestamp: number) => {\n  return new Date(timestamp).toLocaleTimeString()\n}\n\n// 格式化持续时间\nconst formatDuration = (duration: number) => {\n  if (duration < 1000) {\n    return `${duration}ms`\n  }\n  return `${(duration / 1000).toFixed(2)}s`\n}\n</script>\n\n<template>\n  <div class=\"overflow-hidden rounded-lg border\">\n    <!-- 响应头部 -->\n    <div class=\"flex items-center justify-between border-b bg-muted/50 p-3\">\n      <div class=\"flex items-center space-x-2\">\n        <Button variant=\"ghost\" size=\"sm\" @click=\"toggleExpanded\" class=\"h-6 w-6 p-1\">\n          <ChevronRightIcon v-if=\"!isExpanded\" class=\"size-4\" />\n          <ChevronDownIcon v-else class=\"size-4\" />\n        </Button>\n\n        <div class=\"flex items-center space-x-2\">\n          <div\n            class=\"h-2 w-2 rounded-full\"\n            :class=\"response?.success ? 'bg-green-500' : 'bg-red-500'\"\n          />\n          <span class=\"text-sm font-medium\">\n            {{ response?.success ? '成功' : '失败' }}\n          </span>\n        </div>\n\n        <div v-if=\"response\" class=\"text-xs text-muted-foreground\">\n          {{ formatTime(response.timestamp) }} · {{ formatDuration(response.duration) }}\n        </div>\n      </div>\n\n      <Button\n        variant=\"ghost\"\n        size=\"sm\"\n        @click=\"copyToClipboard\"\n        :disabled=\"!formattedJson\"\n        class=\"h-6 w-6 p-1\"\n      >\n        <CheckIcon v-if=\"copySuccess\" class=\"size-4 text-green-500\" />\n        <CopyIcon v-else class=\"size-4\" />\n      </Button>\n    </div>\n\n    <!-- 响应内容 -->\n    <div v-if=\"isExpanded\" class=\"relative\">\n      <!-- 加载状态 -->\n      <div v-if=\"loading\" class=\"flex items-center justify-center p-8\">\n        <div\n          class=\"size-6 animate-spin rounded-full border-2 border-current border-t-transparent\"\n        />\n        <span class=\"ml-2 text-sm text-muted-foreground\">执行中...</span>\n      </div>\n\n      <!-- 无响应 -->\n      <div v-else-if=\"!response\" class=\"flex items-center justify-center p-8\">\n        <div class=\"text-sm text-muted-foreground\">暂无响应数据</div>\n      </div>\n\n      <!-- 响应内容 -->\n      <div v-else class=\"relative\">\n        <pre\n          class=\"overflow-x-auto rounded-md border-0 bg-slate-50 p-4 text-sm dark:bg-slate-900\"\n          :class=\"response?.success ? 'border-green-200' : 'border-red-200'\"\n        ><code>{{ formattedJson }}</code></pre>\n      </div>\n    </div>\n  </div>\n</template>\n\n<style scoped>\npre {\n  margin: 0;\n  font-family: 'Consolas', 'Monaco', 'Courier New', monospace;\n  white-space: pre;\n  word-wrap: normal;\n}\n\ncode {\n  color: inherit;\n}\n</style>\n"
  },
  {
    "path": "web/src/features/playground/components/ParamFormBuilder.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed, watch } from 'vue'\nimport ParamFormField from './ParamFormField.vue'\nimport { Separator } from '@/components/ui/separator'\nimport type { FormField, FormData } from '../types'\n\ninterface Props {\n  fields: FormField[]\n  modelValue: FormData\n}\n\ninterface Emits {\n  (e: 'update:modelValue', value: FormData): void\n}\n\nconst props = defineProps<Props>()\nconst emit = defineEmits<Emits>()\n\n// 表单数据\nconst formData = computed({\n  get: () => props.modelValue,\n  set: (val) => emit('update:modelValue', val),\n})\n\n// 更新单个字段的值\nconst updateFieldValue = (fieldName: string, value: unknown) => {\n  formData.value = {\n    ...formData.value,\n    [fieldName]: value,\n  }\n}\n\n// 根据类型获取默认值\nconst getDefaultValueForType = (type: string): unknown => {\n  switch (type) {\n    case 'string':\n      return ''\n    case 'number':\n    case 'integer':\n      return 0\n    case 'boolean':\n      return false\n    case 'array':\n      return []\n    case 'object':\n      return {}\n    default:\n      return null\n  }\n}\n\n// 初始化表单数据\nwatch(\n  () => props.fields,\n  (newFields) => {\n    if (!newFields || newFields.length === 0) return\n\n    const initialData: FormData = { ...props.modelValue }\n    let hasChanges = false\n\n    // 为所有字段设置初始值\n    newFields.forEach((field) => {\n      if (!(field.name in initialData)) {\n        if (field.defaultValue !== undefined) {\n          initialData[field.name] = field.defaultValue\n          hasChanges = true\n        } else if (field.required) {\n          // 为必填字段设置默认值\n          initialData[field.name] = getDefaultValueForType(field.type)\n          hasChanges = true\n        }\n      }\n    })\n\n    if (hasChanges) {\n      emit('update:modelValue', initialData)\n    }\n  },\n  { immediate: true }\n)\n\n// 是否有复杂类型（需要 JSON 编辑器）\nconst hasComplexTypes = computed(() => {\n  return props.fields.some((field) => field.type === 'array' || field.type === 'object')\n})\n</script>\n\n<template>\n  <div class=\"space-y-4\">\n    <!-- 无字段提示 -->\n    <div\n      v-if=\"!fields || fields.length === 0\"\n      class=\"rounded-lg border border-dashed p-6 text-center\"\n    >\n      <p class=\"text-sm text-muted-foreground\">此方法无需参数，或参数 schema 为空</p>\n    </div>\n\n    <!-- 表单字段列表 -->\n    <template v-else>\n      <!-- 复杂类型提示 -->\n      <div\n        v-if=\"hasComplexTypes\"\n        class=\"rounded-lg border border-yellow-200 bg-yellow-50 p-3 text-sm text-yellow-800 dark:border-yellow-800 dark:bg-yellow-950 dark:text-yellow-200\"\n      >\n        <div class=\"flex items-start gap-2\">\n          <svg\n            class=\"mt-0.5 h-4 w-4 flex-shrink-0\"\n            fill=\"currentColor\"\n            viewBox=\"0 0 20 20\"\n            xmlns=\"http://www.w3.org/2000/svg\"\n          >\n            <path\n              fill-rule=\"evenodd\"\n              d=\"M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z\"\n              clip-rule=\"evenodd\"\n            />\n          </svg>\n          <p>\n            此方法包含复杂类型参数（数组/对象），建议切换到\n            <strong>JSON 模式</strong> 进行编辑以获得更好的体验。\n          </p>\n        </div>\n      </div>\n\n      <!-- 渲染所有字段 -->\n      <div v-for=\"(field, index) in fields\" :key=\"field.name\" class=\"space-y-4\">\n        <ParamFormField\n          :field=\"field\"\n          :model-value=\"formData[field.name]\"\n          @update:model-value=\"(value) => updateFieldValue(field.name, value)\"\n        />\n\n        <!-- 分隔线（最后一个字段不显示） -->\n        <Separator v-if=\"index < fields.length - 1\" class=\"my-4\" />\n      </div>\n    </template>\n  </div>\n</template>\n"
  },
  {
    "path": "web/src/features/playground/components/ParamFormField.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed } from 'vue'\nimport { Input } from '@/components/ui/input'\nimport { Label } from '@/components/ui/label'\nimport { Checkbox } from '@/components/ui/checkbox'\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from '@/components/ui/select'\nimport { Badge } from '@/components/ui/badge'\nimport { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'\nimport { InfoIcon } from 'lucide-vue-next'\nimport type { FormField } from '../types'\n\ninterface Props {\n  field: FormField\n  modelValue: unknown\n}\n\ninterface Emits {\n  (e: 'update:modelValue', value: unknown): void\n}\n\nconst props = defineProps<Props>()\nconst emit = defineEmits<Emits>()\n\n// 计算值的双向绑定\nconst value = computed({\n  get: () => {\n    const defaultValue = getDefaultValue()\n    const currentValue = props.modelValue ?? defaultValue\n\n    // 对于字符串类型，确保始终返回字符串\n    if (props.field.type === 'string') {\n      return String(currentValue ?? '')\n    }\n\n    // 对于数字类型，确保是数字或 null\n    if (props.field.type === 'number' || props.field.type === 'integer') {\n      if (typeof currentValue === 'number') {\n        return currentValue\n      }\n      return null // 返回 null 让 input 组件处理\n    }\n\n    return currentValue\n  },\n  set: (val) => emit('update:modelValue', val),\n})\n\n// 获取默认值\nconst getDefaultValue = () => {\n  if (props.field.defaultValue !== undefined) {\n    return props.field.defaultValue\n  }\n\n  switch (props.field.type) {\n    case 'string':\n      return ''\n    case 'number':\n    case 'integer':\n      return 0\n    case 'boolean':\n      return false\n    default:\n      return null\n  }\n}\n\n// 处理数字输入\nconst handleNumberInput = (event: Event) => {\n  const target = event.target as HTMLInputElement\n  const num = props.field.type === 'integer' ? parseInt(target.value, 10) : parseFloat(target.value)\n  value.value = isNaN(num) ? null : num\n}\n\nconst handleCheckboxChange = (newValue: boolean | 'indeterminate') => {\n  value.value = newValue === true\n}\n</script>\n\n<template>\n  <div class=\"space-y-2\">\n    <!-- 字段标签 -->\n    <div class=\"flex items-center gap-2\">\n      <Label :for=\"field.name\" class=\"text-sm font-medium\">\n        {{ field.label }}\n        <Badge v-if=\"field.required\" variant=\"destructive\" class=\"ml-2 text-xs\">必填</Badge>\n        <Badge v-else variant=\"secondary\" class=\"ml-2 text-xs\">可选</Badge>\n      </Label>\n\n      <!-- 字段描述提示 -->\n      <TooltipProvider v-if=\"field.description\" :delay-duration=\"300\">\n        <Tooltip>\n          <TooltipTrigger as-child>\n            <InfoIcon class=\"h-4 w-4 cursor-help text-muted-foreground\" />\n          </TooltipTrigger>\n          <TooltipContent class=\"max-w-xs\">\n            <p class=\"text-sm\">{{ field.description }}</p>\n          </TooltipContent>\n        </Tooltip>\n      </TooltipProvider>\n    </div>\n\n    <!-- 根据类型渲染不同的输入组件 -->\n\n    <!-- 字符串类型 - 有枚举值时使用下拉选择 -->\n    <Select v-if=\"field.type === 'string' && field.enum\" v-model=\"value\">\n      <SelectTrigger :id=\"field.name\">\n        <SelectValue :placeholder=\"`选择 ${field.label}`\" />\n      </SelectTrigger>\n      <SelectContent>\n        <SelectItem v-for=\"option in field.enum\" :key=\"String(option)\" :value=\"String(option)\">\n          {{ option }}\n        </SelectItem>\n      </SelectContent>\n    </Select>\n\n    <!-- 字符串类型 - 普通文本输入 -->\n    <Input\n      v-else-if=\"field.type === 'string'\"\n      :id=\"field.name\"\n      :model-value=\"value as string\"\n      type=\"text\"\n      :placeholder=\"field.defaultValue ? String(field.defaultValue) : `输入 ${field.label}`\"\n      :minlength=\"field.minLength\"\n      :maxlength=\"field.maxLength\"\n      :pattern=\"field.pattern\"\n      @input=\"(e: Event) => emit('update:modelValue', (e.target as HTMLInputElement).value)\"\n    />\n\n    <!-- 数字类型 -->\n    <Input\n      v-else-if=\"field.type === 'number' || field.type === 'integer'\"\n      :id=\"field.name\"\n      :model-value=\"value !== null ? String(value) : ''\"\n      :type=\"field.type === 'integer' ? 'number' : 'number'\"\n      :step=\"field.type === 'integer' ? '1' : 'any'\"\n      :min=\"field.minimum\"\n      :max=\"field.maximum\"\n      :placeholder=\"field.defaultValue ? String(field.defaultValue) : `输入 ${field.label}`\"\n      @input=\"handleNumberInput\"\n    />\n\n    <!-- 布尔类型 -->\n    <div v-else-if=\"field.type === 'boolean'\" class=\"flex items-center space-x-2\">\n      <Checkbox\n        :id=\"field.name\"\n        :model-value=\"!!value\"\n        @update:model-value=\"handleCheckboxChange\"\n      />\n      <Label :for=\"field.name\" class=\"cursor-pointer text-sm font-normal\">\n        {{ value ? '是' : '否' }}\n      </Label>\n    </div>\n\n    <!-- 数组和对象类型 - 暂时显示提示 -->\n    <div\n      v-else-if=\"field.type === 'array' || field.type === 'object'\"\n      class=\"rounded-md border border-dashed border-muted-foreground/25 bg-muted/50 p-3 text-sm text-muted-foreground\"\n    >\n      <p>复杂类型 ({{ field.type }}) 请切换到 <strong>JSON 模式</strong> 编辑</p>\n    </div>\n\n    <!-- 未知类型 -->\n    <div v-else class=\"text-sm text-muted-foreground\">不支持的类型: {{ field.type }}</div>\n\n    <!-- 字段额外信息 -->\n    <div\n      v-if=\"field.minLength || field.maxLength || field.minimum || field.maximum\"\n      class=\"text-xs text-muted-foreground\"\n    >\n      <span v-if=\"field.minLength\">最小长度: {{ field.minLength }}</span>\n      <span v-if=\"field.maxLength\" class=\"ml-2\">最大长度: {{ field.maxLength }}</span>\n      <span v-if=\"field.minimum\">最小值: {{ field.minimum }}</span>\n      <span v-if=\"field.maximum\" class=\"ml-2\">最大值: {{ field.maximum }}</span>\n    </div>\n  </div>\n</template>\n"
  },
  {
    "path": "web/src/features/playground/components/ParamInputPanel.vue",
    "content": "<script setup lang=\"ts\">\nimport { ref, watch, computed } from 'vue'\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'\nimport { Button } from '@/components/ui/button'\nimport { Textarea } from '@/components/ui/textarea'\nimport { WandSparklesIcon, RotateCcwIcon } from 'lucide-vue-next'\nimport ParamFormBuilder from './ParamFormBuilder.vue'\nimport { useMethodSignature } from '../composables/useMethodSignature'\nimport type { FormData } from '../types'\n\ninterface Props {\n  methodName?: string | null\n}\n\ninterface Emits {\n  (e: 'update:params', params: unknown): void\n}\n\nconst props = defineProps<Props>()\nconst emit = defineEmits<Emits>()\n\nconst { error, formFields, fetchSignature, generateDefaultParams } = useMethodSignature()\n\n// 当前模式：form 或 json\nconst currentMode = ref<'form' | 'json'>('form')\n\n// 表单数据\nconst formData = ref<FormData>({})\n\n// JSON 输入\nconst jsonInput = ref('{}')\n\n// JSON 验证\nconst isValidJson = computed(() => {\n  try {\n    JSON.parse(jsonInput.value)\n    return true\n  } catch {\n    return false\n  }\n})\n\n// 监听方法变化\nwatch(\n  () => props.methodName,\n  async (newMethod) => {\n    if (!newMethod) {\n      formData.value = {}\n      jsonInput.value = '{}'\n      return\n    }\n\n    // 获取方法签名\n    await fetchSignature(newMethod)\n\n    // 生成默认参数\n    if (formFields.value.length > 0) {\n      const defaultParams = generateDefaultParams()\n      formData.value = defaultParams\n      jsonInput.value = JSON.stringify(defaultParams, null, 2)\n    } else {\n      formData.value = {}\n      jsonInput.value = '{}'\n    }\n  },\n  { immediate: true }\n)\n\n// 表单数据变化时，通知父组件\nwatch(\n  formData,\n  (newData) => {\n    if (currentMode.value === 'form') {\n      emit('update:params', newData)\n    }\n  },\n  { deep: true }\n)\n\n// JSON 输入变化时（在 JSON 模式下）\nwatch(jsonInput, (newJson) => {\n  if (currentMode.value === 'json' && isValidJson.value) {\n    try {\n      const parsed = JSON.parse(newJson)\n      formData.value = parsed\n      emit('update:params', parsed)\n    } catch (error) {\n      console.error('Failed to parse JSON:', error)\n    }\n  }\n})\n\n// 模式切换时同步数据\nwatch(currentMode, (newMode) => {\n  if (newMode === 'json') {\n    // 切换到 JSON 模式：从表单数据同步\n    jsonInput.value = JSON.stringify(formData.value, null, 2)\n  } else {\n    // 切换到表单模式：从 JSON 同步\n    if (isValidJson.value) {\n      try {\n        formData.value = JSON.parse(jsonInput.value)\n      } catch (error) {\n        console.error('Failed to sync to form:', error)\n      }\n    }\n  }\n})\n\n// 格式化 JSON\nconst formatJson = () => {\n  if (isValidJson.value) {\n    try {\n      const parsed = JSON.parse(jsonInput.value)\n      jsonInput.value = JSON.stringify(parsed, null, 2)\n    } catch {\n      // 忽略错误\n    }\n  }\n}\n\n// 生成模板（一键填充默认值）\nconst generateTemplate = () => {\n  const defaultParams = generateDefaultParams()\n  formData.value = defaultParams\n  jsonInput.value = JSON.stringify(defaultParams, null, 2)\n}\n\n// 重置参数\nconst resetParams = () => {\n  formData.value = {}\n  jsonInput.value = '{}'\n  emit('update:params', {})\n}\n\n// 获取当前参数\nconst getCurrentParams = (): unknown => {\n  if (currentMode.value === 'json' && isValidJson.value) {\n    return JSON.parse(jsonInput.value)\n  }\n  return formData.value\n}\n\n// 暴露方法给父组件\ndefineExpose({\n  getCurrentParams,\n  isValidJson,\n})\n</script>\n\n<template>\n  <div class=\"flex h-full flex-col\">\n    <!-- 标题栏 -->\n    <div class=\"flex items-center justify-between border-b p-3\">\n      <h4 class=\"text-sm font-medium\">请求参数</h4>\n      <div class=\"flex items-center gap-2\">\n        <!-- 生成模板按钮 -->\n        <Button v-if=\"formFields.length > 0\" variant=\"outline\" size=\"sm\" @click=\"generateTemplate\">\n          <WandSparklesIcon class=\"mr-1 h-3 w-3\" />\n          生成模板\n        </Button>\n\n        <!-- 重置按钮 -->\n        <Button variant=\"outline\" size=\"sm\" @click=\"resetParams\">\n          <RotateCcwIcon class=\"mr-1 h-3 w-3\" />\n          重置\n        </Button>\n      </div>\n    </div>\n\n    <!-- 错误提示 -->\n    <div\n      v-if=\"error\"\n      class=\"m-4 rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-800 dark:border-red-800 dark:bg-red-950 dark:text-red-200\"\n    >\n      <p>{{ error }}</p>\n    </div>\n\n    <!-- 参数输入区域 -->\n    <div v-else class=\"flex-1 overflow-y-auto p-4\">\n      <Tabs v-model=\"currentMode\" class=\"w-full\">\n        <!-- 模式切换标签 -->\n        <TabsList class=\"grid w-full grid-cols-2\">\n          <TabsTrigger value=\"form\">表单模式</TabsTrigger>\n          <TabsTrigger value=\"json\">JSON 模式</TabsTrigger>\n        </TabsList>\n\n        <!-- 表单模式 -->\n        <TabsContent value=\"form\" class=\"mt-4\">\n          <ParamFormBuilder v-model=\"formData\" :fields=\"formFields\" />\n        </TabsContent>\n\n        <!-- JSON 模式 -->\n        <TabsContent value=\"json\" class=\"mt-4 space-y-3\">\n          <div class=\"flex items-center justify-between\">\n            <p class=\"text-sm text-muted-foreground\">直接编辑 JSON 格式的参数</p>\n            <Button variant=\"outline\" size=\"sm\" @click=\"formatJson\" :disabled=\"!isValidJson\">\n              格式化\n            </Button>\n          </div>\n\n          <Textarea\n            v-model=\"jsonInput\"\n            placeholder='{\"key\": \"value\"}'\n            class=\"min-h-[300px] font-mono text-sm\"\n            :class=\"!isValidJson && 'border-red-500 focus:border-red-500'\"\n          />\n\n          <!-- JSON 格式错误提示 -->\n          <div v-if=\"!isValidJson\" class=\"flex items-center text-xs text-red-500\">\n            <svg class=\"mr-1 h-4 w-4\" fill=\"currentColor\" viewBox=\"0 0 20 20\">\n              <path\n                fill-rule=\"evenodd\"\n                d=\"M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z\"\n                clip-rule=\"evenodd\"\n              />\n            </svg>\n            JSON 格式错误，请检查语法\n          </div>\n        </TabsContent>\n      </Tabs>\n    </div>\n  </div>\n</template>\n"
  },
  {
    "path": "web/src/features/playground/components/ToastDemo.vue",
    "content": "<script setup lang=\"ts\">\nimport { useToast } from '@/composables/useToast'\nimport { Button } from '@/components/ui/button'\n\nconst { toast } = useToast()\n\n// 基础示例\nconst showBasicToast = () => {\n  toast('这是一条基础通知')\n}\n\nconst showSuccessToast = () => {\n  toast.success('操作成功！')\n}\n\nconst showErrorToast = () => {\n  toast.error('操作失败', {\n    description: '请检查网络连接后重试',\n  })\n}\n\nconst showWarningToast = () => {\n  toast.warning('警告：磁盘空间不足')\n}\n\nconst showInfoToast = () => {\n  toast.info('提示：新版本可用')\n}\n\n// 带描述的 Toast\nconst showWithDescription = () => {\n  toast.success('文件上传成功', {\n    description: '已成功上传 3 个文件到服务器',\n  })\n}\n\n// 带操作按钮\nconst showWithAction = () => {\n  toast('文件已删除', {\n    action: {\n      label: '撤销',\n      onClick: () => {\n        toast.info('已撤销删除操作')\n      },\n    },\n  })\n}\n\n// 自定义时长\nconst showLongDuration = () => {\n  toast.info('这条消息会显示 10 秒', {\n    duration: 10000,\n  })\n}\n\n// Loading 状态\nconst showLoadingToast = () => {\n  const loadingToast = toast.loading('正在处理...')\n  \n  setTimeout(() => {\n    toast.dismiss(loadingToast)\n    toast.success('处理完成！')\n  }, 3000)\n}\n\n// Promise Toast\nconst showPromiseToast = async () => {\n  const simulateAsync = () => \n    new Promise((resolve, reject) => {\n      setTimeout(() => {\n        Math.random() > 0.5 ? resolve('Success') : reject(new Error('Failed'))\n      }, 2000)\n    })\n\n  toast.promise(simulateAsync(), {\n    loading: '正在保存设置...',\n    success: '设置已保存',\n    error: (err: unknown) => {\n      const message = err instanceof Error ? err.message : String(err)\n      return `保存失败: ${message}`\n    },\n  })\n}\n\n// 多个同时显示\nconst showMultipleToasts = () => {\n  toast.success('第一条消息')\n  setTimeout(() => toast.info('第二条消息'), 500)\n  setTimeout(() => toast.warning('第三条消息'), 1000)\n}\n</script>\n\n<template>\n  <div class=\"space-y-8\">\n    <div>\n      <h2 class=\"text-2xl font-bold mb-4\">Toast 通知组件演示</h2>\n      <p class=\"text-muted-foreground mb-6\">\n        演示 vue-sonner toast 组件的各种用法\n      </p>\n    </div>\n\n    <!-- 基础类型 -->\n    <section>\n      <h3 class=\"text-lg font-semibold mb-3\">基础类型</h3>\n      <div class=\"flex flex-wrap gap-2\">\n        <Button @click=\"showBasicToast\" variant=\"outline\">\n          基础 Toast\n        </Button>\n        <Button @click=\"showSuccessToast\" variant=\"outline\">\n          成功\n        </Button>\n        <Button @click=\"showErrorToast\" variant=\"outline\">\n          错误\n        </Button>\n        <Button @click=\"showWarningToast\" variant=\"outline\">\n          警告\n        </Button>\n        <Button @click=\"showInfoToast\" variant=\"outline\">\n          信息\n        </Button>\n      </div>\n    </section>\n\n    <!-- 高级功能 -->\n    <section>\n      <h3 class=\"text-lg font-semibold mb-3\">高级功能</h3>\n      <div class=\"flex flex-wrap gap-2\">\n        <Button @click=\"showWithDescription\" variant=\"outline\">\n          带描述\n        </Button>\n        <Button @click=\"showWithAction\" variant=\"outline\">\n          带操作按钮\n        </Button>\n        <Button @click=\"showLongDuration\" variant=\"outline\">\n          长时间显示\n        </Button>\n        <Button @click=\"showLoadingToast\" variant=\"outline\">\n          Loading 状态\n        </Button>\n        <Button @click=\"showPromiseToast\" variant=\"outline\">\n          Promise Toast\n        </Button>\n        <Button @click=\"showMultipleToasts\" variant=\"outline\">\n          多个通知\n        </Button>\n      </div>\n    </section>\n\n    <!-- 代码示例 -->\n    <section>\n      <h3 class=\"text-lg font-semibold mb-3\">使用示例</h3>\n      <div class=\"space-y-4 text-sm\">\n        <div class=\"rounded-lg border p-4 bg-muted/50\">\n          <p class=\"font-mono text-xs\">\n            <span class=\"text-muted-foreground\">// 基础用法</span><br />\n            const { toast } = useToast()<br />\n            toast.success('操作成功')\n          </p>\n        </div>\n        \n        <div class=\"rounded-lg border p-4 bg-muted/50\">\n          <p class=\"font-mono text-xs\">\n            <span class=\"text-muted-foreground\">// 带描述</span><br />\n            toast.error('操作失败', {<br />\n            &nbsp;&nbsp;description: '网络连接超时'<br />\n            })\n          </p>\n        </div>\n\n        <div class=\"rounded-lg border p-4 bg-muted/50\">\n          <p class=\"font-mono text-xs\">\n            <span class=\"text-muted-foreground\">// 带操作按钮</span><br />\n            toast('确认删除？', {<br />\n            &nbsp;&nbsp;action: {<br />\n            &nbsp;&nbsp;&nbsp;&nbsp;label: '撤销',<br />\n            &nbsp;&nbsp;&nbsp;&nbsp;onClick: () => console.log('Undo')<br />\n            &nbsp;&nbsp;}<br />\n            })\n          </p>\n        </div>\n\n        <div class=\"rounded-lg border p-4 bg-muted/50\">\n          <p class=\"font-mono text-xs\">\n            <span class=\"text-muted-foreground\">// Promise Toast</span><br />\n            toast.promise(saveSettings(), {<br />\n            &nbsp;&nbsp;loading: '保存中...',<br />\n            &nbsp;&nbsp;success: '保存成功',<br />\n            &nbsp;&nbsp;error: '保存失败'<br />\n            })\n          </p>\n        </div>\n      </div>\n    </section>\n  </div>\n</template>\n"
  },
  {
    "path": "web/src/features/playground/composables/useApiMethods.ts",
    "content": "import { ref, computed } from 'vue'\nimport { call } from '@/core/rpc'\nimport type { ApiMethod } from '../types'\n\n// 获取所有可用的API方法\nexport function useApiMethods() {\n  const methods = ref<ApiMethod[]>([])\n  const loading = ref(false)\n  const error = ref<string | null>(null)\n\n  // 按分类分组的方法\n  const methodsByCategory = computed(() => {\n    const grouped: Record<string, ApiMethod[]> = {}\n\n    methods.value.forEach((method) => {\n      const category = method.category || '未分类'\n      if (!grouped[category]) {\n        grouped[category] = []\n      }\n      grouped[category].push(method)\n    })\n\n    return grouped\n  })\n\n  // 获取所有方法列表\n  const fetchMethods = async () => {\n    loading.value = true\n    error.value = null\n\n    try {\n      // 调用 system.listMethods 获取所有API方法\n      // 后端返回的是包含 name 和 description 的对象数组\n      const methodData = await call<Array<{ name: string; description: string }>>('system.listMethods')\n\n      // 为每个方法创建基本信息\n      const apiMethods: ApiMethod[] = methodData.map((method) => {\n        // 确保name是字符串类型\n        const methodName = String(method.name)\n        const methodDescription = String(method.description || `API 方法: ${methodName}`)\n        \n        // 根据方法名推断分类\n        let category = '未分类'\n        if (methodName.includes('.')) {\n          const parts = methodName.split('.')\n          if (parts.length >= 2 && parts[0]) {\n            category = parts[0]\n          }\n        } else if (methodName.startsWith('system')) {\n          category = '系统'\n        }\n\n        return {\n          name: methodName,\n          category,\n          description: methodDescription,\n        }\n      })\n\n      methods.value = apiMethods\n    } catch (err) {\n      error.value = err instanceof Error ? err.message : '获取API方法失败'\n      console.error('Failed to fetch API methods:', err)\n    } finally {\n      loading.value = false\n    }\n  }\n\n  // 根据名称搜索方法\n  const searchMethods = (query: string) => {\n    if (!query.trim()) return methods.value\n\n    const lowerQuery = query.toLowerCase()\n    return methods.value.filter(\n      (method) =>\n        method.name.toLowerCase().includes(lowerQuery) ||\n        method.category?.toLowerCase().includes(lowerQuery) ||\n        method.description?.toLowerCase().includes(lowerQuery)\n    )\n  }\n\n  // 根据分类获取方法\n  const getMethodsByCategory = (category: string) => {\n    return methods.value.filter((method) => method.category === category)\n  }\n\n  return {\n    methods,\n    methodsByCategory,\n    loading,\n    error,\n    fetchMethods,\n    searchMethods,\n    getMethodsByCategory,\n  }\n}\n"
  },
  {
    "path": "web/src/features/playground/composables/useApiTest.ts",
    "content": "import { ref } from 'vue'\nimport { call } from '@/core/rpc'\nimport type { ApiTestRequest, ApiTestResponse, ApiTestHistory } from '../types'\n\nexport function useApiTest() {\n  const loading = ref(false)\n  const history = ref<ApiTestHistory[]>([])\n\n  // 执行API测试\n  const testApi = async (request: ApiTestRequest): Promise<ApiTestResponse> => {\n    loading.value = true\n    const startTime = Date.now()\n\n    try {\n      const result = await call(request.method, request.params, 0)\n      const duration = Date.now() - startTime\n\n      const response: ApiTestResponse = {\n        success: true,\n        data: result,\n        timestamp: Date.now(),\n        duration,\n      }\n\n      // 添加到历史记录\n      addToHistory(request, response)\n\n      return response\n    } catch (error) {\n      const duration = Date.now() - startTime\n\n      const response: ApiTestResponse = {\n        success: false,\n        error: {\n          code: (error as any).code || -1,\n          message: (error as any).message || '未知错误',\n          data: (error as any).data,\n        },\n        timestamp: Date.now(),\n        duration,\n      }\n\n      // 添加到历史记录\n      addToHistory(request, response)\n\n      return response\n    } finally {\n      loading.value = false\n    }\n  }\n\n  // 添加到历史记录\n  const addToHistory = (request: ApiTestRequest, response: ApiTestResponse) => {\n    const historyItem: ApiTestHistory = {\n      id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,\n      request,\n      response,\n      timestamp: Date.now(),\n    }\n\n    history.value.unshift(historyItem)\n\n    // 限制历史记录数量\n    if (history.value.length > 50) {\n      history.value = history.value.slice(0, 50)\n    }\n  }\n\n  // 清空历史记录\n  const clearHistory = () => {\n    history.value = []\n  }\n\n  // 删除特定历史记录\n  const removeHistoryItem = (id: string) => {\n    const index = history.value.findIndex((item) => item.id === id)\n    if (index > -1) {\n      history.value.splice(index, 1)\n    }\n  }\n\n  // 格式化JSON参数\n  const formatParams = (params: unknown): string => {\n    try {\n      return JSON.stringify(params, null, 2)\n    } catch {\n      return String(params)\n    }\n  }\n\n  // 解析JSON参数\n  const parseParams = (jsonString: string): unknown => {\n    try {\n      return JSON.parse(jsonString)\n    } catch {\n      return jsonString\n    }\n  }\n\n  return {\n    loading,\n    history,\n    testApi,\n    clearHistory,\n    removeHistoryItem,\n    formatParams,\n    parseParams,\n  }\n}\n"
  },
  {
    "path": "web/src/features/playground/composables/useIntegrationTest.ts",
    "content": "import { ref } from 'vue'\nimport { call } from '@/core/rpc'\nimport { createInfinityNikkiAlbumScanParams } from '@/extensions/infinity_nikki'\n\n// 测试场景分类\nexport type ScenarioCategory = 'gallery' | 'database' | 'system'\n\n// 测试步骤\nexport interface TestStep {\n  method: string\n  params?: unknown\n  description: string\n}\n\n// 测试场景定义\nexport interface TestScenario {\n  id: string\n  name: string\n  description: string\n  icon: string\n  category: ScenarioCategory\n  steps: TestStep[]\n  dangerous?: boolean\n}\n\n// 执行日志\nexport interface ExecutionLog {\n  timestamp: number\n  step: string\n  status: 'pending' | 'running' | 'success' | 'error'\n  message: string\n  data?: unknown\n  error?: string\n  duration?: number\n}\n\n// 执行结果\nexport interface ExecutionResult {\n  scenarioId: string\n  startTime: number\n  endTime?: number\n  duration?: number\n  status: 'running' | 'success' | 'error'\n  logs: ExecutionLog[]\n  finalResult?: unknown\n}\n\nexport function useIntegrationTest() {\n  const executing = ref(false)\n  const currentExecution = ref<ExecutionResult | null>(null)\n  const executionHistory = ref<ExecutionResult[]>([])\n\n  // 定义所有可用的测试场景\n  const scenarios = ref<TestScenario[]>([\n    {\n      id: 'scan-photos',\n      name: '扫描照片文件夹',\n      description: '从游戏目录扫描照片，生成缩略图',\n      icon: '📸',\n      category: 'gallery',\n      steps: [\n        {\n          method: 'extensions.infinityNikki.getGameDirectory',\n          description: '获取游戏目录',\n        },\n        {\n          method: 'gallery.scanDirectory',\n          description: '扫描照片目录并生成缩略图',\n        },\n      ],\n    },\n  ])\n\n  // 添加日志\n  const addLog = (log: ExecutionLog) => {\n    if (currentExecution.value) {\n      currentExecution.value.logs.push(log)\n    }\n  }\n\n  // 执行单个步骤\n  const executeStep = async (step: TestStep, context: Record<string, unknown> = {}) => {\n    const startTime = Date.now()\n\n    addLog({\n      timestamp: startTime,\n      step: step.method,\n      status: 'running',\n      message: step.description,\n    })\n\n    try {\n      let params = step.params\n\n      // 如果是扫描目录步骤，使用之前获取的 gameDir\n      if (step.method === 'gallery.scanDirectory' && typeof context.gameDir === 'string') {\n        params = createInfinityNikkiAlbumScanParams(context.gameDir)\n      }\n\n      const result = await call(step.method, params, 60000) // 60秒超时\n      const duration = Date.now() - startTime\n\n      addLog({\n        timestamp: Date.now(),\n        step: step.method,\n        status: 'success',\n        message: `${step.description} - 完成`,\n        data: result,\n        duration,\n      })\n\n      return { success: true, data: result }\n    } catch (error) {\n      const duration = Date.now() - startTime\n      const errorMessage = error instanceof Error ? error.message : String(error)\n\n      addLog({\n        timestamp: Date.now(),\n        step: step.method,\n        status: 'error',\n        message: `${step.description} - 失败`,\n        error: errorMessage,\n        duration,\n      })\n\n      return { success: false, error: errorMessage }\n    }\n  }\n\n  // 执行测试场景\n  const executeScenario = async (scenarioId: string) => {\n    const scenario = scenarios.value.find((s) => s.id === scenarioId)\n    if (!scenario) {\n      throw new Error(`Scenario not found: ${scenarioId}`)\n    }\n\n    if (executing.value) {\n      throw new Error('Already executing a scenario')\n    }\n\n    executing.value = true\n    const startTime = Date.now()\n\n    // 初始化执行结果\n    currentExecution.value = {\n      scenarioId,\n      startTime,\n      status: 'running',\n      logs: [],\n    }\n\n    try {\n      const context: Record<string, unknown> = {}\n\n      // 依次执行每个步骤\n      for (const step of scenario.steps) {\n        const result = await executeStep(step, context)\n\n        if (!result.success) {\n          throw new Error(`Step failed: ${step.method}`)\n        }\n\n        // 保存步骤结果到上下文\n        if (step.method === 'extensions.infinityNikki.getGameDirectory') {\n          // 从返回的对象中提取 gameDir 字符串\n          context.gameDir = (result.data as any)?.gameDir || result.data\n        }\n\n        // 保存最终结果\n        currentExecution.value.finalResult = result.data\n      }\n\n      const endTime = Date.now()\n      currentExecution.value.endTime = endTime\n      currentExecution.value.duration = endTime - startTime\n      currentExecution.value.status = 'success'\n\n      // 添加到历史记录\n      executionHistory.value.unshift({ ...currentExecution.value })\n\n      // 限制历史记录数量\n      if (executionHistory.value.length > 20) {\n        executionHistory.value = executionHistory.value.slice(0, 20)\n      }\n    } catch (error) {\n      const endTime = Date.now()\n      currentExecution.value.endTime = endTime\n      currentExecution.value.duration = endTime - startTime\n      currentExecution.value.status = 'error'\n\n      // 添加到历史记录\n      executionHistory.value.unshift({ ...currentExecution.value })\n    } finally {\n      executing.value = false\n    }\n  }\n\n  // 清空当前执行结果\n  const clearCurrentExecution = () => {\n    currentExecution.value = null\n  }\n\n  // 清空历史记录\n  const clearHistory = () => {\n    executionHistory.value = []\n  }\n\n  // 根据分类获取场景\n  const getScenariosByCategory = (category: ScenarioCategory) => {\n    return scenarios.value.filter((s) => s.category === category)\n  }\n\n  return {\n    executing,\n    currentExecution,\n    executionHistory,\n    scenarios,\n    executeScenario,\n    clearCurrentExecution,\n    clearHistory,\n    getScenariosByCategory,\n  }\n}\n"
  },
  {
    "path": "web/src/features/playground/composables/useMethodSignature.ts",
    "content": "import { ref } from 'vue'\nimport { call } from '@/core/rpc'\nimport type { MethodSignatureResponse, JSONSchema, FormField, JSONSchemaType } from '../types'\n\n/**\n * 获取方法签名的 composable\n */\nexport function useMethodSignature() {\n  const error = ref<string | null>(null)\n  const signature = ref<MethodSignatureResponse | null>(null)\n  const schema = ref<JSONSchema | null>(null)\n  const formFields = ref<FormField[]>([])\n\n  /**\n   * 获取方法签名\n   */\n  const fetchSignature = async (methodName: string) => {\n    error.value = null\n    signature.value = null\n    schema.value = null\n    formFields.value = []\n\n    try {\n      const response = await call<MethodSignatureResponse>('system.methodSignature', {\n        method: methodName,\n      })\n\n      signature.value = response\n\n      // 解析 JSON Schema\n      try {\n        const parsedSchema = JSON.parse(response.paramsSchema) as JSONSchema\n        console.log('Parsed schema:', parsedSchema)\n        schema.value = parsedSchema\n        const fields = parseSchemaToFormFields(parsedSchema)\n        console.log('Parsed fields:', fields)\n        formFields.value = fields\n      } catch (parseError) {\n        console.error('Failed to parse params schema:', parseError)\n        error.value = '解析参数 schema 失败'\n      }\n    } catch (err) {\n      error.value = err instanceof Error ? err.message : '获取方法签名失败'\n      console.error('Failed to fetch method signature:', err)\n    }\n  }\n\n  /**\n   * 统一的 $ref 解析工具\n   */\n  const resolveRef = (rootSchema: JSONSchema, ref: string): JSONSchema | null => {\n    if (!ref.startsWith('#/')) {\n      return null\n    }\n\n    // 同时支持 definitions 和 $defs (JSON Schema 2020-12)\n    const defs = rootSchema.definitions ?? (rootSchema as any).$defs ?? {}\n\n    // 处理不同的 $ref 格式\n    let refPath = ref\n    if (refPath.startsWith('#/definitions/')) {\n      refPath = refPath.replace('#/definitions/', '')\n    } else if (refPath.startsWith('#/$defs/')) {\n      refPath = refPath.replace('#/$defs/', '')\n    } else if (refPath.startsWith('#/')) {\n      refPath = refPath.replace('#/', '')\n    }\n\n    return defs[refPath] ?? null\n  }\n\n  /**\n   * 解析 JSON Schema 为表单字段配置\n   */\n  const parseSchemaToFormFields = (jsonSchema: JSONSchema): FormField[] => {\n    const fields: FormField[] = []\n\n    // 处理顶层 $ref 引用\n    let actualSchema = jsonSchema\n    if (jsonSchema.$ref) {\n      const resolved = resolveRef(jsonSchema, jsonSchema.$ref)\n      if (resolved) {\n        actualSchema = resolved\n        console.log('Resolved top-level $ref:', jsonSchema.$ref, '-> schema:', actualSchema)\n      } else {\n        console.warn('Definition not found for:', jsonSchema.$ref)\n        console.warn('Available definitions:', Object.keys(jsonSchema.definitions || {}))\n      }\n    }\n\n    // 如果没有 properties，返回空数组\n    if (!actualSchema.properties) {\n      return fields\n    }\n\n    const requiredFields = actualSchema.required || []\n\n    // 遍历所有属性\n    for (const [propName, propSchema] of Object.entries(actualSchema.properties)) {\n      const field = parsePropertyToField(\n        propName,\n        propSchema,\n        requiredFields.includes(propName),\n        jsonSchema\n      )\n      if (field) {\n        fields.push(field)\n      }\n    }\n\n    return fields\n  }\n\n  /**\n   * 解析单个属性为表单字段\n   */\n  const parsePropertyToField = (\n    name: string,\n    propSchema: JSONSchema,\n    isRequired: boolean,\n    rootSchema: JSONSchema\n  ): FormField | null => {\n    // 1. 先处理属性级别的 $ref 引用\n    let normalizedSchema = { ...propSchema }\n\n    if (propSchema.$ref) {\n      const resolved = resolveRef(rootSchema, propSchema.$ref)\n      if (resolved) {\n        // 合并 $ref 解析结果，保留原属性上可能声明的 title/description\n        normalizedSchema = {\n          ...resolved,\n          title: propSchema.title ?? resolved.title,\n          description: propSchema.description ?? resolved.description,\n        }\n        console.log(\n          'Resolved property $ref:',\n          name,\n          propSchema.$ref,\n          '-> type:',\n          normalizedSchema.type\n        )\n      } else {\n        console.warn('Definition not found for property:', name, propSchema.$ref)\n      }\n    }\n\n    // 2. 处理 anyOf/oneOf，转换为标准的 type\n    if (normalizedSchema.anyOf || normalizedSchema.oneOf) {\n      const union = normalizedSchema.anyOf || normalizedSchema.oneOf\n      // 从 anyOf/oneOf 中提取所有 type\n      const types: string[] = []\n      union!.forEach((subSchema) => {\n        if (subSchema.type) {\n          if (Array.isArray(subSchema.type)) {\n            types.push(...subSchema.type)\n          } else {\n            types.push(subSchema.type)\n          }\n        }\n      })\n\n      // 去重\n      const uniqueTypes = Array.from(new Set(types))\n\n      // 如果提取到类型，归一化为 type\n      if (uniqueTypes.length > 0) {\n        normalizedSchema.type =\n          uniqueTypes.length === 1\n            ? (uniqueTypes[0] as JSONSchemaType)\n            : (uniqueTypes as JSONSchemaType[])\n      }\n    }\n\n    // 确定字段类型\n    let fieldType = normalizedSchema.type\n    if (Array.isArray(fieldType)) {\n      // 如果是联合类型，取第一个非 null 的类型\n      fieldType = fieldType.find((t) => t !== 'null') || fieldType[0]\n    }\n\n    if (!fieldType || typeof fieldType !== 'string') {\n      console.warn(`Unknown field type for ${name}:`, fieldType)\n      return null\n    }\n\n    const field: FormField = {\n      name,\n      label: propSchema.title || formatLabel(name),\n      type: fieldType,\n      required: isRequired,\n      description: propSchema.description,\n      defaultValue: propSchema.default,\n    }\n\n    // 根据类型添加特定配置\n    switch (fieldType) {\n      case 'string':\n        field.enum = propSchema.enum\n        field.pattern = propSchema.pattern\n        field.minLength = propSchema.minLength\n        field.maxLength = propSchema.maxLength\n        break\n\n      case 'number':\n      case 'integer':\n        field.minimum = propSchema.minimum\n        field.maximum = propSchema.maximum\n        break\n\n      case 'array':\n        if (propSchema.items) {\n          // 简化处理：假设数组项是简单类型\n          const itemType = propSchema.items.type\n          if (itemType && typeof itemType === 'string') {\n            field.items = {\n              name: `${name}_item`,\n              label: 'Item',\n              type: itemType,\n              required: false,\n            }\n          }\n        }\n        field.minItems = propSchema.minItems\n        field.maxItems = propSchema.maxItems\n        break\n\n      case 'object':\n        if (normalizedSchema.properties) {\n          // 递归解析嵌套对象\n          field.properties = []\n          const nestedRequired = normalizedSchema.required || []\n          for (const [nestedName, nestedSchema] of Object.entries(normalizedSchema.properties)) {\n            const nestedField = parsePropertyToField(\n              nestedName,\n              nestedSchema,\n              nestedRequired.includes(nestedName),\n              rootSchema\n            )\n            if (nestedField) {\n              field.properties.push(nestedField)\n            }\n          }\n        }\n        break\n    }\n\n    return field\n  }\n\n  /**\n   * 格式化字段名为标签\n   */\n  const formatLabel = (name: string): string => {\n    // 将 snake_case 或 camelCase 转换为可读的标签\n    return name\n      .replace(/([A-Z])/g, ' $1') // camelCase -> camel Case\n      .replace(/_/g, ' ') // snake_case -> snake case\n      .replace(/^./, (str) => str.toUpperCase()) // 首字母大写\n      .trim()\n  }\n\n  /**\n   * 生成默认参数值（基于 schema）\n   */\n  const generateDefaultParams = (): Record<string, unknown> => {\n    const params: Record<string, unknown> = {}\n\n    formFields.value.forEach((field) => {\n      if (field.defaultValue !== undefined) {\n        params[field.name] = field.defaultValue\n      } else if (field.required) {\n        // 为必填字段生成默认值\n        params[field.name] = getDefaultValueForType(field.type)\n      }\n    })\n\n    return params\n  }\n\n  /**\n   * 根据类型获取默认值\n   */\n  const getDefaultValueForType = (type: string): unknown => {\n    switch (type) {\n      case 'string':\n        return ''\n      case 'number':\n      case 'integer':\n        return 0\n      case 'boolean':\n        return false\n      case 'array':\n        return []\n      case 'object':\n        return {}\n      default:\n        return null\n    }\n  }\n\n  /**\n   * 验证参数是否符合 schema\n   */\n  const validateParams = (params: unknown): { valid: boolean; errors: string[] } => {\n    const errors: string[] = []\n\n    if (!schema.value || !formFields.value.length) {\n      return { valid: true, errors: [] }\n    }\n\n    // 简单验证：检查必填字段\n    formFields.value.forEach((field) => {\n      if (field.required) {\n        const value = (params as Record<string, unknown>)[field.name]\n        if (value === undefined || value === null || value === '') {\n          errors.push(`字段 \"${field.label}\" 是必填的`)\n        }\n      }\n    })\n\n    return {\n      valid: errors.length === 0,\n      errors,\n    }\n  }\n\n  return {\n    error,\n    signature,\n    schema,\n    formFields,\n    fetchSignature,\n    generateDefaultParams,\n    validateParams,\n  }\n}\n"
  },
  {
    "path": "web/src/features/playground/index.ts",
    "content": "// 导出路由\nexport { default as routes } from './routes'\n\n// 导出类型\nexport * from './types'\n\n// 导出组合式函数\nexport { useApiMethods } from './composables/useApiMethods'\nexport { useApiTest } from './composables/useApiTest'\nexport { useIntegrationTest } from './composables/useIntegrationTest'\n\n// 导出组件\nexport { default as ApiMethodList } from './components/ApiMethodList.vue'\nexport { default as ApiTestPanel } from './components/ApiTestPanel.vue'\nexport { default as JsonResponseViewer } from './components/JsonResponseViewer.vue'\n"
  },
  {
    "path": "web/src/features/playground/pages/ApiPlaygroundPage.vue",
    "content": "<script setup lang=\"ts\">\nimport { onMounted, ref } from 'vue'\n// import { Splitter, SplitterContent, SplitterPanel } from '@/components/ui/split' // 不使用split组件\nimport ApiMethodList from '../components/ApiMethodList.vue'\nimport ApiTestPanel from '../components/ApiTestPanel.vue'\nimport { useApiMethods } from '../composables/useApiMethods'\nimport type { ApiMethod, ApiTestResponse } from '../types'\n\nconst { methods, loading, error, fetchMethods } = useApiMethods()\n\nconst selectedMethod = ref<ApiMethod | null>(null)\nconst lastResponse = ref<ApiTestResponse | null>(null)\n\n// 页面加载时获取API方法列表\nonMounted(() => {\n  fetchMethods()\n})\n\n// 选择方法\nconst handleSelectMethod = (method: ApiMethod) => {\n  selectedMethod.value = method\n}\n\n// 接收响应\nconst handleResponse = (response: ApiTestResponse) => {\n  lastResponse.value = response\n}\n\n// 刷新方法列表\nconst handleRefresh = () => {\n  fetchMethods()\n}\n</script>\n\n<template>\n  <div class=\"flex h-full flex-col\">\n    <!-- 错误提示 -->\n    <div v-if=\"error\" class=\"border-l-4 border-red-500 bg-red-50 p-4 text-red-700\">\n      <div class=\"flex\">\n        <div class=\"flex-shrink-0\">\n          <svg class=\"h-5 w-5 text-red-400\" viewBox=\"0 0 20 20\" fill=\"currentColor\">\n            <path\n              fill-rule=\"evenodd\"\n              d=\"M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z\"\n              clip-rule=\"evenodd\"\n            />\n          </svg>\n        </div>\n        <div class=\"ml-3\">\n          <h3 class=\"text-sm font-medium text-red-800\">获取API方法失败</h3>\n          <div class=\"mt-2 text-sm text-red-700\">\n            {{ error }}\n          </div>\n          <div class=\"mt-4\">\n            <div class=\"-mx-2 -my-1.5 flex\">\n              <button\n                type=\"button\"\n                @click=\"handleRefresh\"\n                class=\"px-3 py-2 text-sm font-medium text-red-800 hover:bg-red-100 focus:ring-2 focus:ring-red-600 focus:ring-offset-2 focus:ring-offset-red-50 focus:outline-none\"\n              >\n                重试\n              </button>\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n\n    <!-- 主要内容 -->\n    <div class=\"flex-1 overflow-hidden p-4\">\n      <div class=\"flex h-full gap-4\">\n        <!-- 左侧方法列表 -->\n        <div class=\"w-72 overflow-hidden rounded-lg border\">\n          <ApiMethodList\n            :methods=\"methods\"\n            :loading=\"loading\"\n            :selected-method=\"selectedMethod?.name\"\n            @select=\"handleSelectMethod\"\n            @refresh=\"handleRefresh\"\n          />\n        </div>\n\n        <!-- 右侧测试面板 -->\n        <div class=\"flex-1 overflow-hidden rounded-lg border\">\n          <ApiTestPanel :method=\"selectedMethod\" @response=\"handleResponse\" />\n        </div>\n      </div>\n    </div>\n  </div>\n</template>\n"
  },
  {
    "path": "web/src/features/playground/pages/IntegrationTestPage.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed } from 'vue'\nimport { PlayIcon, Trash2Icon, ClockIcon } from 'lucide-vue-next'\nimport { Button } from '@/components/ui/button'\nimport { Badge } from '@/components/ui/badge'\nimport JsonResponseViewer from '../components/JsonResponseViewer.vue'\nimport { useIntegrationTest } from '../composables/useIntegrationTest'\nimport type { TestScenario, ExecutionLog } from '../composables/useIntegrationTest'\n\nconst {\n  executing,\n  currentExecution,\n  executionHistory,\n  scenarios,\n  executeScenario,\n  clearCurrentExecution,\n  clearHistory,\n  getScenariosByCategory,\n} = useIntegrationTest()\n\n// 获取分类后的场景\nconst galleryScenarios = computed(() => getScenariosByCategory('gallery'))\nconst databaseScenarios = computed(() => getScenariosByCategory('database'))\nconst systemScenarios = computed(() => getScenariosByCategory('system'))\n\n// 执行场景\nconst handleExecuteScenario = async (scenario: TestScenario) => {\n  if (executing.value) return\n\n  try {\n    await executeScenario(scenario.id)\n  } catch (error) {\n    console.error('Failed to execute scenario:', error)\n  }\n}\n\n// 格式化时间\nconst formatTime = (timestamp: number) => {\n  return new Date(timestamp).toLocaleTimeString('zh-CN')\n}\n\n// 格式化持续时间\nconst formatDuration = (duration?: number) => {\n  if (!duration) return '-'\n  if (duration < 1000) return `${duration}ms`\n  return `${(duration / 1000).toFixed(2)}s`\n}\n\n// 获取日志状态的颜色类\nconst getLogStatusClass = (status: ExecutionLog['status']) => {\n  switch (status) {\n    case 'running':\n      return 'text-blue-600 dark:text-blue-400'\n    case 'success':\n      return 'text-green-600 dark:text-green-400'\n    case 'error':\n      return 'text-red-600 dark:text-red-400'\n    default:\n      return 'text-gray-600 dark:text-gray-400'\n  }\n}\n\n// 获取日志状态的图标\nconst getLogStatusIcon = (status: ExecutionLog['status']) => {\n  switch (status) {\n    case 'running':\n      return '⏳'\n    case 'success':\n      return '✅'\n    case 'error':\n      return '❌'\n    default:\n      return '⚪'\n  }\n}\n</script>\n\n<template>\n  <div class=\"flex h-full flex-col\">\n    <!-- 主要内容 -->\n    <div class=\"flex-1 overflow-y-auto p-4\">\n      <div class=\"mx-auto max-w-6xl space-y-6\">\n        <!-- 图库管理场景 -->\n        <section v-if=\"galleryScenarios.length > 0\">\n          <h2 class=\"mb-3 text-lg font-semibold\">📸 图库管理</h2>\n          <div class=\"grid gap-3 md:grid-cols-2 lg:grid-cols-3\">\n            <div\n              v-for=\"scenario in galleryScenarios\"\n              :key=\"scenario.id\"\n              class=\"rounded-lg border bg-card p-4 shadow-sm transition-shadow hover:shadow-md\"\n            >\n              <div class=\"mb-2 flex items-start justify-between\">\n                <div class=\"flex items-center gap-2\">\n                  <span class=\"text-2xl\">{{ scenario.icon }}</span>\n                  <h3 class=\"font-medium\">{{ scenario.name }}</h3>\n                </div>\n                <Badge v-if=\"scenario.dangerous\" variant=\"destructive\" class=\"text-xs\">\n                  危险\n                </Badge>\n              </div>\n              <p class=\"mb-4 text-sm text-muted-foreground\">\n                {{ scenario.description }}\n              </p>\n              <div class=\"mb-3 space-y-1\">\n                <p class=\"text-xs text-muted-foreground\">执行步骤：</p>\n                <ul class=\"space-y-1 text-xs text-muted-foreground\">\n                  <li\n                    v-for=\"(step, index) in scenario.steps\"\n                    :key=\"index\"\n                    class=\"flex items-start gap-1\"\n                  >\n                    <span class=\"mt-0.5\">{{ index + 1 }}.</span>\n                    <span>{{ step.description }}</span>\n                  </li>\n                </ul>\n              </div>\n              <Button\n                @click=\"handleExecuteScenario(scenario)\"\n                :disabled=\"executing\"\n                class=\"w-full\"\n                size=\"sm\"\n              >\n                <PlayIcon v-if=\"!executing\" class=\"mr-2 h-3.5 w-3.5\" />\n                <div\n                  v-else\n                  class=\"mr-2 h-3.5 w-3.5 animate-spin rounded-full border-2 border-current border-t-transparent\"\n                />\n                {{ executing ? '执行中...' : '执行' }}\n              </Button>\n            </div>\n          </div>\n        </section>\n\n        <!-- 数据库管理场景 -->\n        <section v-if=\"databaseScenarios.length > 0\">\n          <h2 class=\"mb-3 text-lg font-semibold\">🗄️ 数据库管理</h2>\n          <div class=\"grid gap-3 md:grid-cols-2 lg:grid-cols-3\">\n            <div\n              v-for=\"scenario in databaseScenarios\"\n              :key=\"scenario.id\"\n              class=\"rounded-lg border bg-card p-4 shadow-sm transition-shadow hover:shadow-md\"\n            >\n              <div class=\"mb-2 flex items-start justify-between\">\n                <div class=\"flex items-center gap-2\">\n                  <span class=\"text-2xl\">{{ scenario.icon }}</span>\n                  <h3 class=\"font-medium\">{{ scenario.name }}</h3>\n                </div>\n                <Badge v-if=\"scenario.dangerous\" variant=\"destructive\" class=\"text-xs\">\n                  危险\n                </Badge>\n              </div>\n              <p class=\"mb-4 text-sm text-muted-foreground\">\n                {{ scenario.description }}\n              </p>\n              <div class=\"mb-3 space-y-1\">\n                <p class=\"text-xs text-muted-foreground\">执行步骤：</p>\n                <ul class=\"space-y-1 text-xs text-muted-foreground\">\n                  <li\n                    v-for=\"(step, index) in scenario.steps\"\n                    :key=\"index\"\n                    class=\"flex items-start gap-1\"\n                  >\n                    <span class=\"mt-0.5\">{{ index + 1 }}.</span>\n                    <span>{{ step.description }}</span>\n                  </li>\n                </ul>\n              </div>\n              <Button\n                @click=\"handleExecuteScenario(scenario)\"\n                :disabled=\"executing\"\n                class=\"w-full\"\n                size=\"sm\"\n                :variant=\"scenario.dangerous ? 'destructive' : 'default'\"\n              >\n                <PlayIcon v-if=\"!executing\" class=\"mr-2 h-3.5 w-3.5\" />\n                <div\n                  v-else\n                  class=\"mr-2 h-3.5 w-3.5 animate-spin rounded-full border-2 border-current border-t-transparent\"\n                />\n                {{ executing ? '执行中...' : '执行' }}\n              </Button>\n            </div>\n          </div>\n        </section>\n\n        <!-- 系统管理场景 -->\n        <section v-if=\"systemScenarios.length > 0\">\n          <h2 class=\"mb-3 text-lg font-semibold\">⚙️ 系统管理</h2>\n          <div class=\"grid gap-3 md:grid-cols-2 lg:grid-cols-3\">\n            <div\n              v-for=\"scenario in systemScenarios\"\n              :key=\"scenario.id\"\n              class=\"rounded-lg border bg-card p-4 shadow-sm transition-shadow hover:shadow-md\"\n            >\n              <div class=\"mb-2 flex items-start justify-between\">\n                <div class=\"flex items-center gap-2\">\n                  <span class=\"text-2xl\">{{ scenario.icon }}</span>\n                  <h3 class=\"font-medium\">{{ scenario.name }}</h3>\n                </div>\n                <Badge v-if=\"scenario.dangerous\" variant=\"destructive\" class=\"text-xs\">\n                  危险\n                </Badge>\n              </div>\n              <p class=\"mb-4 text-sm text-muted-foreground\">\n                {{ scenario.description }}\n              </p>\n              <div class=\"mb-3 space-y-1\">\n                <p class=\"text-xs text-muted-foreground\">执行步骤：</p>\n                <ul class=\"space-y-1 text-xs text-muted-foreground\">\n                  <li\n                    v-for=\"(step, index) in scenario.steps\"\n                    :key=\"index\"\n                    class=\"flex items-start gap-1\"\n                  >\n                    <span class=\"mt-0.5\">{{ index + 1 }}.</span>\n                    <span>{{ step.description }}</span>\n                  </li>\n                </ul>\n              </div>\n              <Button\n                @click=\"handleExecuteScenario(scenario)\"\n                :disabled=\"executing\"\n                class=\"w-full\"\n                size=\"sm\"\n                :variant=\"scenario.dangerous ? 'destructive' : 'default'\"\n              >\n                <PlayIcon v-if=\"!executing\" class=\"mr-2 h-3.5 w-3.5\" />\n                <div\n                  v-else\n                  class=\"mr-2 h-3.5 w-3.5 animate-spin rounded-full border-2 border-current border-t-transparent\"\n                />\n                {{ executing ? '执行中...' : '执行' }}\n              </Button>\n            </div>\n          </div>\n        </section>\n\n        <!-- 执行结果 -->\n        <section v-if=\"currentExecution\" class=\"rounded-lg border bg-card\">\n          <div class=\"border-b p-4\">\n            <div class=\"flex items-center justify-between\">\n              <h2 class=\"text-lg font-semibold\">📊 执行结果</h2>\n              <Button @click=\"clearCurrentExecution\" variant=\"ghost\" size=\"sm\">\n                <Trash2Icon class=\"mr-2 h-4 w-4\" />\n                清空\n              </Button>\n            </div>\n          </div>\n\n          <div class=\"p-4\">\n            <!-- 执行信息 -->\n            <div class=\"mb-4 flex items-center gap-4 text-sm\">\n              <div class=\"flex items-center gap-2\">\n                <span class=\"text-muted-foreground\">状态:</span>\n                <Badge\n                  :variant=\"\n                    currentExecution.status === 'success'\n                      ? 'default'\n                      : currentExecution.status === 'error'\n                        ? 'destructive'\n                        : 'secondary'\n                  \"\n                >\n                  {{\n                    currentExecution.status === 'running'\n                      ? '执行中'\n                      : currentExecution.status === 'success'\n                        ? '成功'\n                        : '失败'\n                  }}\n                </Badge>\n              </div>\n              <div class=\"flex items-center gap-2\">\n                <ClockIcon class=\"h-4 w-4 text-muted-foreground\" />\n                <span class=\"text-muted-foreground\">耗时:</span>\n                <span class=\"font-mono\">{{ formatDuration(currentExecution.duration) }}</span>\n              </div>\n              <div class=\"flex items-center gap-2\">\n                <span class=\"text-muted-foreground\">开始时间:</span>\n                <span class=\"font-mono\">{{ formatTime(currentExecution.startTime) }}</span>\n              </div>\n            </div>\n\n            <!-- 执行日志 -->\n            <div class=\"mb-4\">\n              <h3 class=\"mb-2 text-sm font-medium\">执行日志</h3>\n              <div class=\"rounded-md border bg-muted/50 p-3\">\n                <div class=\"space-y-2 font-mono text-xs\">\n                  <div\n                    v-for=\"(log, index) in currentExecution.logs\"\n                    :key=\"index\"\n                    class=\"flex items-start gap-2\"\n                    :class=\"getLogStatusClass(log.status)\"\n                  >\n                    <span class=\"flex-shrink-0\">{{ getLogStatusIcon(log.status) }}</span>\n                    <span class=\"flex-shrink-0 text-muted-foreground\">\n                      {{ formatTime(log.timestamp) }}\n                    </span>\n                    <span class=\"flex-1\">{{ log.message }}</span>\n                    <span v-if=\"log.duration\" class=\"flex-shrink-0 text-muted-foreground\">\n                      ({{ formatDuration(log.duration) }})\n                    </span>\n                  </div>\n                  <div v-if=\"currentExecution.logs.length === 0\" class=\"text-muted-foreground\">\n                    暂无日志\n                  </div>\n                </div>\n              </div>\n            </div>\n\n            <!-- 响应数据 -->\n            <div>\n              <h3 class=\"mb-2 text-sm font-medium\">响应数据</h3>\n              <JsonResponseViewer\n                :response=\"{\n                  success: currentExecution.status === 'success',\n                  data: currentExecution.finalResult,\n                  timestamp: currentExecution.startTime,\n                  duration: currentExecution.duration || 0,\n                }\"\n                :loading=\"currentExecution.status === 'running'\"\n              />\n            </div>\n          </div>\n        </section>\n\n        <!-- 执行历史 -->\n        <section v-if=\"executionHistory.length > 0\" class=\"rounded-lg border bg-card\">\n          <div class=\"border-b p-4\">\n            <div class=\"flex items-center justify-between\">\n              <h2 class=\"text-lg font-semibold\">📜 执行历史</h2>\n              <Button @click=\"clearHistory\" variant=\"ghost\" size=\"sm\">\n                <Trash2Icon class=\"mr-2 h-4 w-4\" />\n                清空\n              </Button>\n            </div>\n          </div>\n\n          <div class=\"divide-y\">\n            <div\n              v-for=\"(execution, index) in executionHistory.slice(0, 5)\"\n              :key=\"index\"\n              class=\"p-4\"\n            >\n              <div class=\"flex items-center justify-between\">\n                <div class=\"flex items-center gap-3\">\n                  <Badge\n                    :variant=\"\n                      execution.status === 'success'\n                        ? 'default'\n                        : execution.status === 'error'\n                          ? 'destructive'\n                          : 'secondary'\n                    \"\n                  >\n                    {{\n                      execution.status === 'running'\n                        ? '执行中'\n                        : execution.status === 'success'\n                          ? '成功'\n                          : '失败'\n                    }}\n                  </Badge>\n                  <span class=\"text-sm font-medium\">\n                    {{\n                      scenarios.find((s) => s.id === execution.scenarioId)?.name ||\n                      execution.scenarioId\n                    }}\n                  </span>\n                </div>\n                <div class=\"flex items-center gap-4 text-sm text-muted-foreground\">\n                  <span class=\"font-mono\">{{ formatDuration(execution.duration) }}</span>\n                  <span class=\"font-mono\">{{ formatTime(execution.startTime) }}</span>\n                </div>\n              </div>\n            </div>\n          </div>\n        </section>\n      </div>\n    </div>\n  </div>\n</template>\n"
  },
  {
    "path": "web/src/features/playground/pages/PlaygroundPage.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed, watch } from 'vue'\nimport { useRoute, useRouter } from 'vue-router'\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'\nimport ApiPlaygroundPage from './ApiPlaygroundPage.vue'\nimport IntegrationTestPage from './IntegrationTestPage.vue'\n\nconst route = useRoute()\nconst router = useRouter()\n\n// 从 query 参数获取当前 tab，默认为 'api'\nconst activeTab = computed({\n  get: () => (route.query.tab as string) || 'api',\n  set: (value: string) => {\n    router.replace({\n      query: { ...route.query, tab: value },\n    })\n  },\n})\n\n// 监听 tab 变化，更新页面标题\nwatch(\n  activeTab,\n  (tab) => {\n    document.title = tab === 'api' ? 'API 测试工具' : '集成测试工具'\n  },\n  { immediate: true }\n)\n</script>\n\n<template>\n  <div class=\"flex h-full flex-col bg-background\">\n    <!-- 标题和 Tabs -->\n    <div class=\"flex items-center gap-6 border-b p-4\">\n      <Tabs v-model=\"activeTab\" class=\"flex-shrink-0\">\n        <TabsList>\n          <TabsTrigger value=\"api\">API 测试</TabsTrigger>\n          <TabsTrigger value=\"integration\">集成测试</TabsTrigger>\n        </TabsList>\n      </Tabs>\n\n      <div class=\"flex-1\">\n        <p class=\"text-muted-foreground\">开发者测试工具集</p>\n      </div>\n    </div>\n\n    <!-- Tab 内容 -->\n    <Tabs v-model=\"activeTab\" class=\"flex flex-1 flex-col overflow-hidden\">\n      <div class=\"hidden\"></div>\n\n      <TabsContent\n        value=\"api\"\n        class=\"flex-1 overflow-hidden data-[state=active]:flex data-[state=active]:flex-col\"\n      >\n        <ApiPlaygroundPage />\n      </TabsContent>\n\n      <TabsContent\n        value=\"integration\"\n        class=\"flex-1 overflow-hidden data-[state=active]:flex data-[state=active]:flex-col\"\n      >\n        <IntegrationTestPage />\n      </TabsContent>\n    </Tabs>\n  </div>\n</template>\n"
  },
  {
    "path": "web/src/features/playground/routes.ts",
    "content": "import type { RouteRecordRaw } from 'vue-router'\n\nconst routes: RouteRecordRaw[] = [\n  {\n    path: '/playground',\n    name: 'playground',\n    component: () => import('./pages/PlaygroundPage.vue'),\n    meta: {\n      title: '开发工具',\n    },\n  },\n]\n\nexport default routes\n"
  },
  {
    "path": "web/src/features/playground/types/index.ts",
    "content": "// API 方法信息\nexport interface ApiMethod {\n  name: string\n  description?: string\n  params?: ApiParam[]\n  category?: string\n}\n\n// API 参数信息\nexport interface ApiParam {\n  name: string\n  type: string\n  required: boolean\n  description?: string\n  defaultValue?: unknown\n}\n\n// API 测试请求\nexport interface ApiTestRequest {\n  method: string\n  params?: unknown\n}\n\n// API 测试响应\nexport interface ApiTestResponse {\n  success: boolean\n  data?: unknown\n  error?: {\n    code: number\n    message: string\n    data?: unknown\n  }\n  timestamp: number\n  duration: number\n}\n\n// API 测试历史记录\nexport interface ApiTestHistory {\n  id: string\n  request: ApiTestRequest\n  response: ApiTestResponse\n  timestamp: number\n}\n\n// 方法签名响应\nexport interface MethodSignatureResponse {\n  method: string\n  description: string\n  paramsSchema: string  // JSON Schema 字符串\n}\n\n// 导出 schema 相关类型\nexport type { JSONSchema, FormField, FormData, JSONSchemaType } from './schema'\n"
  },
  {
    "path": "web/src/features/playground/types/schema.ts",
    "content": "/**\n * JSON Schema 类型定义\n * 用于解析后端返回的参数 schema\n */\n\n// JSON Schema 基础类型\nexport type JSONSchemaType =\n  | 'string'\n  | 'number'\n  | 'integer'\n  | 'boolean'\n  | 'object'\n  | 'array'\n  | 'null'\n\n// JSON Schema 属性定义\nexport interface JSONSchemaProperty {\n  type?: JSONSchemaType | JSONSchemaType[]\n  description?: string\n  default?: unknown\n  enum?: unknown[]\n  // 数字类型的限制\n  minimum?: number\n  maximum?: number\n  // 字符串类型的限制\n  minLength?: number\n  maxLength?: number\n  pattern?: string\n  // 数组类型的限制\n  items?: JSONSchema\n  minItems?: number\n  maxItems?: number\n  // 对象类型的限制\n  properties?: Record<string, JSONSchema>\n  required?: string[]\n  additionalProperties?: boolean | JSONSchema\n  // 其他常用字段\n  title?: string\n  examples?: unknown[]\n  format?: string\n  // 组合 schema\n  oneOf?: JSONSchema[]\n  anyOf?: JSONSchema[]\n  allOf?: JSONSchema[]\n}\n\n// JSON Schema 完整定义\nexport interface JSONSchema extends JSONSchemaProperty {\n  $schema?: string\n  $id?: string\n  $ref?: string\n  definitions?: Record<string, JSONSchema>\n}\n\n// 表单字段配置（从 schema 转换而来）\nexport interface FormField {\n  name: string\n  label: string\n  type: JSONSchemaType\n  required: boolean\n  description?: string\n  defaultValue?: unknown\n  // 字符串选项\n  enum?: unknown[]\n  pattern?: string\n  minLength?: number\n  maxLength?: number\n  // 数字选项\n  minimum?: number\n  maximum?: number\n  // 数组选项\n  items?: FormField\n  minItems?: number\n  maxItems?: number\n  // 对象选项（嵌套字段）\n  properties?: FormField[]\n}\n\n// 表单数据类型\nexport type FormData = Record<string, unknown>\n"
  },
  {
    "path": "web/src/features/settings/api.ts",
    "content": "import { call } from '@/core/rpc'\nimport { isWebView } from '@/core/env'\nimport type { AppSettings, RuntimeCapabilities } from './types'\n\nexport const settingsApi = {\n  get: async (): Promise<AppSettings> => {\n    return call<AppSettings>('settings.get')\n  },\n\n  getRuntimeCapabilities: async (): Promise<RuntimeCapabilities> => {\n    return call<RuntimeCapabilities>('runtime_info.get')\n  },\n\n  patch: async (patch: Partial<AppSettings>): Promise<void> => {\n    await call('settings.patch', { patch })\n  },\n}\n\nexport interface InfinityNikkiGameDirResult {\n  gameDir?: string\n  configFound: boolean\n  gameDirFound: boolean\n  message: string\n}\n\nexport interface FileInfoResult {\n  path: string\n  exists: boolean\n  isDirectory: boolean\n  isRegularFile: boolean\n  isSymlink: boolean\n  size: number\n  extension: string\n  filename: string\n  lastModified: number\n}\n\nexport interface BackgroundAnalysisResult {\n  themeMode: 'light' | 'dark'\n  primaryColor: string\n  overlayColors: string[]\n  brightness: number\n}\n\nexport interface BackgroundImportResult {\n  imageFileName: string\n}\n\nexport async function detectInfinityNikkiGameDirectory(): Promise<InfinityNikkiGameDirResult> {\n  return call<InfinityNikkiGameDirResult>('extensions.infinityNikki.getGameDirectory', {})\n}\n\nexport async function selectDirectory(title: string): Promise<string | null> {\n  const parentWindowMode = isWebView() ? 1 : 2\n  const result = await call<{ path: string }>(\n    'dialog.openDirectory',\n    {\n      title,\n      parentWindowMode,\n    },\n    0\n  )\n\n  return result.path || null\n}\n\nexport async function getFileInfo(path: string): Promise<FileInfoResult> {\n  return call<FileInfoResult>('file.getInfo', { path })\n}\n\nexport async function analyzeBackgroundImage(\n  imageFileName: string,\n  overlayMode: number\n): Promise<BackgroundAnalysisResult> {\n  return call<BackgroundAnalysisResult>(\n    'settings.background.analyze',\n    {\n      imageFileName,\n      overlayMode,\n    },\n    0\n  )\n}\n\n/**\n * 选择背景图片文件\n */\nexport async function selectBackgroundImage(): Promise<string | null> {\n  try {\n    const parentWindowMode = isWebView() ? 1 : 2\n\n    const result = await call<{\n      paths: string[]\n    }>(\n      'dialog.openFile',\n      {\n        title: '选择背景图片',\n        filter:\n          '图片文件 (*.jpg;*.jpeg;*.png;*.bmp;*.gif;*.webp)|*.jpg;*.jpeg;*.png;*.bmp;*.gif;*.webp|所有文件 (*.*)|*.*',\n        allow_multiple: false,\n        parentWindowMode,\n      },\n      0\n    )\n\n    if (result.paths && result.paths.length > 0) {\n      console.log('已选择背景图片:', result.paths[0])\n      return result.paths[0] || null\n    }\n\n    return null\n  } catch (error) {\n    console.error('选择背景图片失败:', error)\n    throw new Error('选择背景图片失败')\n  }\n}\n\n/**\n * 导入背景图片到后端托管目录\n */\nexport async function importBackgroundImage(sourcePath: string): Promise<string> {\n  try {\n    const result = await call<BackgroundImportResult>(\n      'settings.background.import',\n      {\n        sourcePath,\n      },\n      0\n    )\n\n    console.log('背景图片已导入到托管目录:', result.imageFileName)\n    return result.imageFileName\n  } catch (error) {\n    console.error('导入背景图片失败:', error)\n    throw new Error('导入背景图片失败')\n  }\n}\n\n/**\n * 删除已管理的背景图片资源（非阻塞容错）\n */\nexport async function removeBackgroundImageResource(imageFileName: string): Promise<void> {\n  try {\n    if (!imageFileName) {\n      return\n    }\n\n    await call('settings.background.remove', {\n      imageFileName,\n    })\n  } catch (error) {\n    console.warn('清理旧背景图片失败:', error)\n  }\n}\n"
  },
  {
    "path": "web/src/features/settings/appearance.ts",
    "content": "import type { AppSettings, WebThemeMode } from './types'\nimport { resolveBackgroundImageUrl } from './backgroundPath'\nimport { buildOverlayGradient, getOverlayPaletteFromBackground } from './overlayPalette'\n\nconst USER_CUSTOM_STYLE_ID = 'spinning-momo-user-css'\nconst decodedBackgroundUrls = new Set<string>()\n\ntype ResolvedTheme = 'light' | 'dark'\n\nconst HEX_COLOR_PATTERN = /^#[0-9A-Fa-f]{6}$/\n\nconst clamp = (value: number, min: number, max: number): number => {\n  return Math.min(max, Math.max(min, value))\n}\n\nconst resolveTheme = (mode: WebThemeMode): ResolvedTheme => {\n  // 历史配置可能仍为 system；不再跟随 OS，统一按亮色解析\n  if (mode === 'system') {\n    return 'light'\n  }\n  return mode\n}\n\nconst applyTheme = (mode: WebThemeMode): void => {\n  const root = document.documentElement\n  const resolvedTheme = resolveTheme(mode)\n\n  if (resolvedTheme === 'dark') {\n    root.classList.add('dark')\n  } else {\n    root.classList.remove('dark')\n  }\n}\n\nconst applyCustomUserCss = (cssText: string): void => {\n  if (typeof document === 'undefined') return\n\n  const trimmed = cssText.trim()\n  let el = document.getElementById(USER_CUSTOM_STYLE_ID) as HTMLStyleElement | null\n  if (!trimmed) {\n    el?.remove()\n    return\n  }\n  if (!el) {\n    el = document.createElement('style')\n    el.id = USER_CUSTOM_STYLE_ID\n    el.setAttribute('type', 'text/css')\n    document.head.appendChild(el)\n  }\n  el.textContent = trimmed\n}\n\nconst normalizeHexColor = (value: string | undefined, fallback: string): string => {\n  const normalized = value?.trim().toUpperCase() ?? ''\n  return HEX_COLOR_PATTERN.test(normalized) ? normalized : fallback\n}\n\nconst hexToRgb = (hexColor: string): [number, number, number] => {\n  const r = Number.parseInt(hexColor.slice(1, 3), 16)\n  const g = Number.parseInt(hexColor.slice(3, 5), 16)\n  const b = Number.parseInt(hexColor.slice(5, 7), 16)\n  return [r, g, b]\n}\n\nconst toLinear = (value: number): number => {\n  const normalized = value / 255\n  return normalized <= 0.04045 ? normalized / 12.92 : Math.pow((normalized + 0.055) / 1.055, 2.4)\n}\n\nconst resolvePrimaryForeground = (primaryColor: string): string => {\n  const [r, g, b] = hexToRgb(primaryColor)\n  const luminance = 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b)\n  return luminance >= 0.45 ? '#18181B' : '#FFFFFF'\n}\n\nlet backgroundRequestToken = 0\nlet appliedBackgroundUrl: string | null = null\n\nconst setBackgroundVisibility = (root: HTMLElement, visible: boolean): void => {\n  root.style.setProperty('--app-background-visibility', visible ? '1' : '0')\n  root.style.setProperty('--app-background-scale', visible ? '1' : '1.02')\n}\n\nconst decodeImage = async (imageUrl: string): Promise<void> => {\n  const image = new Image()\n  image.decoding = 'async'\n  image.src = imageUrl\n\n  if (typeof image.decode === 'function') {\n    await image.decode()\n    return\n  }\n\n  await new Promise<void>((resolve, reject) => {\n    image.onload = () => resolve()\n    image.onerror = () => reject(new Error(`Failed to load background image: ${imageUrl}`))\n  })\n}\n\nconst applyDecodedBackgroundImage = (\n  root: HTMLElement,\n  imageUrl: string,\n  requestToken: number\n): void => {\n  root.style.setProperty('--app-background-image', `url(\"${imageUrl}\")`)\n\n  requestAnimationFrame(() => {\n    if (requestToken !== backgroundRequestToken) return\n    setBackgroundVisibility(root, true)\n    appliedBackgroundUrl = imageUrl\n  })\n}\n\nconst updateBackgroundImage = (root: HTMLElement, imageUrl: string | null): void => {\n  backgroundRequestToken += 1\n  const requestToken = backgroundRequestToken\n\n  if (!imageUrl) {\n    appliedBackgroundUrl = null\n    root.style.setProperty('--app-background-image', 'none')\n    setBackgroundVisibility(root, false)\n    return\n  }\n\n  if (imageUrl === appliedBackgroundUrl) {\n    setBackgroundVisibility(root, true)\n    return\n  }\n\n  setBackgroundVisibility(root, false)\n\n  if (decodedBackgroundUrls.has(imageUrl)) {\n    applyDecodedBackgroundImage(root, imageUrl, requestToken)\n    return\n  }\n\n  void decodeImage(imageUrl)\n    .then(() => {\n      decodedBackgroundUrls.add(imageUrl)\n      if (requestToken !== backgroundRequestToken) return\n      applyDecodedBackgroundImage(root, imageUrl, requestToken)\n    })\n    .catch((error: unknown) => {\n      if (requestToken !== backgroundRequestToken) return\n      root.style.setProperty('--app-background-image', 'none')\n      setBackgroundVisibility(root, false)\n      appliedBackgroundUrl = null\n      console.warn('Failed to decode background image before display.', error)\n    })\n}\n\nconst applyBackground = (settings: AppSettings): void => {\n  const root = document.documentElement\n  const background = settings.ui.background\n  const imageUrl = resolveBackgroundImageUrl(background)\n  const resolvedTheme = resolveTheme(settings.ui.webTheme.mode)\n  const primaryFallback = resolvedTheme === 'light' ? '#F59E0B' : '#FBBF24'\n  const primaryColor = normalizeHexColor(background.primaryColor, primaryFallback)\n  const primaryForeground = resolvePrimaryForeground(primaryColor)\n\n  const backgroundOpacity = clamp(background.backgroundOpacity, 0, 1)\n  const backgroundBlur = clamp(background.backgroundBlurAmount, 0, 100)\n  const overlayOpacity = clamp(background.overlayOpacity, 0, 1)\n\n  root.style.setProperty('--app-background-opacity', String(backgroundOpacity))\n  root.style.setProperty('--app-background-blur', `${backgroundBlur}px`)\n  root.style.setProperty(\n    '--app-background-overlay-image',\n    buildOverlayGradient(getOverlayPaletteFromBackground(background))\n  )\n  root.style.setProperty('--app-background-overlay-opacity', String(overlayOpacity))\n\n  root.style.setProperty('--surface-opacity', String(clamp(background.surfaceOpacity, 0, 1)))\n  root.style.setProperty('--primary', primaryColor)\n  root.style.setProperty('--ring', primaryColor)\n  root.style.setProperty('--sidebar-primary', primaryColor)\n  root.style.setProperty('--primary-foreground', primaryForeground)\n  root.style.setProperty('--sidebar-primary-foreground', primaryForeground)\n\n  updateBackgroundImage(root, imageUrl)\n}\n\nexport const applyAppearanceToDocument = (settings: AppSettings): void => {\n  if (typeof document === 'undefined') return\n\n  applyTheme(settings.ui.webTheme.mode)\n  applyBackground(settings)\n  applyCustomUserCss(settings.ui.webTheme.customCss ?? '')\n}\n"
  },
  {
    "path": "web/src/features/settings/backgroundPath.ts",
    "content": "import { getStaticUrl } from '@/core/env'\nimport type { WebBackgroundSettings } from './types'\nimport { BACKGROUND_WEB_DIR } from './constants'\n\nexport const resolveBackgroundImageUrl = (background: WebBackgroundSettings): string | null => {\n  if (background.type !== 'image' || !background.imageFileName) return null\n  return getStaticUrl(`${BACKGROUND_WEB_DIR}/${background.imageFileName}`)\n}\n"
  },
  {
    "path": "web/src/features/settings/components/AppearanceContent.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed, ref, watch } from 'vue'\nimport { useSettingsStore } from '../store'\nimport { useAppearanceActions } from '../composables/useAppearanceActions'\nimport { useTheme } from '../composables/useTheme'\nimport { storeToRefs } from 'pinia'\nimport { Button } from '@/components/ui/button'\nimport { Slider } from '@/components/ui/slider'\nimport { Switch } from '@/components/ui/switch'\nimport {\n  Item,\n  ItemContent,\n  ItemTitle,\n  ItemDescription,\n  ItemActions,\n  ItemGroup,\n} from '@/components/ui/item'\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from '@/components/ui/select'\nimport { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'\nimport { Textarea } from '@/components/ui/textarea'\nimport ColorPicker from '@/components/ui/color-picker/ColorPicker.vue'\nimport ResetSettingsDialog from './ResetSettingsDialog.vue'\nimport OverlayPaletteEditor from './OverlayPaletteEditor.vue'\nimport { useI18n } from '@/composables/useI18n'\nimport type { WebThemeMode } from '../types'\nimport type { OverlayPalette, OverlayPalettePreset } from '../overlayPalette'\nimport { getOverlayPaletteFromBackground } from '../overlayPalette'\nimport { resolveBackgroundImageUrl } from '../backgroundPath'\nimport {\n  SURFACE_OPACITY_RANGE,\n  BACKGROUND_BLUR_RANGE,\n  BACKGROUND_OPACITY_RANGE,\n  OVERLAY_OPACITY_RANGE,\n} from '../constants'\n\nconst store = useSettingsStore()\nconst { appSettings, error, isInitialized } = storeToRefs(store)\nconst { setTheme } = useTheme()\nconst {\n  resetWebAppearanceSettings,\n  updateBackgroundOpacity,\n  updateBackgroundBlur,\n  updateOverlayOpacity,\n  updatePrimaryColor,\n  updateOverlayPalette,\n  applyOverlayPalettePreset,\n  updateWebViewTransparentBackground,\n  updateCustomCss,\n  updateSurfaceOpacity,\n  handleBackgroundImageSelect,\n  handleBackgroundImageRemove,\n  applyWallpaperAnalysis,\n} = useAppearanceActions()\nconst { clearError } = store\nconst { t } = useI18n()\n\nconst themeOptions = [\n  { value: 'light', label: t('settings.appearance.theme.light') },\n  { value: 'dark', label: t('settings.appearance.theme.dark') },\n]\n\nconst webThemeSelectValue = computed(() => {\n  const m = appSettings.value.ui.webTheme.mode\n  return m === 'dark' ? 'dark' : 'light'\n})\nconst customCssDraft = ref('')\n\nwatch(\n  () => appSettings.value.ui.webTheme.customCss,\n  (v) => {\n    customCssDraft.value = v ?? ''\n  },\n  { immediate: true }\n)\n\nconst overlayPalette = computed<OverlayPalette>(() =>\n  getOverlayPaletteFromBackground(appSettings.value.ui.background)\n)\nconst canSampleOverlayPaletteFromWallpaper = computed(() => {\n  return Boolean(resolveBackgroundImageUrl(appSettings.value.ui.background))\n})\n\nconst handleSurfaceOpacityChange = async (val: number[] | undefined) => {\n  if (!val || val.length === 0) return\n  try {\n    await updateSurfaceOpacity(val[0]!)\n  } catch (error) {\n    console.error('Failed to update surface opacity:', error)\n  }\n}\n\nconst handleBackgroundOpacityChange = async (val: number[] | undefined) => {\n  if (!val || val.length === 0) return\n  try {\n    await updateBackgroundOpacity(val[0]!)\n  } catch (error) {\n    console.error('Failed to update background opacity:', error)\n  }\n}\n\nconst handleBackgroundBlurAmountChange = async (val: number[] | undefined) => {\n  if (!val || val.length === 0) return\n  try {\n    await updateBackgroundBlur(val[0]!)\n  } catch (error) {\n    console.error('Failed to update background blur amount:', error)\n  }\n}\n\nconst handleOverlayOpacityChange = async (val: number[] | undefined) => {\n  if (!val || val.length === 0) return\n  try {\n    await updateOverlayOpacity(val[0]!)\n  } catch (error) {\n    console.error('Failed to update overlay opacity:', error)\n  }\n}\n\nconst handleOverlayPaletteChange = async (palette: OverlayPalette) => {\n  try {\n    await updateOverlayPalette(palette)\n  } catch (error) {\n    console.error('Failed to update overlay palette:', error)\n  }\n}\n\nconst handleOverlayPresetApply = async (preset: OverlayPalettePreset) => {\n  try {\n    await applyOverlayPalettePreset(preset)\n  } catch (error) {\n    console.error('Failed to apply overlay palette preset:', error)\n  }\n}\n\nconst handleOverlaySampleFromWallpaper = async () => {\n  const imageFileName = appSettings.value.ui.background.imageFileName\n  if (!imageFileName) return\n\n  try {\n    await applyWallpaperAnalysis(imageFileName)\n  } catch (error) {\n    console.error('Failed to sample overlay palette from wallpaper:', error)\n  }\n}\n\nconst handleThemeChange = async (themeMode: string) => {\n  try {\n    await setTheme(themeMode as WebThemeMode)\n  } catch (error) {\n    console.error('Failed to update theme:', error)\n  }\n}\n\nconst handleCustomCssBlur = async () => {\n  const next = customCssDraft.value\n  if (next === (appSettings.value.ui.webTheme.customCss ?? '')) return\n  try {\n    await updateCustomCss(next)\n  } catch (error) {\n    console.error('Failed to update custom CSS:', error)\n  }\n}\n\nconst handlePrimaryColorChange = async (primaryColor: string) => {\n  try {\n    await updatePrimaryColor(primaryColor)\n  } catch (error) {\n    console.error('Failed to update primary color:', error)\n  }\n}\n\nconst handleWebViewTransparentBackgroundChange = async (enabled: boolean) => {\n  try {\n    await updateWebViewTransparentBackground(enabled)\n  } catch (error) {\n    console.error('Failed to update WebView transparent background setting:', error)\n  }\n}\n\nconst handleResetSettings = async () => {\n  await resetWebAppearanceSettings()\n}\n\nconst handleClearError = () => {\n  clearError()\n}\n</script>\n\n<template>\n  <div v-if=\"!isInitialized\" class=\"flex items-center justify-center p-6\">\n    <div class=\"text-center\">\n      <div\n        class=\"mx-auto h-8 w-8 animate-spin rounded-full border-4 border-muted border-t-primary\"\n      ></div>\n      <p class=\"mt-2 text-sm text-muted-foreground\">{{ t('settings.webAppearance.loading') }}</p>\n    </div>\n  </div>\n\n  <div v-else-if=\"error\" class=\"flex items-center justify-center p-6\">\n    <div class=\"text-center\">\n      <p class=\"text-sm text-muted-foreground\">{{ t('settings.webAppearance.error.title') }}</p>\n      <p class=\"mt-1 text-sm text-red-500\">{{ error }}</p>\n      <Button variant=\"outline\" size=\"sm\" @click=\"handleClearError\" class=\"mt-2\">\n        {{ t('settings.webAppearance.error.retry') }}\n      </Button>\n    </div>\n  </div>\n\n  <div v-else class=\"w-full\">\n    <div class=\"mb-6 flex items-center justify-between\">\n      <div>\n        <h1 class=\"text-2xl font-bold text-foreground\">{{ t('settings.webAppearance.title') }}</h1>\n        <p class=\"mt-1 text-muted-foreground\">{{ t('settings.webAppearance.description') }}</p>\n      </div>\n      <ResetSettingsDialog\n        :title=\"t('settings.webAppearance.reset.title')\"\n        :description=\"t('settings.webAppearance.reset.description')\"\n        @reset=\"handleResetSettings\"\n      />\n    </div>\n\n    <div class=\"space-y-8\">\n      <div class=\"space-y-4\">\n        <div>\n          <h3 class=\"text-lg font-semibold text-foreground\">\n            {{ t('settings.appearance.quickSetup.title') }}\n          </h3>\n        </div>\n\n        <ItemGroup>\n          <Item variant=\"surface\" size=\"sm\">\n            <ItemContent>\n              <ItemTitle>\n                {{ t('settings.appearance.background.image.label') }}\n              </ItemTitle>\n              <ItemDescription>\n                {{ t('settings.appearance.background.image.autoThemeDescription') }}\n              </ItemDescription>\n            </ItemContent>\n            <ItemActions>\n              <Button variant=\"outline\" size=\"sm\" @click=\"handleBackgroundImageSelect\">\n                {{ t('settings.appearance.background.image.selectButton') }}\n              </Button>\n              <Button\n                variant=\"outline\"\n                size=\"sm\"\n                @click=\"handleBackgroundImageRemove\"\n                :disabled=\"appSettings.ui?.background?.type === 'none'\"\n              >\n                {{ t('settings.appearance.background.image.removeButton') }}\n              </Button>\n            </ItemActions>\n          </Item>\n\n          <Item variant=\"surface\" size=\"sm\">\n            <ItemContent>\n              <ItemTitle>\n                {{ t('settings.appearance.theme.primaryColor.label') }}\n              </ItemTitle>\n              <ItemDescription>\n                {{ t('settings.appearance.theme.primaryColor.description') }}\n              </ItemDescription>\n            </ItemContent>\n            <ItemActions>\n              <Popover>\n                <PopoverTrigger as-child>\n                  <Button\n                    variant=\"outline\"\n                    size=\"sm\"\n                    class=\"h-9 w-[13rem] justify-start gap-2 px-2 font-mono text-xs\"\n                  >\n                    <div\n                      class=\"h-5 w-8 shrink-0 rounded-sm border border-border/70\"\n                      :style=\"{ backgroundColor: appSettings.ui.background.primaryColor }\"\n                    />\n                    <span class=\"text-muted-foreground\">\n                      {{ appSettings.ui.background.primaryColor }}\n                    </span>\n                  </Button>\n                </PopoverTrigger>\n                <PopoverContent align=\"end\" class=\"w-auto p-3\">\n                  <ColorPicker\n                    :model-value=\"appSettings.ui.background.primaryColor\"\n                    @update:model-value=\"handlePrimaryColorChange\"\n                  />\n                </PopoverContent>\n              </Popover>\n            </ItemActions>\n          </Item>\n\n          <Item variant=\"surface\" size=\"sm\">\n            <ItemContent>\n              <ItemTitle>\n                {{ t('settings.appearance.background.overlayPalette.label') }}\n              </ItemTitle>\n              <ItemDescription>\n                {{ t('settings.appearance.background.overlayPalette.description') }}\n              </ItemDescription>\n            </ItemContent>\n            <ItemActions>\n              <OverlayPaletteEditor\n                :model-value=\"overlayPalette\"\n                :show-wallpaper-sampler=\"canSampleOverlayPaletteFromWallpaper\"\n                @update:model-value=\"handleOverlayPaletteChange\"\n                @apply-preset=\"handleOverlayPresetApply\"\n                @sample-from-wallpaper=\"handleOverlaySampleFromWallpaper\"\n              />\n            </ItemActions>\n          </Item>\n        </ItemGroup>\n      </div>\n\n      <div class=\"space-y-4\">\n        <div>\n          <h3 class=\"text-lg font-semibold text-foreground\">\n            {{ t('settings.appearance.background.title') }}\n          </h3>\n          <p class=\"mt-1 text-sm text-muted-foreground\">\n            {{ t('settings.appearance.background.description') }}\n          </p>\n        </div>\n\n        <ItemGroup>\n          <Item variant=\"surface\" size=\"sm\">\n            <ItemContent>\n              <ItemTitle>\n                {{ t('settings.appearance.background.backgroundOpacity.label') }}\n              </ItemTitle>\n              <ItemDescription>\n                {{ t('settings.appearance.background.backgroundOpacity.description') }}\n              </ItemDescription>\n            </ItemContent>\n            <ItemActions>\n              <div class=\"flex items-center gap-2\">\n                <Slider\n                  :model-value=\"[appSettings.ui.background.backgroundOpacity]\"\n                  @update:model-value=\"handleBackgroundOpacityChange\"\n                  :min=\"BACKGROUND_OPACITY_RANGE.MIN\"\n                  :max=\"BACKGROUND_OPACITY_RANGE.MAX\"\n                  :step=\"BACKGROUND_OPACITY_RANGE.STEP\"\n                  class=\"w-36\"\n                />\n                <span class=\"w-12 text-sm text-muted-foreground\">\n                  {{ (appSettings.ui.background.backgroundOpacity * 100).toFixed(0) }}%\n                </span>\n              </div>\n            </ItemActions>\n          </Item>\n\n          <Item variant=\"surface\" size=\"sm\">\n            <ItemContent>\n              <ItemTitle>\n                {{ t('settings.appearance.background.backgroundBlurAmount.label') }}\n              </ItemTitle>\n              <ItemDescription>\n                {{ t('settings.appearance.background.backgroundBlurAmount.description') }}\n              </ItemDescription>\n            </ItemContent>\n            <ItemActions>\n              <div class=\"flex items-center gap-2\">\n                <Slider\n                  :model-value=\"[appSettings.ui.background.backgroundBlurAmount]\"\n                  @update:model-value=\"handleBackgroundBlurAmountChange\"\n                  :min=\"BACKGROUND_BLUR_RANGE.MIN\"\n                  :max=\"BACKGROUND_BLUR_RANGE.MAX\"\n                  :step=\"BACKGROUND_BLUR_RANGE.STEP\"\n                  class=\"w-36\"\n                />\n                <span class=\"w-12 text-sm text-muted-foreground\">\n                  {{ appSettings.ui.background.backgroundBlurAmount }}px\n                </span>\n              </div>\n            </ItemActions>\n          </Item>\n\n          <Item variant=\"surface\" size=\"sm\">\n            <ItemContent>\n              <ItemTitle>\n                {{ t('settings.appearance.background.overlayOpacity.label') }}\n              </ItemTitle>\n              <ItemDescription>\n                {{ t('settings.appearance.background.overlayOpacity.description') }}\n              </ItemDescription>\n            </ItemContent>\n            <ItemActions>\n              <div class=\"flex items-center gap-2\">\n                <Slider\n                  :model-value=\"[appSettings.ui.background.overlayOpacity]\"\n                  @update:model-value=\"handleOverlayOpacityChange\"\n                  :min=\"OVERLAY_OPACITY_RANGE.MIN\"\n                  :max=\"OVERLAY_OPACITY_RANGE.MAX\"\n                  :step=\"OVERLAY_OPACITY_RANGE.STEP\"\n                  class=\"w-36\"\n                />\n                <span class=\"w-12 text-sm text-muted-foreground\">\n                  {{ (appSettings.ui.background.overlayOpacity * 100).toFixed(0) }}%\n                </span>\n              </div>\n            </ItemActions>\n          </Item>\n\n          <Item variant=\"surface\" size=\"sm\">\n            <ItemContent>\n              <ItemTitle>\n                {{ t('settings.appearance.background.surfaceOpacity.label') }}\n              </ItemTitle>\n              <ItemDescription>\n                {{ t('settings.appearance.background.surfaceOpacity.description') }}\n              </ItemDescription>\n            </ItemContent>\n            <ItemActions>\n              <div class=\"flex items-center gap-2\">\n                <Slider\n                  :model-value=\"[appSettings.ui.background.surfaceOpacity]\"\n                  @update:model-value=\"handleSurfaceOpacityChange\"\n                  :min=\"SURFACE_OPACITY_RANGE.MIN\"\n                  :max=\"SURFACE_OPACITY_RANGE.MAX\"\n                  :step=\"SURFACE_OPACITY_RANGE.STEP\"\n                  class=\"w-36\"\n                />\n                <span class=\"w-12 text-sm text-muted-foreground\">\n                  {{ (appSettings.ui.background.surfaceOpacity * 100).toFixed(0) }}%\n                </span>\n              </div>\n            </ItemActions>\n          </Item>\n        </ItemGroup>\n      </div>\n\n      <div class=\"space-y-4\">\n        <div>\n          <h3 class=\"text-lg font-semibold text-foreground\">\n            {{ t('settings.appearance.theme.title') }}\n          </h3>\n          <p class=\"mt-1 text-sm text-muted-foreground\">\n            {{ t('settings.appearance.theme.description') }}\n          </p>\n        </div>\n\n        <Item variant=\"surface\" size=\"sm\">\n          <ItemContent>\n            <ItemTitle>\n              {{ t('settings.appearance.theme.mode.label') }}\n            </ItemTitle>\n            <ItemDescription>\n              {{ t('settings.appearance.theme.mode.description') }}\n            </ItemDescription>\n          </ItemContent>\n          <ItemActions>\n            <Select\n              :model-value=\"webThemeSelectValue\"\n              @update:model-value=\"(v) => handleThemeChange(v as string)\"\n            >\n              <SelectTrigger class=\"w-32\">\n                <SelectValue />\n              </SelectTrigger>\n              <SelectContent>\n                <SelectItem\n                  v-for=\"option in themeOptions\"\n                  :key=\"option.value\"\n                  :value=\"option.value\"\n                >\n                  {{ option.label }}\n                </SelectItem>\n              </SelectContent>\n            </Select>\n          </ItemActions>\n        </Item>\n\n        <Item variant=\"surface\" size=\"sm\">\n          <ItemContent>\n            <ItemTitle>\n              {{ t('settings.appearance.webview.transparentBackground.label') }}\n            </ItemTitle>\n            <ItemDescription>\n              {{ t('settings.appearance.webview.transparentBackground.description') }}\n            </ItemDescription>\n          </ItemContent>\n          <ItemActions>\n            <Switch\n              :model-value=\"appSettings.ui.webviewWindow.enableTransparentBackground\"\n              @update:model-value=\"\n                (value) => handleWebViewTransparentBackgroundChange(Boolean(value))\n              \"\n            />\n          </ItemActions>\n        </Item>\n\n        <Item variant=\"surface\" size=\"sm\" class=\"flex-col items-stretch gap-3 sm:flex-row\">\n          <ItemContent class=\"min-w-0 shrink-0 sm:max-w-xs\">\n            <ItemTitle>\n              {{ t('settings.appearance.theme.customCss.label') }}\n            </ItemTitle>\n            <ItemDescription>\n              {{ t('settings.appearance.theme.customCss.description') }}\n            </ItemDescription>\n          </ItemContent>\n          <ItemActions class=\"w-full min-w-0 justify-stretch sm:justify-end\">\n            <Textarea\n              v-model=\"customCssDraft\"\n              class=\"max-h-64 min-h-32 w-full resize-y font-mono text-xs\"\n              :placeholder=\"t('settings.appearance.theme.customCss.placeholder')\"\n              @blur=\"handleCustomCssBlur\"\n            />\n          </ItemActions>\n        </Item>\n      </div>\n    </div>\n  </div>\n</template>\n"
  },
  {
    "path": "web/src/features/settings/components/CaptureSettingsContent.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed, ref } from 'vue'\nimport { useSettingsStore } from '../store'\nimport { useFunctionActions } from '../composables/useFunctionActions'\nimport { storeToRefs } from 'pinia'\nimport { Button } from '@/components/ui/button'\nimport { Input } from '@/components/ui/input'\nimport { Switch } from '@/components/ui/switch'\nimport {\n  Item,\n  ItemContent,\n  ItemTitle,\n  ItemDescription,\n  ItemActions,\n  ItemGroup,\n} from '@/components/ui/item'\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from '@/components/ui/select'\nimport { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'\nimport { useI18n } from '@/composables/useI18n'\nimport { useToast } from '@/composables/useToast'\nimport { TriangleAlert } from 'lucide-vue-next'\nimport ResetSettingsDialog from './ResetSettingsDialog.vue'\nimport { call } from '@/core/rpc'\n\nconst store = useSettingsStore()\nconst { appSettings, runtimeCapabilities, error, isInitialized } = storeToRefs(store)\nconst {\n  updateOutputDir,\n  updateGameAlbumPath,\n  updateMotionPhotoDuration,\n  updateMotionPhotoResolution,\n  updateMotionPhotoFps,\n  updateMotionPhotoBitrate,\n  updateMotionPhotoQuality,\n  updateMotionPhotoRateControl,\n  updateMotionPhotoCodec,\n  updateMotionPhotoAudioSource,\n  updateMotionPhotoAudioBitrate,\n  updateReplayBufferDuration,\n  updateRecordingFps,\n  updateRecordingBitrate,\n  updateRecordingQuality,\n  updateRecordingQp,\n  updateRecordingRateControl,\n  updateRecordingEncoderMode,\n  updateRecordingCodec,\n  updateRecordingCaptureClientArea,\n  updateRecordingCaptureCursor,\n  updateRecordingAutoRestartOnResize,\n  updateRecordingAudioSource,\n  updateRecordingAudioBitrate,\n  resetCaptureExportSettings,\n} = useFunctionActions()\nconst { clearError } = store\nconst { t } = useI18n()\nconst { toast } = useToast()\n\n/** 是否为 Windows 盘符根路径（如 C:\\\\、D:/），用于避免将输出目录设为整盘根。 */\nconst isWindowsDriveRoot = (raw: string) => {\n  const normalized = raw.trim().replace(/\\\\/g, '/')\n  return /^[A-Za-z]:\\/?$/.test(normalized)\n}\n\nconst isSelectingOutputDir = ref(false)\nconst isSelectingGameAlbumDir = ref(false)\nconst inputBitrateMbps = ref(\n  (appSettings.value?.features?.recording?.bitrate || 80000000) / 1000000\n)\nconst inputQuality = ref(appSettings.value?.features?.recording?.quality || 70)\nconst inputQp = ref(appSettings.value?.features?.recording?.qp || 23)\nconst inputAudioBitrateKbps = ref(\n  (appSettings.value?.features?.recording?.audioBitrate || 320000) / 1000\n)\nconst inputFps = ref(appSettings.value?.features?.recording?.fps || 60)\n\nconst inputMotionPhotoDuration = ref(appSettings.value?.features?.motionPhoto?.duration || 3)\nconst inputMotionPhotoFps = ref(appSettings.value?.features?.motionPhoto?.fps || 30)\nconst inputMotionPhotoBitrateMbps = ref(\n  (appSettings.value?.features?.motionPhoto?.bitrate || 10000000) / 1000000\n)\nconst inputMotionPhotoAudioBitrateKbps = ref(\n  (appSettings.value?.features?.motionPhoto?.audioBitrate || 192000) / 1000\n)\nconst inputMotionPhotoQuality = ref(appSettings.value?.features?.motionPhoto?.quality || 100)\n\nconst inputReplayBufferDuration = ref(appSettings.value?.features?.replayBuffer?.duration || 30)\n\nconst showCursorCaptureHint = computed(() => {\n  return runtimeCapabilities.value\n    ? !runtimeCapabilities.value.isCursorCaptureControlSupported\n    : false\n})\n\nconst showGameOnlyAudioHint = computed(() => {\n  return runtimeCapabilities.value\n    ? !runtimeCapabilities.value.isProcessLoopbackAudioSupported\n    : false\n})\n\nconst showExperimentalCaptureSettings = computed(() => {\n  return Boolean(runtimeCapabilities.value?.isDebugBuild)\n})\n\nconst handleSelectOutputDir = async () => {\n  isSelectingOutputDir.value = true\n  try {\n    const parentWindowMode = 2\n    const result = await call<{ path: string }>(\n      'dialog.openDirectory',\n      {\n        title: t('settings.function.outputDir.dialogTitle'),\n        parentWindowMode,\n      },\n      0\n    )\n    if (isWindowsDriveRoot(result.path)) {\n      toast.warning(t('settings.function.outputDir.driveRootNotAllowedTitle'), {\n        description: t('settings.function.outputDir.driveRootNotAllowedDescription'),\n      })\n      return\n    }\n    await updateOutputDir(result.path)\n  } catch (error) {\n    console.error('Failed to select output directory:', error)\n  } finally {\n    isSelectingOutputDir.value = false\n  }\n}\n\nconst handleSelectGameAlbumDir = async () => {\n  isSelectingGameAlbumDir.value = true\n  try {\n    const parentWindowMode = 2\n    const result = await call<{ path: string }>(\n      'dialog.openDirectory',\n      {\n        title: t('settings.function.screenshot.gameAlbum.dialogTitle'),\n        parentWindowMode,\n      },\n      0\n    )\n    await updateGameAlbumPath(result.path)\n  } catch (error) {\n    console.error('Failed to select game album directory:', error)\n  } finally {\n    isSelectingGameAlbumDir.value = false\n  }\n}\n\nconst handleBitrateChange = async () => {\n  const bitrateBps = inputBitrateMbps.value * 1000000\n  await updateRecordingBitrate(bitrateBps)\n}\n\nconst handleQualityChange = async () => {\n  await updateRecordingQuality(inputQuality.value)\n}\n\nconst handleQpChange = async () => {\n  await updateRecordingQp(inputQp.value)\n}\n\nconst handleAudioBitrateChange = async () => {\n  const audioBitrateBps = inputAudioBitrateKbps.value * 1000\n  await updateRecordingAudioBitrate(audioBitrateBps)\n}\n\nconst handleFpsChange = async () => {\n  if (!inputFps.value) return\n  await updateRecordingFps(inputFps.value)\n}\n\nconst handleMotionPhotoDurationChange = async () => {\n  if (!inputMotionPhotoDuration.value) return\n  await updateMotionPhotoDuration(inputMotionPhotoDuration.value)\n}\n\nconst handleMotionPhotoFpsChange = async () => {\n  if (!inputMotionPhotoFps.value) return\n  await updateMotionPhotoFps(inputMotionPhotoFps.value)\n}\n\nconst handleMotionPhotoBitrateChange = async () => {\n  const bitrateBps = inputMotionPhotoBitrateMbps.value * 1000000\n  await updateMotionPhotoBitrate(bitrateBps)\n}\n\nconst handleMotionPhotoAudioBitrateChange = async () => {\n  const audioBitrateBps = inputMotionPhotoAudioBitrateKbps.value * 1000\n  await updateMotionPhotoAudioBitrate(audioBitrateBps)\n}\n\nconst handleMotionPhotoQualityChange = async () => {\n  await updateMotionPhotoQuality(inputMotionPhotoQuality.value)\n}\n\nconst handleReplayBufferDurationChange = async () => {\n  if (!inputReplayBufferDuration.value) return\n  await updateReplayBufferDuration(inputReplayBufferDuration.value)\n}\n\nconst handleResetSettings = async () => {\n  await resetCaptureExportSettings()\n  inputBitrateMbps.value = 80\n  inputQuality.value = 100\n  inputQp.value = 23\n  inputAudioBitrateKbps.value = 320\n  inputFps.value = 60\n  inputMotionPhotoDuration.value = 3\n  inputMotionPhotoFps.value = 30\n  inputMotionPhotoBitrateMbps.value = 10\n  inputMotionPhotoQuality.value = 100\n  inputMotionPhotoAudioBitrateKbps.value = 192\n  inputReplayBufferDuration.value = 30\n}\n</script>\n\n<template>\n  <div v-if=\"!isInitialized\" class=\"flex items-center justify-center p-6\">\n    <div class=\"text-center\">\n      <div\n        class=\"mx-auto h-8 w-8 animate-spin rounded-full border-4 border-muted border-t-primary\"\n      ></div>\n      <p class=\"mt-2 text-sm text-muted-foreground\">{{ t('settings.capture.loading') }}</p>\n    </div>\n  </div>\n\n  <div v-else-if=\"error\" class=\"flex items-center justify-center p-6\">\n    <div class=\"text-center\">\n      <p class=\"text-sm text-muted-foreground\">{{ t('settings.capture.error.title') }}</p>\n      <p class=\"mt-1 text-sm text-red-500\">{{ error }}</p>\n      <Button variant=\"outline\" size=\"sm\" @click=\"clearError\" class=\"mt-2\">\n        {{ t('settings.capture.error.retry') }}\n      </Button>\n    </div>\n  </div>\n\n  <div v-else class=\"w-full\">\n    <div class=\"mb-6 flex items-center justify-between\">\n      <div>\n        <h1 class=\"text-2xl font-bold text-foreground\">{{ t('settings.capture.title') }}</h1>\n        <p class=\"mt-1 text-muted-foreground\">{{ t('settings.capture.description') }}</p>\n      </div>\n      <ResetSettingsDialog\n        :title=\"t('settings.capture.reset.title')\"\n        :description=\"t('settings.capture.reset.description')\"\n        @reset=\"handleResetSettings\"\n      />\n    </div>\n\n    <div class=\"space-y-8\">\n      <div class=\"space-y-4\">\n        <div>\n          <h3 class=\"text-lg font-semibold text-foreground\">\n            {{ t('settings.function.outputDir.title') }}\n          </h3>\n          <p class=\"mt-1 text-sm text-muted-foreground\">\n            {{ t('settings.function.outputDir.description') }}\n          </p>\n        </div>\n\n        <Item variant=\"surface\" size=\"sm\">\n          <ItemContent>\n            <ItemTitle>\n              {{ t('settings.function.outputDir.label') }}\n            </ItemTitle>\n            <ItemDescription>\n              <template v-if=\"appSettings?.features?.outputDirPath\">\n                {{ appSettings.features.outputDirPath }}\n              </template>\n              <template v-else>\n                {{ t('settings.function.outputDir.default') }}\n              </template>\n            </ItemDescription>\n          </ItemContent>\n          <ItemActions>\n            <Button @click=\"handleSelectOutputDir\" :disabled=\"isSelectingOutputDir\" size=\"sm\">\n              {{\n                isSelectingOutputDir\n                  ? t('settings.function.outputDir.selecting')\n                  : t('settings.function.outputDir.selectButton')\n              }}\n            </Button>\n          </ItemActions>\n        </Item>\n      </div>\n\n      <div class=\"space-y-4\">\n        <div>\n          <h3 class=\"text-lg font-semibold text-foreground\">\n            {{ t('settings.function.screenshot.title') }}\n          </h3>\n          <p class=\"mt-1 text-sm text-muted-foreground\">\n            {{ t('settings.function.screenshot.description') }}\n          </p>\n        </div>\n\n        <Item variant=\"surface\" size=\"sm\">\n          <ItemContent>\n            <ItemTitle>\n              {{ t('settings.function.screenshot.gameAlbum.label') }}\n            </ItemTitle>\n            <ItemDescription>\n              <template v-if=\"appSettings?.features?.externalAlbumPath\">\n                {{ appSettings.features.externalAlbumPath }}\n              </template>\n              <template v-else>\n                {{ t('settings.function.screenshot.gameAlbum.description') }}\n              </template>\n            </ItemDescription>\n          </ItemContent>\n          <ItemActions>\n            <Button @click=\"handleSelectGameAlbumDir\" :disabled=\"isSelectingGameAlbumDir\" size=\"sm\">\n              {{\n                isSelectingGameAlbumDir\n                  ? t('settings.function.screenshot.gameAlbum.selecting')\n                  : t('settings.function.screenshot.gameAlbum.selectButton')\n              }}\n            </Button>\n          </ItemActions>\n        </Item>\n      </div>\n\n      <div class=\"space-y-4\">\n        <div>\n          <h3 class=\"text-lg font-semibold text-foreground\">\n            {{ t('settings.function.recording.title') }}\n          </h3>\n          <p class=\"mt-1 text-sm text-muted-foreground\">\n            {{ t('settings.function.recording.description') }}\n          </p>\n        </div>\n\n        <ItemGroup>\n          <Item variant=\"surface\" size=\"sm\">\n            <ItemContent>\n              <ItemTitle>\n                {{ t('settings.function.recording.fps.label') }}\n              </ItemTitle>\n              <ItemDescription>\n                {{ t('settings.function.recording.fps.description') }}\n              </ItemDescription>\n            </ItemContent>\n            <ItemActions>\n              <Input\n                v-model.number=\"inputFps\"\n                type=\"number\"\n                :min=\"1\"\n                class=\"w-24\"\n                @blur=\"handleFpsChange\"\n                @keydown.enter=\"handleFpsChange\"\n              />\n              <span class=\"text-sm text-muted-foreground\">FPS</span>\n            </ItemActions>\n          </Item>\n\n          <Item variant=\"surface\" size=\"sm\">\n            <ItemContent>\n              <ItemTitle>\n                {{ t('settings.function.recording.rateControl.label') }}\n              </ItemTitle>\n              <ItemDescription>\n                {{ t('settings.function.recording.rateControl.description') }}\n              </ItemDescription>\n            </ItemContent>\n            <ItemActions>\n              <Select\n                :model-value=\"appSettings?.features?.recording?.rateControl\"\n                @update:model-value=\"\n                  (value) => updateRecordingRateControl(value as 'cbr' | 'vbr' | 'manual_qp')\n                \"\n              >\n                <SelectTrigger class=\"w-48\">\n                  <SelectValue />\n                </SelectTrigger>\n                <SelectContent>\n                  <SelectItem value=\"cbr\">{{\n                    t('settings.function.recording.rateControl.cbr')\n                  }}</SelectItem>\n                  <SelectItem value=\"vbr\">{{\n                    t('settings.function.recording.rateControl.vbr')\n                  }}</SelectItem>\n                  <SelectItem value=\"manual_qp\">{{\n                    t('settings.function.recording.rateControl.manualQp')\n                  }}</SelectItem>\n                </SelectContent>\n              </Select>\n            </ItemActions>\n          </Item>\n\n          <Item\n            v-if=\"appSettings?.features?.recording?.rateControl === 'cbr'\"\n            variant=\"surface\"\n            size=\"sm\"\n          >\n            <ItemContent>\n              <ItemTitle>\n                {{ t('settings.function.recording.bitrate.label') }}\n              </ItemTitle>\n              <ItemDescription>\n                {{ t('settings.function.recording.bitrate.description') }}\n              </ItemDescription>\n            </ItemContent>\n            <ItemActions>\n              <Input\n                v-model.number=\"inputBitrateMbps\"\n                type=\"number\"\n                :min=\"1\"\n                :max=\"500\"\n                class=\"w-24\"\n                @blur=\"handleBitrateChange\"\n                @keydown.enter=\"handleBitrateChange\"\n              />\n              <span class=\"text-sm text-muted-foreground\">Mbps</span>\n            </ItemActions>\n          </Item>\n\n          <Item\n            v-if=\"appSettings?.features?.recording?.rateControl === 'vbr'\"\n            variant=\"surface\"\n            size=\"sm\"\n          >\n            <ItemContent>\n              <ItemTitle>\n                {{ t('settings.function.recording.quality.label') }}\n              </ItemTitle>\n              <ItemDescription>\n                {{ t('settings.function.recording.quality.description') }}\n              </ItemDescription>\n            </ItemContent>\n            <ItemActions>\n              <Input\n                v-model.number=\"inputQuality\"\n                type=\"number\"\n                :min=\"0\"\n                :max=\"100\"\n                class=\"w-24\"\n                @blur=\"handleQualityChange\"\n                @keydown.enter=\"handleQualityChange\"\n              />\n              <span class=\"text-sm text-muted-foreground\">(0-100)</span>\n            </ItemActions>\n          </Item>\n\n          <Item\n            v-if=\"appSettings?.features?.recording?.rateControl === 'manual_qp'\"\n            variant=\"surface\"\n            size=\"sm\"\n          >\n            <ItemContent>\n              <ItemTitle>\n                {{ t('settings.function.recording.qp.label') }}\n              </ItemTitle>\n              <ItemDescription>\n                {{ t('settings.function.recording.qp.description') }}\n              </ItemDescription>\n            </ItemContent>\n            <ItemActions>\n              <Input\n                v-model.number=\"inputQp\"\n                type=\"number\"\n                :min=\"0\"\n                :max=\"51\"\n                class=\"w-24\"\n                @blur=\"handleQpChange\"\n                @keydown.enter=\"handleQpChange\"\n              />\n              <span class=\"text-sm text-muted-foreground\">(0-51)</span>\n            </ItemActions>\n          </Item>\n\n          <Item variant=\"surface\" size=\"sm\">\n            <ItemContent>\n              <ItemTitle>\n                {{ t('settings.function.recording.codec.label') }}\n              </ItemTitle>\n              <ItemDescription>\n                {{ t('settings.function.recording.codec.description') }}\n              </ItemDescription>\n            </ItemContent>\n            <ItemActions>\n              <Select\n                :model-value=\"appSettings?.features?.recording?.codec\"\n                @update:model-value=\"(value) => updateRecordingCodec(value as 'h264' | 'h265')\"\n              >\n                <SelectTrigger class=\"w-48\">\n                  <SelectValue />\n                </SelectTrigger>\n                <SelectContent>\n                  <SelectItem value=\"h264\">H.264 / AVC</SelectItem>\n                  <SelectItem value=\"h265\">H.265 / HEVC</SelectItem>\n                </SelectContent>\n              </Select>\n            </ItemActions>\n          </Item>\n\n          <Item variant=\"surface\" size=\"sm\">\n            <ItemContent>\n              <ItemTitle>\n                {{ t('settings.function.recording.encoderMode.label') }}\n              </ItemTitle>\n              <ItemDescription>\n                {{ t('settings.function.recording.encoderMode.description') }}\n              </ItemDescription>\n            </ItemContent>\n            <ItemActions>\n              <Select\n                :model-value=\"appSettings?.features?.recording?.encoderMode\"\n                @update:model-value=\"\n                  (value) => updateRecordingEncoderMode(value as 'auto' | 'gpu' | 'cpu')\n                \"\n              >\n                <SelectTrigger class=\"w-48\">\n                  <SelectValue />\n                </SelectTrigger>\n                <SelectContent>\n                  <SelectItem value=\"auto\">{{\n                    t('settings.function.recording.encoderMode.auto')\n                  }}</SelectItem>\n                  <SelectItem value=\"gpu\">{{\n                    t('settings.function.recording.encoderMode.gpu')\n                  }}</SelectItem>\n                  <SelectItem value=\"cpu\">{{\n                    t('settings.function.recording.encoderMode.cpu')\n                  }}</SelectItem>\n                </SelectContent>\n              </Select>\n            </ItemActions>\n          </Item>\n\n          <Item variant=\"surface\" size=\"sm\">\n            <ItemContent>\n              <ItemTitle>\n                {{ t('settings.function.recording.captureClientArea.label') }}\n              </ItemTitle>\n              <ItemDescription>\n                {{ t('settings.function.recording.captureClientArea.description') }}\n              </ItemDescription>\n            </ItemContent>\n            <ItemActions>\n              <Switch\n                :model-value=\"appSettings?.features?.recording?.captureClientArea\"\n                @update:model-value=\"(value) => updateRecordingCaptureClientArea(Boolean(value))\"\n              />\n            </ItemActions>\n          </Item>\n\n          <Item variant=\"surface\" size=\"sm\">\n            <ItemContent>\n              <ItemTitle class=\"flex items-center gap-1\">\n                {{ t('settings.function.recording.captureCursor.label') }}\n                <TooltipProvider v-if=\"showCursorCaptureHint\" :delay-duration=\"300\">\n                  <Tooltip>\n                    <TooltipTrigger as-child>\n                      <span class=\"inline-flex cursor-help text-amber-500\">\n                        <TriangleAlert class=\"size-4\" />\n                      </span>\n                    </TooltipTrigger>\n                    <TooltipContent class=\"max-w-xs\">\n                      {{ t('settings.function.recording.captureCursor.unsupportedHint') }}\n                    </TooltipContent>\n                  </Tooltip>\n                </TooltipProvider>\n              </ItemTitle>\n              <ItemDescription>\n                {{ t('settings.function.recording.captureCursor.description') }}\n              </ItemDescription>\n            </ItemContent>\n            <ItemActions>\n              <Switch\n                :model-value=\"appSettings?.features?.recording?.captureCursor\"\n                @update:model-value=\"(value) => updateRecordingCaptureCursor(Boolean(value))\"\n              />\n            </ItemActions>\n          </Item>\n\n          <Item variant=\"surface\" size=\"sm\">\n            <ItemContent>\n              <ItemTitle>\n                {{ t('settings.function.recording.autoRestartOnResize.label') }}\n              </ItemTitle>\n              <ItemDescription>\n                {{ t('settings.function.recording.autoRestartOnResize.description') }}\n              </ItemDescription>\n            </ItemContent>\n            <ItemActions>\n              <Switch\n                :model-value=\"appSettings?.features?.recording?.autoRestartOnResize\"\n                @update:model-value=\"(value) => updateRecordingAutoRestartOnResize(Boolean(value))\"\n              />\n            </ItemActions>\n          </Item>\n\n          <Item variant=\"surface\" size=\"sm\">\n            <ItemContent>\n              <ItemTitle class=\"flex items-center gap-1\">\n                {{ t('settings.function.recording.audioSource.label') }}\n                <TooltipProvider v-if=\"showGameOnlyAudioHint\" :delay-duration=\"300\">\n                  <Tooltip>\n                    <TooltipTrigger as-child>\n                      <span class=\"inline-flex cursor-help text-amber-500\">\n                        <TriangleAlert class=\"size-4\" />\n                      </span>\n                    </TooltipTrigger>\n                    <TooltipContent class=\"max-w-xs\">\n                      {{ t('settings.function.recording.audioSource.gameOnlyFallbackHint') }}\n                    </TooltipContent>\n                  </Tooltip>\n                </TooltipProvider>\n              </ItemTitle>\n              <ItemDescription>\n                {{ t('settings.function.recording.audioSource.description') }}\n              </ItemDescription>\n            </ItemContent>\n            <ItemActions>\n              <Select\n                :model-value=\"appSettings?.features?.recording?.audioSource\"\n                @update:model-value=\"\n                  (value) => updateRecordingAudioSource(value as 'none' | 'system' | 'game_only')\n                \"\n              >\n                <SelectTrigger class=\"w-48\">\n                  <SelectValue />\n                </SelectTrigger>\n                <SelectContent>\n                  <SelectItem value=\"none\">{{\n                    t('settings.function.recording.audioSource.none')\n                  }}</SelectItem>\n                  <SelectItem value=\"system\">{{\n                    t('settings.function.recording.audioSource.system')\n                  }}</SelectItem>\n                  <SelectItem value=\"game_only\">{{\n                    t('settings.function.recording.audioSource.gameOnly')\n                  }}</SelectItem>\n                </SelectContent>\n              </Select>\n            </ItemActions>\n          </Item>\n\n          <Item variant=\"surface\" size=\"sm\">\n            <ItemContent>\n              <ItemTitle>\n                {{ t('settings.function.recording.audioBitrate.label') }}\n              </ItemTitle>\n              <ItemDescription>\n                {{ t('settings.function.recording.audioBitrate.description') }}\n              </ItemDescription>\n            </ItemContent>\n            <ItemActions>\n              <Input\n                v-model.number=\"inputAudioBitrateKbps\"\n                type=\"number\"\n                :min=\"64\"\n                :max=\"512\"\n                class=\"w-24\"\n                @blur=\"handleAudioBitrateChange\"\n                @keydown.enter=\"handleAudioBitrateChange\"\n              />\n              <span class=\"text-sm text-muted-foreground\">kbps</span>\n            </ItemActions>\n          </Item>\n        </ItemGroup>\n      </div>\n\n      <div v-if=\"showExperimentalCaptureSettings\" class=\"space-y-4\">\n        <div>\n          <h3 class=\"text-lg font-semibold text-foreground\">\n            {{ t('settings.function.motionPhoto.title') }}\n          </h3>\n          <p class=\"mt-1 text-sm text-muted-foreground\">\n            {{ t('settings.function.motionPhoto.description') }}\n          </p>\n        </div>\n\n        <ItemGroup>\n          <Item variant=\"surface\" size=\"sm\">\n            <ItemContent>\n              <ItemTitle>\n                {{ t('settings.function.motionPhoto.duration.label') }}\n              </ItemTitle>\n              <ItemDescription>\n                {{ t('settings.function.motionPhoto.duration.description') }}\n              </ItemDescription>\n            </ItemContent>\n            <ItemActions>\n              <Input\n                v-model.number=\"inputMotionPhotoDuration\"\n                type=\"number\"\n                :min=\"1\"\n                :max=\"10\"\n                class=\"w-24\"\n                @blur=\"handleMotionPhotoDurationChange\"\n                @keydown.enter=\"handleMotionPhotoDurationChange\"\n              />\n              <span class=\"text-sm text-muted-foreground\">s</span>\n            </ItemActions>\n          </Item>\n\n          <Item variant=\"surface\" size=\"sm\">\n            <ItemContent>\n              <ItemTitle>\n                {{ t('settings.function.motionPhoto.resolution.label') }}\n              </ItemTitle>\n              <ItemDescription>\n                {{ t('settings.function.motionPhoto.resolution.description') }}\n              </ItemDescription>\n            </ItemContent>\n            <ItemActions>\n              <Select\n                :model-value=\"String(appSettings?.features?.motionPhoto?.resolution ?? 0)\"\n                @update:model-value=\"(value) => updateMotionPhotoResolution(Number(value))\"\n              >\n                <SelectTrigger class=\"w-32\">\n                  <SelectValue />\n                </SelectTrigger>\n                <SelectContent>\n                  <SelectItem value=\"0\">{{\n                    t('settings.function.motionPhoto.resolution.original')\n                  }}</SelectItem>\n                  <SelectItem value=\"720\">720P</SelectItem>\n                  <SelectItem value=\"1080\">1080P</SelectItem>\n                  <SelectItem value=\"1440\">1440P</SelectItem>\n                  <SelectItem value=\"2160\">4K</SelectItem>\n                </SelectContent>\n              </Select>\n            </ItemActions>\n          </Item>\n\n          <Item variant=\"surface\" size=\"sm\">\n            <ItemContent>\n              <ItemTitle>\n                {{ t('settings.function.motionPhoto.fps.label') }}\n              </ItemTitle>\n              <ItemDescription>\n                {{ t('settings.function.motionPhoto.fps.description') }}\n              </ItemDescription>\n            </ItemContent>\n            <ItemActions>\n              <Input\n                v-model.number=\"inputMotionPhotoFps\"\n                type=\"number\"\n                :min=\"15\"\n                :max=\"60\"\n                class=\"w-24\"\n                @blur=\"handleMotionPhotoFpsChange\"\n                @keydown.enter=\"handleMotionPhotoFpsChange\"\n              />\n              <span class=\"text-sm text-muted-foreground\">FPS</span>\n            </ItemActions>\n          </Item>\n\n          <Item variant=\"surface\" size=\"sm\">\n            <ItemContent>\n              <ItemTitle>\n                {{ t('settings.function.motionPhoto.rateControl.label') }}\n              </ItemTitle>\n              <ItemDescription>\n                {{ t('settings.function.motionPhoto.rateControl.description') }}\n              </ItemDescription>\n            </ItemContent>\n            <ItemActions>\n              <Select\n                :model-value=\"appSettings?.features?.motionPhoto?.rateControl\"\n                @update:model-value=\"\n                  (value) => updateMotionPhotoRateControl(value as 'cbr' | 'vbr')\n                \"\n              >\n                <SelectTrigger class=\"w-40\">\n                  <SelectValue />\n                </SelectTrigger>\n                <SelectContent>\n                  <SelectItem value=\"cbr\">{{\n                    t('settings.function.recording.rateControl.cbr')\n                  }}</SelectItem>\n                  <SelectItem value=\"vbr\">{{\n                    t('settings.function.recording.rateControl.vbr')\n                  }}</SelectItem>\n                </SelectContent>\n              </Select>\n            </ItemActions>\n          </Item>\n\n          <Item\n            v-if=\"appSettings?.features?.motionPhoto?.rateControl === 'cbr'\"\n            variant=\"surface\"\n            size=\"sm\"\n          >\n            <ItemContent>\n              <ItemTitle>\n                {{ t('settings.function.motionPhoto.bitrate.label') }}\n              </ItemTitle>\n              <ItemDescription>\n                {{ t('settings.function.motionPhoto.bitrate.description') }}\n              </ItemDescription>\n            </ItemContent>\n            <ItemActions>\n              <Input\n                v-model.number=\"inputMotionPhotoBitrateMbps\"\n                type=\"number\"\n                :min=\"1\"\n                :max=\"50\"\n                class=\"w-24\"\n                @blur=\"handleMotionPhotoBitrateChange\"\n                @keydown.enter=\"handleMotionPhotoBitrateChange\"\n              />\n              <span class=\"text-sm text-muted-foreground\">Mbps</span>\n            </ItemActions>\n          </Item>\n\n          <Item\n            v-if=\"appSettings?.features?.motionPhoto?.rateControl === 'vbr'\"\n            variant=\"surface\"\n            size=\"sm\"\n          >\n            <ItemContent>\n              <ItemTitle>\n                {{ t('settings.function.motionPhoto.quality.label') }}\n              </ItemTitle>\n              <ItemDescription>\n                {{ t('settings.function.motionPhoto.quality.description') }}\n              </ItemDescription>\n            </ItemContent>\n            <ItemActions>\n              <Input\n                v-model.number=\"inputMotionPhotoQuality\"\n                type=\"number\"\n                :min=\"0\"\n                :max=\"100\"\n                class=\"w-24\"\n                @blur=\"handleMotionPhotoQualityChange\"\n                @keydown.enter=\"handleMotionPhotoQualityChange\"\n              />\n              <span class=\"text-sm text-muted-foreground\">(0-100)</span>\n            </ItemActions>\n          </Item>\n\n          <Item variant=\"surface\" size=\"sm\">\n            <ItemContent>\n              <ItemTitle>\n                {{ t('settings.function.motionPhoto.codec.label') }}\n              </ItemTitle>\n              <ItemDescription>\n                {{ t('settings.function.motionPhoto.codec.description') }}\n              </ItemDescription>\n            </ItemContent>\n            <ItemActions>\n              <Select\n                :model-value=\"appSettings?.features?.motionPhoto?.codec\"\n                @update:model-value=\"(value) => updateMotionPhotoCodec(value as 'h264' | 'h265')\"\n              >\n                <SelectTrigger class=\"w-40\">\n                  <SelectValue />\n                </SelectTrigger>\n                <SelectContent>\n                  <SelectItem value=\"h264\">H.264 / AVC</SelectItem>\n                  <SelectItem value=\"h265\">H.265 / HEVC</SelectItem>\n                </SelectContent>\n              </Select>\n            </ItemActions>\n          </Item>\n\n          <Item variant=\"surface\" size=\"sm\">\n            <ItemContent>\n              <ItemTitle class=\"flex items-center gap-1\">\n                {{ t('settings.function.motionPhoto.audioSource.label') }}\n                <TooltipProvider v-if=\"showGameOnlyAudioHint\" :delay-duration=\"300\">\n                  <Tooltip>\n                    <TooltipTrigger as-child>\n                      <span class=\"inline-flex cursor-help text-amber-500\">\n                        <TriangleAlert class=\"size-4\" />\n                      </span>\n                    </TooltipTrigger>\n                    <TooltipContent class=\"max-w-xs\">\n                      {{ t('settings.function.recording.audioSource.gameOnlyFallbackHint') }}\n                    </TooltipContent>\n                  </Tooltip>\n                </TooltipProvider>\n              </ItemTitle>\n              <ItemDescription>\n                {{ t('settings.function.motionPhoto.audioSource.description') }}\n              </ItemDescription>\n            </ItemContent>\n            <ItemActions>\n              <Select\n                :model-value=\"appSettings?.features?.motionPhoto?.audioSource\"\n                @update:model-value=\"\n                  (value) => updateMotionPhotoAudioSource(value as 'none' | 'system' | 'game_only')\n                \"\n              >\n                <SelectTrigger class=\"w-40\">\n                  <SelectValue />\n                </SelectTrigger>\n                <SelectContent>\n                  <SelectItem value=\"none\">{{\n                    t('settings.function.recording.audioSource.none')\n                  }}</SelectItem>\n                  <SelectItem value=\"system\">{{\n                    t('settings.function.recording.audioSource.system')\n                  }}</SelectItem>\n                  <SelectItem value=\"game_only\">{{\n                    t('settings.function.recording.audioSource.gameOnly')\n                  }}</SelectItem>\n                </SelectContent>\n              </Select>\n            </ItemActions>\n          </Item>\n\n          <Item variant=\"surface\" size=\"sm\">\n            <ItemContent>\n              <ItemTitle>\n                {{ t('settings.function.motionPhoto.audioBitrate.label') }}\n              </ItemTitle>\n              <ItemDescription>\n                {{ t('settings.function.motionPhoto.audioBitrate.description') }}\n              </ItemDescription>\n            </ItemContent>\n            <ItemActions>\n              <Input\n                v-model.number=\"inputMotionPhotoAudioBitrateKbps\"\n                type=\"number\"\n                :min=\"64\"\n                :max=\"320\"\n                class=\"w-24\"\n                @blur=\"handleMotionPhotoAudioBitrateChange\"\n                @keydown.enter=\"handleMotionPhotoAudioBitrateChange\"\n              />\n              <span class=\"text-sm text-muted-foreground\">kbps</span>\n            </ItemActions>\n          </Item>\n        </ItemGroup>\n      </div>\n\n      <div v-if=\"showExperimentalCaptureSettings\" class=\"space-y-4\">\n        <div>\n          <h3 class=\"text-lg font-semibold text-foreground\">\n            {{ t('settings.function.replayBuffer.title') }}\n          </h3>\n          <p class=\"mt-1 text-sm text-muted-foreground\">\n            {{ t('settings.function.replayBuffer.description') }}\n          </p>\n        </div>\n\n        <Item variant=\"surface\" size=\"sm\">\n          <ItemContent>\n            <ItemTitle>\n              {{ t('settings.function.replayBuffer.duration.label') }}\n            </ItemTitle>\n            <ItemDescription>\n              {{ t('settings.function.replayBuffer.duration.description') }}\n            </ItemDescription>\n          </ItemContent>\n          <ItemActions>\n            <Input\n              v-model.number=\"inputReplayBufferDuration\"\n              type=\"number\"\n              :min=\"5\"\n              :max=\"300\"\n              class=\"w-24\"\n              @blur=\"handleReplayBufferDurationChange\"\n              @keydown.enter=\"handleReplayBufferDurationChange\"\n            />\n            <span class=\"text-sm text-muted-foreground\">s</span>\n          </ItemActions>\n        </Item>\n      </div>\n    </div>\n  </div>\n</template>\n"
  },
  {
    "path": "web/src/features/settings/components/DraggableSettingsList.vue",
    "content": "<script setup lang=\"ts\">\nimport { ref, onUnmounted, nextTick } from 'vue'\nimport { Switch } from '@/components/ui/switch'\nimport { Label } from '@/components/ui/label'\nimport { Button } from '@/components/ui/button'\nimport { Input } from '@/components/ui/input'\nimport { GripVertical, Plus, Trash2 } from 'lucide-vue-next'\nimport { useI18n } from '@/composables/useI18n'\nimport type { MenuItem } from '../types'\n\nconst props = defineProps<{\n  items: MenuItem[]\n  title: string\n  description: string\n\n  // Optional features\n  allowAdd?: boolean\n  allowRemove?: boolean\n  showToggle?: boolean // 是否显示启用/禁用切换开关\n\n  // Customization\n  addPlaceholder?: string\n  validateInput?: (value: string) => boolean\n  getLabel?: (id: string) => string\n}>()\n\nconst emit = defineEmits<{\n  (e: 'reorder', items: MenuItem[]): void\n  (e: 'toggle', id: string, enabled: boolean): void\n  (e: 'add', item: { id: string; enabled: boolean }): void\n  (e: 'remove', id: string): void\n}>()\n\nconst { t } = useI18n()\nconst isAdding = ref(false)\nconst newItemValue = ref('')\n\n// Resolve label\nconst getItemLabel = (id: string) => {\n  return props.getLabel ? props.getLabel(id) : id\n}\n\n// --- Drag & Drop State ---\nconst draggingId = ref<string | null>(null)\nconst dragStartY = ref(0)\nconst mouseOffsetInItem = ref(0) // Mouse position relative to item top\nconst itemsContainer = ref<HTMLElement | null>(null)\nconst sourceEl = ref<HTMLElement | null>(null)\nconst ghostEl = ref<HTMLElement | null>(null)\nconst cachedRects = ref<Map<string, DOMRect>>(new Map())\nconst rafId = ref<number | null>(null)\n\n// --- Helper Actions ---\n\nconst handleAddItem = () => {\n  if (!props.allowAdd) return\n  const value = newItemValue.value.trim()\n  if (!value) return\n  if (props.items.some((item) => item.id === value)) return // Exists\n  if (props.validateInput && !props.validateInput(value)) return // Invalid\n\n  emit('add', { id: value, enabled: true })\n  newItemValue.value = ''\n  isAdding.value = false\n}\n\nconst handleKeyDown = (e: KeyboardEvent) => {\n  if (e.key === 'Enter') handleAddItem()\n  if (e.key === 'Escape') {\n    isAdding.value = false\n    newItemValue.value = ''\n  }\n}\n\nconst handleToggle = (id: string, enabled: boolean) => {\n  if (draggingId.value) return // Prevent toggle while dragging\n  emit('toggle', id, enabled)\n}\n\n// --- Ghost Element Functions ---\n\nconst createGhost = (source: HTMLElement, rect: DOMRect, initialTop: number): HTMLElement => {\n  const ghost = source.cloneNode(true) as HTMLElement\n  ghost.classList.add('draggable-ghost')\n\n  // Remove Tailwind classes that would override our styles\n  ghost.classList.remove('surface-top', 'hover:bg-accent/50')\n\n  ghost.style.cssText = `\n        position: fixed;\n        left: ${rect.left}px;\n        top: ${initialTop}px;\n        width: ${rect.width}px;\n        height: ${rect.height}px;\n        margin: 0;\n        pointer-events: none;\n        z-index: 9999;\n        will-change: top;\n    `\n  document.body.appendChild(ghost)\n  return ghost\n}\n\nconst removeGhost = () => {\n  if (ghostEl.value) {\n    ghostEl.value.remove()\n    ghostEl.value = null\n  }\n}\n\nconst cacheItemRects = () => {\n  if (!itemsContainer.value) return\n  cachedRects.value.clear()\n  const itemElements = Array.from(\n    itemsContainer.value.querySelectorAll('.draggable-item')\n  ) as HTMLElement[]\n  itemElements.forEach((el, index) => {\n    const itemId = props.items[index]?.id\n    if (itemId) {\n      cachedRects.value.set(itemId, el.getBoundingClientRect())\n    }\n  })\n}\n\n// --- Drag & Drop Logic ---\n\nconst handlePointerDown = (_id: string, event: PointerEvent) => {\n  if (event.button !== 0) return\n  const target = event.currentTarget as HTMLElement\n  if (!target) return\n\n  const itemEl = target.closest('.draggable-item') as HTMLElement\n  if (!itemEl) return\n\n  // Immediately prevent text selection\n  document.body.style.userSelect = 'none'\n  document.body.style.cursor = 'grabbing'\n\n  sourceEl.value = itemEl\n  dragStartY.value = event.clientY\n\n  // Record mouse offset within the item (for ghost positioning)\n  const rect = itemEl.getBoundingClientRect()\n  mouseOffsetInItem.value = event.clientY - rect.top\n\n  // Cache all item rects for efficient swap detection\n  cacheItemRects()\n\n  window.addEventListener('pointermove', handlePointerMove)\n  window.addEventListener('pointerup', handlePointerUp)\n  window.addEventListener('pointercancel', handlePointerUp)\n}\n\nconst handlePointerMove = (event: PointerEvent) => {\n  const currentY = event.clientY\n  const deltaY = currentY - dragStartY.value\n\n  // Start dragging after 3px threshold\n  if (!draggingId.value && sourceEl.value && Math.abs(deltaY) > 3) {\n    // Find the item id from the source element\n    const itemIndex = Array.from(\n      itemsContainer.value?.querySelectorAll('.draggable-item') || []\n    ).indexOf(sourceEl.value)\n    if (itemIndex >= 0 && props.items[itemIndex]) {\n      draggingId.value = props.items[itemIndex].id\n\n      // Create ghost element at current mouse position\n      const rect = sourceEl.value.getBoundingClientRect()\n      const ghostTop = currentY - mouseOffsetInItem.value\n      ghostEl.value = createGhost(sourceEl.value, rect, ghostTop)\n    }\n  }\n\n  // Update ghost position directly based on mouse Y (no transform offset needed)\n  if (draggingId.value && ghostEl.value) {\n    if (rafId.value) cancelAnimationFrame(rafId.value)\n    rafId.value = requestAnimationFrame(() => {\n      if (ghostEl.value) {\n        // Ghost follows mouse directly\n        ghostEl.value.style.top = `${currentY - mouseOffsetInItem.value}px`\n      }\n    })\n\n    // Check for swap\n    checkSwap(currentY)\n  }\n}\n\nconst checkSwap = (cursorY: number) => {\n  if (!draggingId.value || !itemsContainer.value) return\n\n  const currentIndex = props.items.findIndex((item) => item.id === draggingId.value)\n  if (currentIndex === -1) return\n\n  // Check swap with previous item\n  if (currentIndex > 0) {\n    const prevItem = props.items[currentIndex - 1]\n    if (!prevItem) return\n    const prevRect = cachedRects.value.get(prevItem.id)\n    if (prevRect) {\n      const prevCenter = prevRect.top + prevRect.height / 2\n      if (cursorY < prevCenter) {\n        performSwap(currentIndex, currentIndex - 1, -prevRect.height)\n        return\n      }\n    }\n  }\n\n  // Check swap with next item\n  if (currentIndex < props.items.length - 1) {\n    const nextItem = props.items[currentIndex + 1]\n    if (!nextItem) return\n    const nextRect = cachedRects.value.get(nextItem.id)\n    if (nextRect) {\n      const nextCenter = nextRect.top + nextRect.height / 2\n      if (cursorY > nextCenter) {\n        performSwap(currentIndex, currentIndex + 1, nextRect.height)\n        return\n      }\n    }\n  }\n}\n\nconst performSwap = (fromIndex: number, toIndex: number, _offsetAdjust: number) => {\n  const newItems = [...props.items]\n  const moved = newItems.splice(fromIndex, 1)[0]\n  if (moved) {\n    newItems.splice(toIndex, 0, moved)\n    // 不再需要设置 order，数组顺序即为显示顺序\n    emit('reorder', newItems)\n\n    // Re-cache rects after DOM update\n    nextTick(() => cacheItemRects())\n  }\n}\n\nconst handlePointerUp = () => {\n  // Cancel any pending animation frame\n  if (rafId.value) {\n    cancelAnimationFrame(rafId.value)\n    rafId.value = null\n  }\n\n  // Clean up ghost\n  removeGhost()\n\n  // Reset state\n  draggingId.value = null\n  sourceEl.value = null\n  mouseOffsetInItem.value = 0\n  cachedRects.value.clear()\n\n  // Restore body styles\n  document.body.style.userSelect = ''\n  document.body.style.cursor = ''\n\n  window.removeEventListener('pointermove', handlePointerMove)\n  window.removeEventListener('pointerup', handlePointerUp)\n  window.removeEventListener('pointercancel', handlePointerUp)\n}\n\nonUnmounted(() => {\n  if (rafId.value) cancelAnimationFrame(rafId.value)\n  removeGhost()\n  document.body.style.userSelect = ''\n  document.body.style.cursor = ''\n  window.removeEventListener('pointermove', handlePointerMove)\n  window.removeEventListener('pointerup', handlePointerUp)\n  window.removeEventListener('pointercancel', handlePointerUp)\n})\n</script>\n\n<template>\n  <div class=\"space-y-3\">\n    <div>\n      <h3 class=\"text-base font-semibold text-foreground\">{{ title }}</h3>\n      <p class=\"mt-1 text-sm text-muted-foreground\">{{ description }}</p>\n    </div>\n\n    <div class=\"surface-top rounded-md p-3\">\n      <div ref=\"itemsContainer\" class=\"relative flex flex-col gap-1\">\n        <TransitionGroup name=\"list\">\n          <div\n            v-for=\"item in items\"\n            :key=\"item.id\"\n            class=\"draggable-item surface-top group flex cursor-grab items-center justify-between rounded-md p-2.5 transition-colors hover:bg-accent/50 active:cursor-grabbing\"\n            :class=\"{\n              'is-dragging-source': draggingId === item.id,\n            }\"\n            @pointerdown=\"handlePointerDown(item.id, $event)\"\n          >\n            <div class=\"flex flex-1 items-center gap-3 pr-4\">\n              <div\n                class=\"text-muted-foreground/60 transition-colors group-hover:text-muted-foreground\"\n              >\n                <GripVertical class=\"h-4 w-4\" />\n              </div>\n              <Label class=\"pointer-events-none text-sm font-medium text-foreground select-none\">\n                {{ getItemLabel(item.id) }}\n              </Label>\n            </div>\n            <div class=\"flex flex-shrink-0 items-center gap-2\">\n              <Switch\n                v-if=\"showToggle\"\n                :model-value=\"item.enabled\"\n                @update:model-value=\"(v: boolean) => handleToggle(item.id, v)\"\n                @click.stop\n                @pointerdown.stop\n              />\n              <Button\n                v-if=\"allowRemove\"\n                variant=\"ghost\"\n                size=\"icon\"\n                class=\"h-6 w-6 text-destructive hover:bg-destructive/10 hover:text-destructive\"\n                @click=\"emit('remove', item.id)\"\n                @pointerdown.stop\n              >\n                <Trash2 class=\"size-4\" />\n              </Button>\n            </div>\n          </div>\n        </TransitionGroup>\n      </div>\n\n      <!-- Add Item Section (only if allowAdd is true) -->\n      <div v-if=\"allowAdd\" class=\"mt-3 pt-3\">\n        <div\n          v-if=\"isAdding\"\n          class=\"flex items-center gap-2 rounded-md border border-primary bg-primary/5 p-3\"\n        >\n          <Input\n            v-model=\"newItemValue\"\n            @keydown=\"handleKeyDown\"\n            :placeholder=\"addPlaceholder\"\n            class=\"flex-1\"\n            autofocus\n          />\n          <Button size=\"sm\" @click=\"handleAddItem\">\n            {{ t('settings.menu.actions.add') }}\n          </Button>\n          <Button\n            variant=\"ghost\"\n            size=\"sm\"\n            @click=\"\n              () => {\n                isAdding = false\n                newItemValue = ''\n              }\n            \"\n          >\n            {{ t('settings.menu.actions.cancel') }}\n          </Button>\n        </div>\n        <Button\n          v-else\n          variant=\"outline\"\n          size=\"sm\"\n          class=\"w-full border-dashed hover:border-primary hover:text-primary\"\n          @click=\"isAdding = true\"\n        >\n          <Plus class=\"mr-2 h-4 w-4\" />\n          {{ t('settings.menu.actions.addCustomItem') }}\n        </Button>\n      </div>\n\n      <div\n        v-if=\"items.length === 0 && !isAdding\"\n        class=\"flex items-center justify-center py-8 text-center text-sm text-muted-foreground\"\n      >\n        {{ t('settings.menu.status.noPresetItems') }}\n      </div>\n    </div>\n  </div>\n</template>\n\n<style scoped>\n/* Smooth transition for items when reordering */\n.list-move {\n  transition: transform 0.2s ease;\n}\n\n/* Hide the source element while dragging (ghost shows instead) */\n.draggable-item.is-dragging-source {\n  opacity: 0 !important;\n  pointer-events: none;\n}\n\n/* Prevent text selection on all draggable items */\n.draggable-item {\n  user-select: none;\n}\n\n/* Ghost element styles (applied via JS, but kept here for reference) */\n:global(.draggable-ghost) {\n  /* Use color-mix to handle transparency for oklch variables */\n  background: color-mix(in srgb, var(--accent), transparent 50%);\n  border: 1px solid color-mix(in srgb, var(--primary), transparent 80%);\n  border-radius: 0.375rem;\n  opacity: 0.9;\n}\n</style>\n"
  },
  {
    "path": "web/src/features/settings/components/ExtensionsContent.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed, ref } from 'vue'\nimport { storeToRefs } from 'pinia'\nimport { Badge } from '@/components/ui/badge'\nimport { Button } from '@/components/ui/button'\nimport { Switch } from '@/components/ui/switch'\nimport {\n  Item,\n  ItemActions,\n  ItemContent,\n  ItemDescription,\n  ItemGroup,\n  ItemTitle,\n} from '@/components/ui/item'\nimport { useI18n } from '@/composables/useI18n'\nimport { useToast } from '@/composables/useToast'\nimport { detectInfinityNikkiGameDirectory, getFileInfo, selectDirectory } from '../api'\nimport { useExtensionActions } from '../composables/useExtensionActions'\nimport { useSettingsStore } from '../store'\nimport ResetSettingsDialog from './ResetSettingsDialog.vue'\n\nconst store = useSettingsStore()\nconst { appSettings, error, isInitialized } = storeToRefs(store)\nconst { clearError } = store\nconst { t } = useI18n()\nconst { toast } = useToast()\nconst {\n  updateInfinityNikkiEnabled,\n  updateInfinityNikkiGameDir,\n  updateInfinityNikkiAllowOnlinePhotoMetadataExtract,\n  updateInfinityNikkiManageScreenshotHardlinks,\n  completeInfinityNikkiInitialization,\n  resetExtensionSettings,\n} = useExtensionActions()\n\nconst isDetectingGameDir = ref(false)\nconst isSelectingGameDir = ref(false)\nconst isCompletingInitialization = ref(false)\n\ntype InitializationStatus = {\n  badgeVariant: 'default' | 'secondary' | 'outline'\n  badgeLabel: string\n  description: string\n}\n\nconst infinityNikkiSettings = computed(() => appSettings.value.extensions.infinityNikki)\nconst trimmedGameDir = computed(() => infinityNikkiSettings.value.gameDir.trim())\nconst canCompleteInitialization = computed(() => {\n  return infinityNikkiSettings.value.enable && trimmedGameDir.value.length > 0\n})\nconst initializationStatus = computed<InitializationStatus>(() => {\n  if (infinityNikkiSettings.value.galleryGuideSeen) {\n    return {\n      badgeVariant: 'secondary',\n      badgeLabel: t('settings.extensions.infinityNikki.initialization.completed'),\n      description: t('settings.extensions.infinityNikki.initialization.completedDescription'),\n    }\n  }\n\n  if (!canCompleteInitialization.value) {\n    return {\n      badgeVariant: 'outline',\n      badgeLabel: t('settings.extensions.infinityNikki.initialization.notReady'),\n      description: t('settings.extensions.infinityNikki.initialization.notReadyDescription'),\n    }\n  }\n\n  return {\n    badgeVariant: 'default',\n    badgeLabel: t('settings.extensions.infinityNikki.initialization.pending'),\n    description: t('settings.extensions.infinityNikki.initialization.pendingDescription'),\n  }\n})\n\nfunction buildInfinityNikkiExePath(dir: string): string {\n  const base = normalizeDirectoryPath(dir).replace(/\\\\/g, '/')\n  return `${base}/InfinityNikki.exe`\n}\n\nfunction normalizeDirectoryPath(dir: string): string {\n  return dir.trim().replace(/[\\/]+$/, '')\n}\n\nasync function isValidInfinityNikkiGameDir(dir: string): Promise<boolean> {\n  const normalizedDir = normalizeDirectoryPath(dir)\n  if (!normalizedDir) {\n    return false\n  }\n\n  const dirInfo = await getFileInfo(normalizedDir)\n  if (!dirInfo.exists || !dirInfo.isDirectory) {\n    return false\n  }\n\n  const exeInfo = await getFileInfo(buildInfinityNikkiExePath(normalizedDir))\n  return exeInfo.exists && exeInfo.isRegularFile\n}\n\nfunction getErrorMessage(error: unknown): string {\n  return error instanceof Error ? error.message : String(error)\n}\n\nconst handleClearError = () => {\n  clearError()\n}\n\nconst handleResetSettings = async () => {\n  await resetExtensionSettings()\n}\n\nconst handleEnableChange = async (enabled: boolean) => {\n  try {\n    await updateInfinityNikkiEnabled(enabled)\n  } catch (error) {\n    toast.error(t('settings.extensions.infinityNikki.saveFailedTitle'), {\n      description: getErrorMessage(error),\n    })\n  }\n}\n\nconst handleAllowOnlinePhotoMetadataExtractChange = async (enabled: boolean) => {\n  try {\n    await updateInfinityNikkiAllowOnlinePhotoMetadataExtract(enabled)\n  } catch (error) {\n    toast.error(t('settings.extensions.infinityNikki.saveFailedTitle'), {\n      description: getErrorMessage(error),\n    })\n  }\n}\n\nconst handleManageScreenshotHardlinksChange = async (enabled: boolean) => {\n  try {\n    await updateInfinityNikkiManageScreenshotHardlinks(enabled)\n  } catch (error) {\n    toast.error(t('settings.extensions.infinityNikki.saveFailedTitle'), {\n      description: getErrorMessage(error),\n    })\n  }\n}\n\nconst handleDetectGameDir = async () => {\n  if (isDetectingGameDir.value) {\n    return\n  }\n\n  isDetectingGameDir.value = true\n  try {\n    const result = await detectInfinityNikkiGameDirectory()\n    if (result.gameDirFound && result.gameDir) {\n      const gameDir = normalizeDirectoryPath(result.gameDir)\n      await updateInfinityNikkiGameDir(gameDir)\n      toast.success(t('settings.extensions.infinityNikki.gameDir.detectSuccessTitle'), {\n        description: gameDir,\n      })\n      return\n    }\n\n    toast.error(t('settings.extensions.infinityNikki.gameDir.detectFailedTitle'), {\n      description:\n        result.message || t('settings.extensions.infinityNikki.gameDir.detectFailedDescription'),\n    })\n  } catch (error) {\n    toast.error(t('settings.extensions.infinityNikki.gameDir.detectFailedTitle'), {\n      description: getErrorMessage(error),\n    })\n  } finally {\n    isDetectingGameDir.value = false\n  }\n}\n\nconst handleSelectGameDir = async () => {\n  if (isSelectingGameDir.value) {\n    return\n  }\n\n  isSelectingGameDir.value = true\n  try {\n    const selectedPath = await selectDirectory(\n      t('settings.extensions.infinityNikki.gameDir.dialogTitle')\n    )\n    if (!selectedPath) {\n      return\n    }\n\n    const normalizedPath = normalizeDirectoryPath(selectedPath)\n    const isValid = await isValidInfinityNikkiGameDir(normalizedPath)\n    if (!isValid) {\n      toast.error(t('settings.extensions.infinityNikki.gameDir.invalidTitle'), {\n        description: t('settings.extensions.infinityNikki.gameDir.invalidDescription'),\n      })\n      return\n    }\n\n    await updateInfinityNikkiGameDir(normalizedPath)\n    toast.success(t('settings.extensions.infinityNikki.gameDir.selectSuccessTitle'), {\n      description: normalizedPath,\n    })\n  } catch (error) {\n    toast.error(t('settings.extensions.infinityNikki.gameDir.selectFailedTitle'), {\n      description: getErrorMessage(error),\n    })\n  } finally {\n    isSelectingGameDir.value = false\n  }\n}\n\nconst handleCompleteInitialization = async () => {\n  if (isCompletingInitialization.value) {\n    return\n  }\n\n  if (!canCompleteInitialization.value) {\n    toast.error(t('settings.extensions.infinityNikki.initialization.requirementsTitle'), {\n      description: t('settings.extensions.infinityNikki.initialization.requirementsDescription'),\n    })\n    return\n  }\n\n  isCompletingInitialization.value = true\n  try {\n    await completeInfinityNikkiInitialization()\n    toast.success(t('settings.extensions.infinityNikki.initialization.completeSuccessTitle'), {\n      description: t('settings.extensions.infinityNikki.initialization.completeSuccessDescription'),\n    })\n  } catch (error) {\n    toast.error(t('settings.extensions.infinityNikki.initialization.completeFailedTitle'), {\n      description: getErrorMessage(error),\n    })\n  } finally {\n    isCompletingInitialization.value = false\n  }\n}\n</script>\n\n<template>\n  <div v-if=\"!isInitialized\" class=\"flex items-center justify-center p-6\">\n    <div class=\"text-center\">\n      <div\n        class=\"mx-auto h-8 w-8 animate-spin rounded-full border-4 border-muted border-t-primary\"\n      ></div>\n      <p class=\"mt-2 text-sm text-muted-foreground\">{{ t('settings.extensions.loading') }}</p>\n    </div>\n  </div>\n\n  <div v-else-if=\"error\" class=\"flex items-center justify-center p-6\">\n    <div class=\"text-center\">\n      <p class=\"text-sm text-muted-foreground\">{{ t('settings.extensions.error.title') }}</p>\n      <p class=\"mt-1 text-sm text-red-500\">{{ error }}</p>\n      <Button variant=\"outline\" size=\"sm\" @click=\"handleClearError\" class=\"mt-2\">\n        {{ t('settings.extensions.error.retry') }}\n      </Button>\n    </div>\n  </div>\n\n  <div v-else class=\"w-full\">\n    <div class=\"mb-6 flex items-center justify-between\">\n      <div>\n        <h1 class=\"text-2xl font-bold text-foreground\">{{ t('settings.extensions.title') }}</h1>\n        <p class=\"mt-1 text-muted-foreground\">{{ t('settings.extensions.description') }}</p>\n      </div>\n      <ResetSettingsDialog\n        :title=\"t('settings.extensions.reset.title')\"\n        :description=\"t('settings.extensions.reset.description')\"\n        @reset=\"handleResetSettings\"\n      />\n    </div>\n\n    <div class=\"space-y-8\">\n      <div class=\"space-y-4\">\n        <div>\n          <h3 class=\"text-lg font-semibold text-foreground\">\n            {{ t('settings.extensions.infinityNikki.title') }}\n          </h3>\n          <p class=\"mt-1 text-sm text-muted-foreground\">\n            {{ t('settings.extensions.infinityNikki.description') }}\n          </p>\n        </div>\n\n        <ItemGroup>\n          <Item variant=\"surface\" size=\"sm\">\n            <ItemContent>\n              <ItemTitle>{{ t('settings.extensions.infinityNikki.enable.label') }}</ItemTitle>\n              <ItemDescription>\n                {{ t('settings.extensions.infinityNikki.enable.description') }}\n              </ItemDescription>\n            </ItemContent>\n            <ItemActions>\n              <Switch\n                :model-value=\"infinityNikkiSettings.enable\"\n                @update:model-value=\"(value) => handleEnableChange(Boolean(value))\"\n              />\n            </ItemActions>\n          </Item>\n\n          <Item variant=\"surface\" size=\"sm\">\n            <ItemContent>\n              <ItemTitle>{{ t('settings.extensions.infinityNikki.gameDir.label') }}</ItemTitle>\n              <ItemDescription class=\"line-clamp-none font-mono text-xs break-all\">\n                {{ trimmedGameDir || t('settings.extensions.infinityNikki.gameDir.empty') }}\n              </ItemDescription>\n            </ItemContent>\n            <ItemActions>\n              <div class=\"flex flex-col gap-2 sm:flex-row\">\n                <Button\n                  variant=\"outline\"\n                  size=\"sm\"\n                  :disabled=\"isDetectingGameDir || isSelectingGameDir\"\n                  @click=\"handleDetectGameDir\"\n                >\n                  {{\n                    isDetectingGameDir\n                      ? t('settings.extensions.infinityNikki.gameDir.detecting')\n                      : t('settings.extensions.infinityNikki.gameDir.detectButton')\n                  }}\n                </Button>\n                <Button\n                  size=\"sm\"\n                  :disabled=\"isSelectingGameDir || isDetectingGameDir\"\n                  @click=\"handleSelectGameDir\"\n                >\n                  {{\n                    isSelectingGameDir\n                      ? t('settings.extensions.infinityNikki.gameDir.selecting')\n                      : t('settings.extensions.infinityNikki.gameDir.selectButton')\n                  }}\n                </Button>\n              </div>\n            </ItemActions>\n          </Item>\n\n          <Item variant=\"surface\" size=\"sm\">\n            <ItemContent>\n              <div class=\"flex items-center gap-2\">\n                <ItemTitle>\n                  {{ t('settings.extensions.infinityNikki.initialization.label') }}\n                </ItemTitle>\n                <Badge :variant=\"initializationStatus.badgeVariant\">\n                  {{ initializationStatus.badgeLabel }}\n                </Badge>\n              </div>\n              <ItemDescription>\n                {{ initializationStatus.description }}\n              </ItemDescription>\n            </ItemContent>\n            <ItemActions>\n              <Button\n                v-if=\"!infinityNikkiSettings.galleryGuideSeen\"\n                size=\"sm\"\n                :disabled=\"isCompletingInitialization || !canCompleteInitialization\"\n                @click=\"handleCompleteInitialization\"\n              >\n                {{\n                  isCompletingInitialization\n                    ? t('settings.extensions.infinityNikki.initialization.completing')\n                    : t('settings.extensions.infinityNikki.initialization.completeButton')\n                }}\n              </Button>\n            </ItemActions>\n          </Item>\n\n          <Item variant=\"surface\" size=\"sm\">\n            <ItemContent>\n              <ItemTitle>{{ t('settings.extensions.infinityNikki.metadata.label') }}</ItemTitle>\n              <ItemDescription>\n                {{ t('settings.extensions.infinityNikki.metadata.description') }}\n              </ItemDescription>\n            </ItemContent>\n            <ItemActions>\n              <Switch\n                :model-value=\"infinityNikkiSettings.allowOnlinePhotoMetadataExtract\"\n                @update:model-value=\"\n                  (value) => handleAllowOnlinePhotoMetadataExtractChange(Boolean(value))\n                \"\n              />\n            </ItemActions>\n          </Item>\n\n          <Item variant=\"surface\" size=\"sm\">\n            <ItemContent>\n              <ItemTitle>{{ t('settings.extensions.infinityNikki.hardlinks.label') }}</ItemTitle>\n              <ItemDescription>\n                {{ t('settings.extensions.infinityNikki.hardlinks.description') }}\n              </ItemDescription>\n            </ItemContent>\n            <ItemActions>\n              <Switch\n                :model-value=\"infinityNikkiSettings.manageScreenshotHardlinks\"\n                @update:model-value=\"\n                  (value) => handleManageScreenshotHardlinksChange(Boolean(value))\n                \"\n              />\n            </ItemActions>\n          </Item>\n        </ItemGroup>\n      </div>\n    </div>\n  </div>\n</template>\n"
  },
  {
    "path": "web/src/features/settings/components/FloatingWindowContent.vue",
    "content": "<script setup lang=\"ts\">\nimport { watch } from 'vue'\nimport { useSettingsStore } from '../store'\nimport { useMenuActions } from '../composables/useMenuActions'\nimport { useAppearanceActions } from '../composables/useAppearanceActions'\nimport { storeToRefs } from 'pinia'\nimport DraggableSettingsList from './DraggableSettingsList.vue'\nimport ResetSettingsDialog from './ResetSettingsDialog.vue'\nimport { Button } from '@/components/ui/button'\nimport { Input } from '@/components/ui/input'\nimport {\n  Item,\n  ItemContent,\n  ItemTitle,\n  ItemDescription,\n  ItemActions,\n  ItemGroup,\n} from '@/components/ui/item'\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from '@/components/ui/select'\nimport { useI18n } from '@/composables/useI18n'\nimport type { FloatingWindowLayout, FloatingWindowThemeMode } from '../types'\n\nconst store = useSettingsStore()\nconst { appSettings, error, isInitialized } = storeToRefs(store)\nconst { clearError } = store\nconst { t } = useI18n()\n\nconst {\n  featureItems,\n  featureDescriptorMap,\n  aspectRatios,\n  resolutions,\n  loadFeatureItems,\n  handleFeatureToggle,\n  handleFeatureReorder,\n  handleAspectRatioAdd,\n  handleAspectRatioRemove,\n  handleAspectRatioReorder,\n  handleResolutionAdd,\n  handleResolutionRemove,\n  handleResolutionReorder,\n  handleResetSettings: resetFloatingMenuSettings,\n} = useMenuActions()\n\nconst { updateFloatingWindowLayout, updateFloatingWindowTheme, resetFloatingWindowSettings } =\n  useAppearanceActions()\n\nwatch(\n  isInitialized,\n  (initialized) => {\n    if (initialized) {\n      loadFeatureItems()\n    }\n  },\n  { immediate: true }\n)\n\nconst floatingWindowThemeOptions = [\n  { value: 'light', label: t('settings.appearance.floatingWindowTheme.light') },\n  { value: 'dark', label: t('settings.appearance.floatingWindowTheme.dark') },\n]\n\nconst getFeatureItemLabel = (id: string): string => {\n  const descriptor = featureDescriptorMap.value.get(id)\n  if (descriptor?.i18nKey) {\n    const translated = t(descriptor.i18nKey)\n    if (translated && translated !== descriptor.i18nKey) {\n      return translated\n    }\n  }\n  return id\n}\n\nconst validateAspectRatio = (value: string): boolean => {\n  const regex = /^\\d+:\\d+$/\n  return regex.test(value) && !value.includes('0:') && !value.includes(':0')\n}\n\nconst validateResolution = (value: string): boolean => {\n  const resolutionRegex = /^\\d+x\\d+$/\n  const presetRegex = /^\\d+[KkPp]?$/\n  return resolutionRegex.test(value) || presetRegex.test(value)\n}\n\nconst layoutKeys = [\n  'baseItemHeight',\n  'baseTitleHeight',\n  'baseSeparatorHeight',\n  'baseFontSize',\n  'baseTextPadding',\n  'baseIndicatorWidth',\n  'baseRatioIndicatorWidth',\n  'baseRatioColumnWidth',\n  'baseResolutionColumnWidth',\n  'baseSettingsColumnWidth',\n  'maxVisibleRows',\n] as const\n\nconst getLayoutMinValue = (field: keyof FloatingWindowLayout): number =>\n  field === 'maxVisibleRows' ? 1 : 0\n\nconst getLayoutUnitKey = (field: keyof FloatingWindowLayout): string =>\n  field === 'maxVisibleRows'\n    ? 'settings.appearance.layout.rowsUnit'\n    : 'settings.appearance.layout.unit'\n\nconst handleLayoutChange = async (field: keyof FloatingWindowLayout, value: string) => {\n  const numValue = parseInt(value, 10)\n  const minValue = getLayoutMinValue(field)\n  if (!isNaN(numValue) && numValue >= minValue && appSettings.value.ui.floatingWindowLayout) {\n    try {\n      await updateFloatingWindowLayout({\n        ...appSettings.value.ui.floatingWindowLayout,\n        [field]: numValue,\n      })\n    } catch (error) {\n      console.error('Failed to update layout settings:', error)\n    }\n  }\n}\n\nconst handleKeyDown = (e: KeyboardEvent) => {\n  if (e.key === 'Enter') {\n    ;(e.target as HTMLInputElement).blur()\n  }\n}\n\nconst handleResetSettings = async () => {\n  await resetFloatingMenuSettings()\n  await resetFloatingWindowSettings()\n}\n</script>\n\n<template>\n  <div v-if=\"!isInitialized\" class=\"flex items-center justify-center p-6\">\n    <div class=\"text-center\">\n      <div\n        class=\"mx-auto h-8 w-8 animate-spin rounded-full border-4 border-muted border-t-primary\"\n      ></div>\n      <p class=\"mt-2 text-sm text-muted-foreground\">{{ t('settings.floatingWindow.loading') }}</p>\n    </div>\n  </div>\n\n  <div v-else-if=\"error\" class=\"flex items-center justify-center p-6\">\n    <div class=\"text-center\">\n      <p class=\"text-sm text-muted-foreground\">{{ t('settings.floatingWindow.error.title') }}</p>\n      <p class=\"mt-1 text-sm text-red-500\">{{ error }}</p>\n      <Button variant=\"outline\" size=\"sm\" @click=\"clearError\" class=\"mt-2\">\n        {{ t('settings.floatingWindow.error.retry') }}\n      </Button>\n    </div>\n  </div>\n\n  <div v-else class=\"w-full\">\n    <div class=\"mb-6 flex items-center justify-between\">\n      <div>\n        <h1 class=\"text-2xl font-bold text-foreground\">{{ t('settings.floatingWindow.title') }}</h1>\n        <p class=\"mt-1 text-muted-foreground\">{{ t('settings.floatingWindow.description') }}</p>\n      </div>\n      <ResetSettingsDialog\n        :title=\"t('settings.floatingWindow.reset.title')\"\n        :description=\"t('settings.floatingWindow.reset.description')\"\n        @reset=\"handleResetSettings\"\n      />\n    </div>\n\n    <div class=\"space-y-8\">\n      <div class=\"space-y-6\">\n        <DraggableSettingsList\n          :items=\"featureItems\"\n          :title=\"t('settings.menu.feature.title')\"\n          :description=\"t('settings.menu.feature.description')\"\n          :show-toggle=\"true\"\n          :get-label=\"getFeatureItemLabel\"\n          @reorder=\"handleFeatureReorder\"\n          @toggle=\"handleFeatureToggle\"\n        />\n\n        <div class=\"grid grid-cols-1 gap-4 md:grid-cols-2\">\n          <DraggableSettingsList\n            :items=\"aspectRatios\"\n            :title=\"t('settings.menu.aspectRatio.title')\"\n            :description=\"t('settings.menu.aspectRatio.description')\"\n            :allow-add=\"true\"\n            :allow-remove=\"true\"\n            :show-toggle=\"false\"\n            :add-placeholder=\"t('settings.menu.aspectRatio.placeholder')\"\n            :validate-input=\"validateAspectRatio\"\n            @reorder=\"handleAspectRatioReorder\"\n            @add=\"handleAspectRatioAdd\"\n            @remove=\"handleAspectRatioRemove\"\n          />\n\n          <DraggableSettingsList\n            :items=\"resolutions\"\n            :title=\"t('settings.menu.resolution.title')\"\n            :description=\"t('settings.menu.resolution.description')\"\n            :allow-add=\"true\"\n            :allow-remove=\"true\"\n            :show-toggle=\"false\"\n            :add-placeholder=\"t('settings.menu.resolution.placeholder')\"\n            :validate-input=\"validateResolution\"\n            @reorder=\"handleResolutionReorder\"\n            @add=\"handleResolutionAdd\"\n            @remove=\"handleResolutionRemove\"\n          />\n        </div>\n      </div>\n\n      <div class=\"space-y-4\">\n        <div>\n          <h3 class=\"text-lg font-semibold text-foreground\">\n            {{ t('settings.floatingWindow.theme.title') }}\n          </h3>\n          <p class=\"mt-1 text-sm text-muted-foreground\">\n            {{ t('settings.floatingWindow.theme.description') }}\n          </p>\n        </div>\n\n        <Item variant=\"surface\" size=\"sm\">\n          <ItemContent>\n            <ItemTitle>\n              {{ t('settings.appearance.floatingWindowTheme.label') }}\n            </ItemTitle>\n            <ItemDescription>\n              {{ t('settings.appearance.floatingWindowTheme.description') }}\n            </ItemDescription>\n          </ItemContent>\n          <ItemActions>\n            <Select\n              :model-value=\"appSettings?.ui?.floatingWindowThemeMode || 'dark'\"\n              @update:model-value=\"(v) => updateFloatingWindowTheme(v as FloatingWindowThemeMode)\"\n            >\n              <SelectTrigger class=\"w-32\">\n                <SelectValue />\n              </SelectTrigger>\n              <SelectContent>\n                <SelectItem\n                  v-for=\"option in floatingWindowThemeOptions\"\n                  :key=\"option.value\"\n                  :value=\"option.value\"\n                >\n                  {{ option.label }}\n                </SelectItem>\n              </SelectContent>\n            </Select>\n          </ItemActions>\n        </Item>\n      </div>\n\n      <div class=\"space-y-4\">\n        <div>\n          <h3 class=\"text-lg font-semibold text-foreground\">\n            {{ t('settings.appearance.layout.title') }}\n          </h3>\n          <p class=\"mt-1 text-sm text-muted-foreground\">\n            {{ t('settings.appearance.layout.description') }}\n          </p>\n        </div>\n\n        <ItemGroup>\n          <Item v-for=\"key in layoutKeys\" :key=\"key\" variant=\"surface\" size=\"sm\">\n            <ItemContent>\n              <ItemTitle>\n                {{ t(`settings.appearance.layout.${key}.label`) }}\n              </ItemTitle>\n              <ItemDescription>\n                {{ t(`settings.appearance.layout.${key}.description`) }}\n              </ItemDescription>\n            </ItemContent>\n            <ItemActions>\n              <div class=\"flex items-center gap-2\">\n                <Input\n                  type=\"number\"\n                  :model-value=\"appSettings?.ui?.floatingWindowLayout?.[key]\"\n                  @input=\"\n                    (e: Event) =>\n                      handleLayoutChange(\n                        key as keyof FloatingWindowLayout,\n                        (e.target as HTMLInputElement).value\n                      )\n                  \"\n                  @keydown=\"handleKeyDown\"\n                  class=\"w-24\"\n                  :min=\"getLayoutMinValue(key as keyof FloatingWindowLayout)\"\n                />\n                <span class=\"text-sm text-muted-foreground\">\n                  {{ t(getLayoutUnitKey(key as keyof FloatingWindowLayout)) }}\n                </span>\n              </div>\n            </ItemActions>\n          </Item>\n        </ItemGroup>\n      </div>\n    </div>\n  </div>\n</template>\n"
  },
  {
    "path": "web/src/features/settings/components/GeneralSettingsContent.vue",
    "content": "<script setup lang=\"ts\">\nimport { useSettingsStore } from '../store'\nimport { useGeneralActions } from '../composables/useGeneralActions'\nimport { storeToRefs } from 'pinia'\nimport { RouterLink } from 'vue-router'\nimport { Switch } from '@/components/ui/switch'\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from '@/components/ui/select'\nimport { Button } from '@/components/ui/button'\nimport { Item, ItemContent, ItemTitle, ItemDescription, ItemActions } from '@/components/ui/item'\nimport ResetSettingsDialog from './ResetSettingsDialog.vue'\nimport { useI18n } from '@/composables/useI18n'\n\nconst store = useSettingsStore()\nconst { appSettings, error, isInitialized } = storeToRefs(store)\nconst {\n  updateLanguage,\n  updateLoggerLevel,\n  updateAutoCheck,\n  updateAutoUpdateOnExit,\n  resetGeneralCoreSettings,\n} = useGeneralActions()\nconst { clearError } = store\nconst { t } = useI18n()\n\nconst handleReset = async () => {\n  await resetGeneralCoreSettings()\n  // simple visual feedback handled by dialog\n}\n</script>\n\n<template>\n  <div v-if=\"!isInitialized\" class=\"flex items-center justify-center p-6\">\n    <div class=\"text-center\">\n      <div\n        class=\"mx-auto h-8 w-8 animate-spin rounded-full border-4 border-muted border-t-primary\"\n      ></div>\n      <p class=\"mt-2 text-sm text-muted-foreground\">{{ t('settings.loading') }}</p>\n    </div>\n  </div>\n\n  <div v-else-if=\"error\" class=\"flex items-center justify-center p-6\">\n    <div class=\"text-center\">\n      <p class=\"text-sm text-muted-foreground\">{{ t('settings.error.title') }}</p>\n      <p class=\"mt-1 text-sm text-red-500\">{{ error }}</p>\n      <Button variant=\"outline\" size=\"sm\" @click=\"clearError\" class=\"mt-2\">\n        {{ t('settings.error.retry') }}\n      </Button>\n    </div>\n  </div>\n\n  <div v-else class=\"w-full\">\n    <!-- Header -->\n    <div class=\"mb-6 flex items-center justify-between\">\n      <div>\n        <h1 class=\"text-2xl font-bold text-foreground\">{{ t('settings.general.title') }}</h1>\n        <p class=\"mt-1 text-muted-foreground\">{{ t('settings.general.description') }}</p>\n      </div>\n      <ResetSettingsDialog\n        :title=\"t('settings.reset.title')\"\n        :description=\"t('settings.reset.description')\"\n        @reset=\"handleReset\"\n      />\n    </div>\n\n    <div class=\"space-y-8\">\n      <!-- Language -->\n      <div class=\"space-y-4\">\n        <div>\n          <h3 class=\"text-lg font-semibold text-foreground\">\n            {{ t('settings.general.language.title') }}\n          </h3>\n          <p class=\"mt-1 text-sm text-muted-foreground\">\n            {{ t('settings.general.language.description') }}\n          </p>\n        </div>\n\n        <Item variant=\"surface\" size=\"sm\">\n          <ItemContent>\n            <ItemTitle>\n              {{ t('common.languageLabelBilingual') }}\n            </ItemTitle>\n            <ItemDescription>\n              {{ t('settings.general.language.displayLanguageDescription') }}\n            </ItemDescription>\n          </ItemContent>\n          <ItemActions>\n            <Select\n              :model-value=\"appSettings.app.language.current\"\n              @update:model-value=\"(v) => updateLanguage(v as string)\"\n            >\n              <SelectTrigger class=\"w-48\">\n                <SelectValue :placeholder=\"t('common.languageLabelBilingual')\" />\n              </SelectTrigger>\n              <SelectContent>\n                <SelectItem value=\"zh-CN\">{{ t('common.languageZhCn') }}</SelectItem>\n                <SelectItem value=\"en-US\">{{ t('common.languageEnUs') }}</SelectItem>\n              </SelectContent>\n            </Select>\n          </ItemActions>\n        </Item>\n      </div>\n\n      <!-- Logger -->\n      <div class=\"space-y-4\">\n        <div>\n          <h3 class=\"text-lg font-semibold text-foreground\">\n            {{ t('settings.general.logger.title') }}\n          </h3>\n          <p class=\"mt-1 text-sm text-muted-foreground\">\n            {{ t('settings.general.logger.description') }}\n          </p>\n        </div>\n\n        <Item variant=\"surface\" size=\"sm\">\n          <ItemContent>\n            <ItemTitle>\n              {{ t('settings.general.logger.level') }}\n            </ItemTitle>\n            <ItemDescription>\n              {{ t('settings.general.logger.levelDescription') }}\n            </ItemDescription>\n          </ItemContent>\n          <ItemActions>\n            <Select\n              :model-value=\"appSettings.app.logger.level\"\n              @update:model-value=\"(v) => updateLoggerLevel(v as string)\"\n            >\n              <SelectTrigger class=\"w-48\">\n                <SelectValue :placeholder=\"t('settings.general.logger.level')\" />\n              </SelectTrigger>\n              <SelectContent>\n                <SelectItem value=\"DEBUG\">DEBUG</SelectItem>\n                <SelectItem value=\"INFO\">INFO</SelectItem>\n                <SelectItem value=\"ERROR\">ERROR</SelectItem>\n              </SelectContent>\n            </Select>\n          </ItemActions>\n        </Item>\n      </div>\n\n      <!-- Update -->\n      <div class=\"space-y-4\">\n        <div>\n          <h3 class=\"text-lg font-semibold text-foreground\">\n            {{ t('settings.general.update.title') }}\n          </h3>\n          <p class=\"mt-1 text-sm text-muted-foreground\">\n            {{ t('settings.general.update.descriptionPrefix') }}\n            <RouterLink\n              :to=\"{ name: 'about' }\"\n              class=\"font-medium text-primary underline underline-offset-4 hover:text-primary/80\"\n            >\n              {{ t('settings.general.update.descriptionLink') }}\n            </RouterLink>\n            {{ t('settings.general.update.descriptionSuffix') }}\n          </p>\n        </div>\n\n        <Item variant=\"surface\" size=\"sm\">\n          <ItemContent>\n            <ItemTitle>\n              {{ t('settings.general.update.autoCheck.label') }}\n            </ItemTitle>\n            <ItemDescription>\n              {{ t('settings.general.update.autoCheck.description') }}\n            </ItemDescription>\n          </ItemContent>\n          <ItemActions>\n            <Switch\n              :model-value=\"appSettings.update.autoCheck\"\n              @update:model-value=\"(value) => updateAutoCheck(Boolean(value))\"\n            />\n          </ItemActions>\n        </Item>\n\n        <Item variant=\"surface\" size=\"sm\">\n          <ItemContent>\n            <ItemTitle>\n              {{ t('settings.general.update.autoUpdateOnExit.label') }}\n            </ItemTitle>\n            <ItemDescription class=\"line-clamp-none\">\n              {{ t('settings.general.update.autoUpdateOnExit.description') }}\n              <span v-if=\"!appSettings.update.autoCheck\" class=\"mt-1 block text-xs\">\n                {{ t('settings.general.update.autoUpdateOnExit.requiresAutoCheck') }}\n              </span>\n            </ItemDescription>\n          </ItemContent>\n          <ItemActions>\n            <Switch\n              :model-value=\"appSettings.update.autoUpdateOnExit\"\n              :disabled=\"!appSettings.update.autoCheck\"\n              @update:model-value=\"(value) => updateAutoUpdateOnExit(Boolean(value))\"\n            />\n          </ItemActions>\n        </Item>\n      </div>\n    </div>\n  </div>\n</template>\n"
  },
  {
    "path": "web/src/features/settings/components/HotkeyRecorder.vue",
    "content": "<script setup lang=\"ts\">\nimport { ref, watch } from 'vue'\nimport { useEventListener } from '@vueuse/core'\nimport { cn } from '@/lib/utils'\nimport { formatHotkeyDisplay, calculateModifiers } from '../utils/hotkeyUtils'\nimport { useI18n } from '@/composables/useI18n'\n\ninterface Hotkey {\n  modifiers: number\n  key: number\n}\n\nconst props = defineProps<{\n  value: Hotkey\n  className?: string\n}>()\n\nconst emit = defineEmits<{\n  (e: 'change', value: Hotkey): void\n}>()\n\nconst { t } = useI18n()\nconst isRecording = ref(false)\nconst displayText = ref('')\nconst currentModifiers = ref(0)\nconst currentKey = ref(0)\nconst recorderRef = ref<HTMLDivElement | null>(null)\nconst suppressedMouseButton = ref<number | null>(null)\nconst MODIFIER_KEYS = new Set([\n  'Control',\n  'ControlLeft',\n  'ControlRight',\n  'Alt',\n  'AltLeft',\n  'AltRight',\n  'Shift',\n  'ShiftLeft',\n  'ShiftRight',\n  'Meta',\n  'MetaLeft',\n  'MetaRight',\n  'OS',\n  'Win',\n])\n\n// 更新显示文本\nwatch(\n  [() => props.value, isRecording, t],\n  () => {\n    if (!isRecording.value) {\n      const text = formatHotkeyDisplay(props.value.modifiers, props.value.key)\n      displayText.value = text || t('settings.general.hotkey.recorder.notSet')\n    }\n  },\n  { immediate: true }\n)\n\n// 实时更新录制时的显示文本\nwatch([currentModifiers, currentKey, isRecording, t], () => {\n  if (isRecording.value) {\n    const text = formatHotkeyDisplay(currentModifiers.value, currentKey.value)\n    displayText.value = text || t('settings.general.hotkey.recorder.pressKey')\n  }\n})\n\nconst startRecording = () => {\n  isRecording.value = true\n  currentModifiers.value = 0\n  currentKey.value = 0\n  suppressedMouseButton.value = null\n}\n\nconst stopRecording = () => {\n  isRecording.value = false\n  currentModifiers.value = 0\n  currentKey.value = 0\n}\n\nconst mapMouseButtonToVirtualKey = (button: number): number => {\n  if (button === 3) return 0x05 // VK_XBUTTON1\n  if (button === 4) return 0x06 // VK_XBUTTON2\n  return 0\n}\n\nconst isModifierKey = (key: string): boolean => MODIFIER_KEYS.has(key)\n\nconst handleKeyDown = (e: KeyboardEvent) => {\n  if (!isRecording.value) return\n\n  // 阻止默认行为\n  e.preventDefault()\n  e.stopPropagation()\n\n  // 处理特殊按键\n  if (e.key === 'Escape') {\n    stopRecording()\n    return\n  }\n\n  if (e.key === 'Backspace') {\n    // 清除快捷键\n    emit('change', { modifiers: 0, key: 0 })\n    stopRecording()\n    return\n  }\n\n  // 获取修饰键状态\n  const modifiers = calculateModifiers(e.shiftKey, e.ctrlKey, e.altKey, e.metaKey)\n\n  // 如果是修饰键，只更新修饰键状态并实时显示\n  if (isModifierKey(e.key)) {\n    currentModifiers.value = modifiers\n    return\n  }\n\n  // 获取主键\n  const key = e.keyCode\n  currentKey.value = key\n  currentModifiers.value = modifiers\n\n  // 更新值\n  emit('change', { modifiers, key })\n\n  // 停止录制\n  stopRecording()\n}\n\nconst handleKeyUp = (e: KeyboardEvent) => {\n  if (!isRecording.value) return\n\n  // 当修饰键被释放时更新修饰键状态\n  if (isModifierKey(e.key)) {\n    const modifiers = calculateModifiers(\n      e.key.startsWith('Shift') ? false : e.shiftKey || false,\n      e.key.startsWith('Control') ? false : e.ctrlKey || false,\n      e.key.startsWith('Alt') ? false : e.altKey || false,\n      e.key.startsWith('Meta') || e.key.startsWith('OS') || e.key === 'Win'\n        ? false\n        : e.metaKey || false\n    )\n    currentModifiers.value = modifiers\n  }\n}\n\nconst handleClickOutside = (e: MouseEvent) => {\n  if (!isRecording.value) return\n\n  const mouseKey = mapMouseButtonToVirtualKey(e.button)\n  if (mouseKey !== 0) {\n    e.preventDefault()\n    e.stopPropagation()\n\n    const modifiers = calculateModifiers(e.shiftKey, e.ctrlKey, e.altKey, e.metaKey)\n    currentModifiers.value = modifiers\n    currentKey.value = mouseKey\n    // side-button shortcuts may trigger browser history on mouseup/auxclick;\n    // keep a one-shot guard for this button to suppress that navigation.\n    suppressedMouseButton.value = e.button\n    emit('change', { modifiers, key: mouseKey })\n    stopRecording()\n    return\n  }\n\n  if (recorderRef.value && !recorderRef.value.contains(e.target as Node)) {\n    stopRecording()\n  }\n}\n\nconst handleSideButtonNavigationGuard = (e: MouseEvent) => {\n  const mouseKey = mapMouseButtonToVirtualKey(e.button)\n  if (mouseKey === 0) return\n\n  const shouldSuppress = isRecording.value || suppressedMouseButton.value === e.button\n  if (!shouldSuppress) return\n\n  e.preventDefault()\n  e.stopPropagation()\n\n  if (suppressedMouseButton.value === e.button && (e.type === 'mouseup' || e.type === 'auxclick')) {\n    suppressedMouseButton.value = null\n  }\n}\n\nuseEventListener(window, 'keydown', handleKeyDown)\nuseEventListener(window, 'keyup', handleKeyUp)\nuseEventListener(document, 'mousedown', handleClickOutside, { capture: true })\nuseEventListener(document, 'mouseup', handleSideButtonNavigationGuard, { capture: true })\nuseEventListener(document, 'auxclick', handleSideButtonNavigationGuard, { capture: true })\n</script>\n\n<template>\n  <div ref=\"recorderRef\" :class=\"cn('w-full', className)\">\n    <div\n      :class=\"\n        cn(\n          'rounded-md border border-input bg-background px-3 py-2 text-sm',\n          'focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',\n          'cursor-pointer transition-colors',\n          isRecording\n            ? 'border-primary ring-2 ring-primary ring-offset-2'\n            : 'hover:border-accent-foreground'\n        )\n      \"\n      @click=\"startRecording\"\n      tabindex=\"0\"\n    >\n      {{\n        isRecording ? displayText || t('settings.general.hotkey.recorder.pressKey') : displayText\n      }}\n    </div>\n  </div>\n</template>\n"
  },
  {
    "path": "web/src/features/settings/components/HotkeySettingsContent.vue",
    "content": "<script setup lang=\"ts\">\nimport { useSettingsStore } from '../store'\nimport { useGeneralActions } from '../composables/useGeneralActions'\nimport { storeToRefs } from 'pinia'\nimport { Button } from '@/components/ui/button'\nimport {\n  Item,\n  ItemContent,\n  ItemTitle,\n  ItemDescription,\n  ItemActions,\n  ItemGroup,\n} from '@/components/ui/item'\nimport HotkeyRecorder from './HotkeyRecorder.vue'\nimport ResetSettingsDialog from './ResetSettingsDialog.vue'\nimport { useI18n } from '@/composables/useI18n'\n\nconst store = useSettingsStore()\nconst { appSettings, error, isInitialized } = storeToRefs(store)\nconst {\n  updateFloatingWindowHotkey,\n  updateScreenshotHotkey,\n  updateRecordingHotkey,\n  resetHotkeySettings,\n} = useGeneralActions()\nconst { clearError } = store\nconst { t } = useI18n()\n\nconst getHotkey = (type: 'floatingWindow' | 'screenshot' | 'recording') => {\n  const hotkey = appSettings.value.app.hotkey[type]\n  return {\n    modifiers: hotkey.modifiers,\n    key: hotkey.key,\n  }\n}\n\nconst handleReset = async () => {\n  await resetHotkeySettings()\n}\n</script>\n\n<template>\n  <div v-if=\"!isInitialized\" class=\"flex items-center justify-center p-6\">\n    <div class=\"text-center\">\n      <div\n        class=\"mx-auto h-8 w-8 animate-spin rounded-full border-4 border-muted border-t-primary\"\n      ></div>\n      <p class=\"mt-2 text-sm text-muted-foreground\">{{ t('settings.hotkeys.loading') }}</p>\n    </div>\n  </div>\n\n  <div v-else-if=\"error\" class=\"flex items-center justify-center p-6\">\n    <div class=\"text-center\">\n      <p class=\"text-sm text-muted-foreground\">{{ t('settings.hotkeys.error.title') }}</p>\n      <p class=\"mt-1 text-sm text-red-500\">{{ error }}</p>\n      <Button variant=\"outline\" size=\"sm\" @click=\"clearError\" class=\"mt-2\">\n        {{ t('settings.hotkeys.error.retry') }}\n      </Button>\n    </div>\n  </div>\n\n  <div v-else class=\"w-full\">\n    <div class=\"mb-6 flex items-center justify-between\">\n      <div>\n        <h1 class=\"text-2xl font-bold text-foreground\">{{ t('settings.hotkeys.title') }}</h1>\n        <p class=\"mt-1 text-muted-foreground\">{{ t('settings.hotkeys.description') }}</p>\n      </div>\n      <ResetSettingsDialog\n        :title=\"t('settings.hotkeys.reset.title')\"\n        :description=\"t('settings.hotkeys.reset.description')\"\n        @reset=\"handleReset\"\n      />\n    </div>\n\n    <div class=\"space-y-8\">\n      <div class=\"space-y-4\">\n        <div>\n          <h3 class=\"text-lg font-semibold text-foreground\">\n            {{ t('settings.general.hotkey.title') }}\n          </h3>\n          <p class=\"mt-1 text-sm text-muted-foreground\">\n            {{ t('settings.general.hotkey.description') }}\n          </p>\n        </div>\n\n        <ItemGroup>\n          <Item variant=\"surface\" size=\"sm\">\n            <ItemContent>\n              <ItemTitle>\n                {{ t('settings.general.hotkey.floatingWindow') }}\n              </ItemTitle>\n              <ItemDescription>\n                {{ t('settings.general.hotkey.floatingWindowDescription') }}\n              </ItemDescription>\n            </ItemContent>\n            <ItemActions>\n              <HotkeyRecorder\n                :value=\"getHotkey('floatingWindow')\"\n                @change=\"(v) => updateFloatingWindowHotkey(v.modifiers, v.key)\"\n                class=\"w-48\"\n              />\n            </ItemActions>\n          </Item>\n\n          <Item variant=\"surface\" size=\"sm\">\n            <ItemContent>\n              <ItemTitle>\n                {{ t('settings.general.hotkey.screenshot') }}\n              </ItemTitle>\n              <ItemDescription>\n                {{ t('settings.general.hotkey.screenshotDescription') }}\n              </ItemDescription>\n            </ItemContent>\n            <ItemActions>\n              <HotkeyRecorder\n                :value=\"getHotkey('screenshot')\"\n                @change=\"(v) => updateScreenshotHotkey(v.modifiers, v.key)\"\n                class=\"w-48\"\n              />\n            </ItemActions>\n          </Item>\n\n          <Item variant=\"surface\" size=\"sm\">\n            <ItemContent>\n              <ItemTitle>\n                {{ t('settings.general.hotkey.recording') }}\n              </ItemTitle>\n              <ItemDescription>\n                {{ t('settings.general.hotkey.recordingDescription') }}\n              </ItemDescription>\n            </ItemContent>\n            <ItemActions>\n              <HotkeyRecorder\n                :value=\"getHotkey('recording')\"\n                @change=\"(v) => updateRecordingHotkey(v.modifiers, v.key)\"\n                class=\"w-48\"\n              />\n            </ItemActions>\n          </Item>\n        </ItemGroup>\n      </div>\n    </div>\n  </div>\n</template>\n"
  },
  {
    "path": "web/src/features/settings/components/OverlayPaletteEditor.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from 'vue'\nimport { ref, watch, computed } from 'vue'\nimport { Check, Palette } from 'lucide-vue-next'\nimport { Button } from '@/components/ui/button'\nimport { Input } from '@/components/ui/input'\nimport { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'\nimport { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'\nimport { cn } from '@/lib/utils'\nimport { useI18n } from '@/composables/useI18n'\nimport type { OverlayColorMode, OverlayPalette, OverlayPalettePreset } from '../overlayPalette'\nimport {\n  buildOverlayGradient,\n  getActiveOverlayColors,\n  normalizeHexColor,\n  OVERLAY_PALETTE_PRESETS,\n} from '../overlayPalette'\nimport ColorPicker from '@/components/ui/color-picker/ColorPicker.vue'\n\nconst props = defineProps<{\n  modelValue: OverlayPalette\n  showWallpaperSampler?: boolean\n  class?: HTMLAttributes['class']\n}>()\n\nconst emits = defineEmits<{\n  (e: 'update:modelValue', value: OverlayPalette): void\n  (e: 'apply-preset', preset: OverlayPalettePreset): void\n  (e: 'sample-from-wallpaper'): void\n}>()\n\nconst { t } = useI18n()\n\nconst localPalette = ref<OverlayPalette>({\n  mode: props.modelValue.mode,\n  colors: [...props.modelValue.colors] as OverlayPalette['colors'],\n})\nconst activeColorIndex = ref(0)\nconst activeHexInput = ref(localPalette.value.colors[0] ?? '#000000')\n\nwatch(\n  () => props.modelValue,\n  (value) => {\n    localPalette.value = {\n      mode: value.mode,\n      colors: [...value.colors] as OverlayPalette['colors'],\n    }\n  },\n  { deep: true }\n)\n\nwatch(\n  () => localPalette.value.mode,\n  (mode) => {\n    if (activeColorIndex.value >= mode) {\n      activeColorIndex.value = mode - 1\n    }\n  }\n)\n\nconst modeOptions: Array<{ value: OverlayColorMode; labelKey: string }> = [\n  { value: 1, labelKey: 'settings.appearance.background.overlayPalette.mode.single' },\n  { value: 2, labelKey: 'settings.appearance.background.overlayPalette.mode.double' },\n  { value: 3, labelKey: 'settings.appearance.background.overlayPalette.mode.triple' },\n  { value: 4, labelKey: 'settings.appearance.background.overlayPalette.mode.quad' },\n]\n\nconst activeColors = computed(() => getActiveOverlayColors(localPalette.value))\nconst currentColor = computed(() => activeColors.value[activeColorIndex.value] ?? '#000000')\nconst modeLabel = computed(() => {\n  const found = modeOptions.find((option) => option.value === localPalette.value.mode)\n  return found ? t(found.labelKey) : ''\n})\n\nwatch(\n  currentColor,\n  (value) => {\n    activeHexInput.value = value\n  },\n  { immediate: true }\n)\n\nconst triggerPreviewStyle = computed(() => ({\n  backgroundImage: buildOverlayGradient(localPalette.value),\n}))\n\nconst setPalette = (nextPalette: OverlayPalette, emitUpdate = true) => {\n  localPalette.value = {\n    mode: nextPalette.mode,\n    colors: [...nextPalette.colors] as OverlayPalette['colors'],\n  }\n  if (emitUpdate) {\n    emits('update:modelValue', localPalette.value)\n  }\n}\n\nconst updateMode = (mode: OverlayColorMode) => {\n  setPalette({\n    mode,\n    colors: [...localPalette.value.colors] as OverlayPalette['colors'],\n  })\n}\n\nconst updateColor = (index: number, color: string) => {\n  const normalizedColor = normalizeHexColor(color, currentColor.value)\n  const nextColors = [...localPalette.value.colors] as OverlayPalette['colors']\n  nextColors[index] = normalizedColor\n  setPalette({\n    mode: localPalette.value.mode,\n    colors: nextColors,\n  })\n}\n\nconst handleModeChange = (value: unknown) => {\n  if (value === null || value === undefined || Array.isArray(value)) return\n  if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'bigint') return\n\n  const mode = Number(value) as OverlayColorMode\n  if (mode < 1 || mode > 4) return\n  updateMode(mode)\n}\n\nconst handleHexCommit = () => {\n  const normalized = normalizeHexColor(activeHexInput.value, currentColor.value)\n  activeHexInput.value = normalized\n  updateColor(activeColorIndex.value, normalized)\n}\n\nconst applyPreset = (preset: OverlayPalettePreset) => {\n  activeColorIndex.value = 0\n  setPalette(\n    {\n      mode: preset.mode,\n      colors: [...preset.colors] as OverlayPalette['colors'],\n    },\n    false\n  )\n  emits('apply-preset', preset)\n}\n\nconst isPresetActive = (preset: OverlayPalette): boolean => {\n  if (preset.mode !== localPalette.value.mode) return false\n  return preset.colors.every((color, index) => color === localPalette.value.colors[index])\n}\n\nconst getPreviewStyle = (palette: OverlayPalette) => ({\n  backgroundImage: buildOverlayGradient(palette),\n})\n\nconst handleSampleFromWallpaper = () => {\n  emits('sample-from-wallpaper')\n}\n</script>\n\n<template>\n  <Popover>\n    <PopoverTrigger as-child>\n      <Button\n        variant=\"outline\"\n        size=\"sm\"\n        :class=\"\n          cn(\n            'h-9 w-[13rem] justify-start gap-2 px-2',\n            'border-border/80 bg-transparent hover:bg-accent',\n            props.class\n          )\n        \"\n      >\n        <div\n          class=\"h-5 w-14 shrink-0 rounded-sm border border-border/70\"\n          :style=\"triggerPreviewStyle\"\n        />\n        <span class=\"text-xs text-muted-foreground\">{{ modeLabel }}</span>\n        <Palette class=\"ml-auto h-4 w-4 text-muted-foreground\" />\n      </Button>\n    </PopoverTrigger>\n\n    <PopoverContent align=\"end\" class=\"w-[21rem] space-y-4\">\n      <div class=\"space-y-2\">\n        <div class=\"flex items-center justify-between gap-2\">\n          <p class=\"text-xs font-medium text-foreground\">\n            {{ t('settings.appearance.background.overlayPalette.mode.title') }}\n          </p>\n          <Button\n            v-if=\"props.showWallpaperSampler\"\n            type=\"button\"\n            variant=\"outline\"\n            size=\"sm\"\n            class=\"h-7 px-2 text-xs\"\n            @click=\"handleSampleFromWallpaper\"\n          >\n            {{ t('settings.appearance.background.overlayPalette.sampleFromWallpaper') }}\n          </Button>\n        </div>\n        <ToggleGroup\n          type=\"single\"\n          variant=\"outline\"\n          size=\"sm\"\n          :model-value=\"String(localPalette.mode)\"\n          @update:model-value=\"(value) => handleModeChange(value)\"\n        >\n          <ToggleGroupItem\n            v-for=\"option in modeOptions\"\n            :key=\"option.value\"\n            :value=\"String(option.value)\"\n            class=\"px-2 text-xs\"\n          >\n            {{ t(option.labelKey) }}\n          </ToggleGroupItem>\n        </ToggleGroup>\n      </div>\n\n      <div class=\"space-y-2\">\n        <div\n          class=\"h-16 rounded-md border border-border/70\"\n          :style=\"{ backgroundImage: buildOverlayGradient(localPalette) }\"\n        />\n\n        <div class=\"flex items-center gap-2\">\n          <button\n            v-for=\"(color, index) in activeColors\"\n            :key=\"`${index}-${color}`\"\n            type=\"button\"\n            :class=\"\n              cn(\n                'relative h-7 w-7 rounded-full border border-border/80 transition-all',\n                activeColorIndex === index && 'ring-2 ring-primary/70 ring-offset-1'\n              )\n            \"\n            :style=\"{ backgroundColor: color }\"\n            @click=\"activeColorIndex = index\"\n          />\n\n          <!-- Custom Color Picker Popover Triggered by the main color block -->\n          <Popover>\n            <PopoverTrigger as-child>\n              <button\n                type=\"button\"\n                class=\"relative h-9 w-11 shrink-0 cursor-pointer overflow-hidden rounded-md border border-input focus:ring-2 focus:ring-primary/50 focus:outline-none\"\n              >\n                <div class=\"h-full w-full\" :style=\"{ backgroundColor: currentColor }\" />\n              </button>\n            </PopoverTrigger>\n            <PopoverContent align=\"center\" :sideOffset=\"8\" class=\"w-auto p-3\">\n              <ColorPicker\n                :model-value=\"currentColor\"\n                :show-hex-input=\"false\"\n                @update:model-value=\"(color) => updateColor(activeColorIndex, color)\"\n              />\n            </PopoverContent>\n          </Popover>\n\n          <Input\n            v-model=\"activeHexInput\"\n            class=\"font-mono uppercase\"\n            placeholder=\"#000000\"\n            @keydown.enter.prevent=\"handleHexCommit\"\n            @blur=\"handleHexCommit\"\n          />\n        </div>\n      </div>\n\n      <div class=\"space-y-2\">\n        <p class=\"text-xs font-medium text-foreground\">\n          {{ t('settings.appearance.background.overlayPalette.presets.title') }}\n        </p>\n        <div class=\"grid grid-cols-4 gap-2\">\n          <button\n            v-for=\"preset in OVERLAY_PALETTE_PRESETS\"\n            :key=\"preset.id\"\n            type=\"button\"\n            :class=\"\n              cn(\n                'group relative h-8 overflow-hidden rounded-md border border-border/70 transition-colors',\n                'hover:border-primary/50',\n                isPresetActive(preset) && 'border-primary'\n              )\n            \"\n            :style=\"getPreviewStyle(preset)\"\n            @click=\"applyPreset(preset)\"\n          >\n            <span\n              class=\"absolute top-1 left-1 rounded bg-black/35 px-1 py-0.5 text-[10px] leading-none text-white\"\n            >\n              {{ preset.mode }}C\n            </span>\n            <Check\n              v-if=\"isPresetActive(preset)\"\n              class=\"absolute right-1 bottom-1 h-3.5 w-3.5 text-white drop-shadow-[0_1px_1px_rgba(0,0,0,0.45)]\"\n            />\n          </button>\n        </div>\n      </div>\n    </PopoverContent>\n  </Popover>\n</template>\n"
  },
  {
    "path": "web/src/features/settings/components/ResetSettingsDialog.vue",
    "content": "<script setup lang=\"ts\">\nimport { ref } from 'vue'\nimport {\n  AlertDialog,\n  AlertDialogAction,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n  AlertDialogTrigger,\n} from '@/components/ui/alert-dialog'\nimport { Button } from '@/components/ui/button'\nimport { RotateCcw } from 'lucide-vue-next'\nimport { useI18n } from '@/composables/useI18n'\n\ninterface Props {\n  title: string\n  description: string\n  triggerText?: string\n  confirmText?: string\n  cancelText?: string\n}\n\ndefineProps<Props>()\nconst emit = defineEmits<{\n  (e: 'reset'): Promise<void>\n}>()\n\nconst { t } = useI18n()\nconst isResetting = ref(false)\nconst open = ref(false)\n\nconst handleReset = async (e: Event) => {\n  e.preventDefault() // 阻止默认关闭行为，等待异步操作\n  isResetting.value = true\n  try {\n    await emit('reset')\n    open.value = false // 手动关闭\n  } finally {\n    isResetting.value = false\n  }\n}\n</script>\n\n<template>\n  <AlertDialog :open=\"open\" @update:open=\"open = $event\">\n    <AlertDialogTrigger as-child>\n      <slot name=\"trigger\">\n        <Button variant=\"outline\" size=\"sm\" class=\"surface-top shrink-0 hover:bg-accent\">\n          <RotateCcw class=\"mr-2 h-4 w-4\" />\n          {{ triggerText || t('settings.reset.dialog.triggerText') }}\n        </Button>\n      </slot>\n    </AlertDialogTrigger>\n    <AlertDialogContent>\n      <AlertDialogHeader>\n        <AlertDialogTitle>{{ title }}</AlertDialogTitle>\n        <AlertDialogDescription>{{ description }}</AlertDialogDescription>\n      </AlertDialogHeader>\n      <AlertDialogFooter>\n        <AlertDialogCancel>\n          {{ cancelText || t('settings.reset.dialog.cancelText') }}\n        </AlertDialogCancel>\n        <!-- 使用 .prevent 防止自动关闭 -->\n        <AlertDialogAction @click.prevent=\"handleReset\" :disabled=\"isResetting\">\n          {{\n            isResetting\n              ? t('settings.reset.dialog.resetting')\n              : confirmText || t('settings.reset.dialog.confirmText')\n          }}\n        </AlertDialogAction>\n      </AlertDialogFooter>\n    </AlertDialogContent>\n  </AlertDialog>\n</template>\n"
  },
  {
    "path": "web/src/features/settings/components/SettingsSidebar.vue",
    "content": "<script setup lang=\"ts\">\nimport { cn } from '@/lib/utils'\nimport { Settings, Keyboard, Camera, Blocks, Monitor, Menu, Palette } from 'lucide-vue-next'\nimport { useI18n } from '@/composables/useI18n'\n\nexport type SettingsPageKey =\n  | 'general'\n  | 'hotkeys'\n  | 'capture'\n  | 'extensions'\n  | 'windowScene'\n  | 'floatingWindow'\n  | 'webAppearance'\n\ninterface SettingsMenuItem {\n  key: SettingsPageKey\n  label: string\n  icon: any\n  description: string\n}\n\ndefineProps<{\n  activePage: SettingsPageKey\n}>()\n\nconst emit = defineEmits<{\n  (e: 'update:activePage', page: SettingsPageKey): void\n}>()\n\nconst { t } = useI18n()\n\nconst settingsMenus: SettingsMenuItem[] = [\n  {\n    key: 'general',\n    label: 'settings.layout.general.title',\n    icon: Settings,\n    description: 'settings.layout.general.description',\n  },\n  {\n    key: 'hotkeys',\n    label: 'settings.layout.hotkeys.title',\n    icon: Keyboard,\n    description: 'settings.layout.hotkeys.description',\n  },\n  {\n    key: 'capture',\n    label: 'settings.layout.capture.title',\n    icon: Camera,\n    description: 'settings.layout.capture.description',\n  },\n  {\n    key: 'extensions',\n    label: 'settings.layout.extensions.title',\n    icon: Blocks,\n    description: 'settings.layout.extensions.description',\n  },\n  {\n    key: 'windowScene',\n    label: 'settings.layout.windowScene.title',\n    icon: Monitor,\n    description: 'settings.layout.windowScene.description',\n  },\n  {\n    key: 'floatingWindow',\n    label: 'settings.layout.floatingWindow.title',\n    icon: Menu,\n    description: 'settings.layout.floatingWindow.description',\n  },\n  {\n    key: 'webAppearance',\n    label: 'settings.layout.webAppearance.title',\n    icon: Palette,\n    description: 'settings.layout.webAppearance.description',\n  },\n]\n\nconst handleMenuClick = (key: SettingsPageKey) => {\n  emit('update:activePage', key)\n}\n</script>\n\n<template>\n  <div class=\"flex h-full w-48 flex-col lg:w-56 2xl:w-64\">\n    <div class=\"h-full p-4\">\n      <nav class=\"flex-1\">\n        <div class=\"space-y-1\">\n          <div v-for=\"item in settingsMenus\" :key=\"item.key\" class=\"group\">\n            <button\n              @click=\"handleMenuClick(item.key)\"\n              :class=\"\n                cn(\n                  'flex w-full items-center space-x-3 rounded-md px-4 py-3 transition-colors duration-200 ease-out',\n                  'text-left focus-visible:ring-2 focus-visible:ring-sidebar-ring focus-visible:ring-offset-2 focus-visible:outline-none',\n                  activePage === item.key\n                    ? 'bg-sidebar-accent font-medium text-primary hover:text-primary [&>svg]:text-primary'\n                    : 'text-sidebar-foreground hover:bg-sidebar-hover hover:text-sidebar-accent-foreground'\n                )\n              \"\n              :title=\"t(item.description)\"\n            >\n              <component\n                :is=\"item.icon\"\n                class=\"h-5 w-5 flex-shrink-0 transition-colors duration-200 ease-out\"\n                stroke-width=\"1.8\"\n              />\n              <div class=\"min-w-0 flex-1\">\n                <div class=\"text-sm font-medium\">{{ t(item.label) }}</div>\n              </div>\n            </button>\n          </div>\n        </div>\n      </nav>\n    </div>\n  </div>\n</template>\n"
  },
  {
    "path": "web/src/features/settings/components/WindowSceneContent.vue",
    "content": "<script setup lang=\"ts\">\nimport { ref, watch } from 'vue'\nimport { useSettingsStore } from '../store'\nimport { useFunctionActions } from '../composables/useFunctionActions'\nimport { storeToRefs } from 'pinia'\nimport WindowTitlePickerButton from '@/components/WindowTitlePickerButton.vue'\nimport { Button } from '@/components/ui/button'\nimport { Input } from '@/components/ui/input'\nimport { Switch } from '@/components/ui/switch'\nimport { Item, ItemContent, ItemTitle, ItemDescription, ItemActions } from '@/components/ui/item'\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from '@/components/ui/select'\nimport { useI18n } from '@/composables/useI18n'\nimport ResetSettingsDialog from './ResetSettingsDialog.vue'\n\nconst store = useSettingsStore()\nconst { appSettings, error, isInitialized } = storeToRefs(store)\nconst {\n  updateWindowTitle,\n  updateWindowCenterLockCursor,\n  updateWindowLayeredCaptureWorkaround,\n  updateWindowResetResolution,\n  resetWindowSceneSettings,\n} = useFunctionActions()\nconst { clearError } = store\nconst { t } = useI18n()\n\ntype ResetResolutionMode = 'screen' | 'custom'\n\nconst inputTitle = ref(appSettings.value?.window?.targetTitle || '')\nconst isEditingTitle = ref(false)\nconst resetResolutionMode = ref<ResetResolutionMode>('screen')\nconst inputResetWidth = ref(appSettings.value?.window?.resetResolution?.width || 0)\nconst inputResetHeight = ref(appSettings.value?.window?.resetResolution?.height || 0)\n\nwatch(\n  () => appSettings.value?.window?.targetTitle,\n  (newTitle) => {\n    if (!isEditingTitle.value) {\n      inputTitle.value = newTitle || ''\n    }\n  },\n  { immediate: true }\n)\n\nwatch(\n  () => appSettings.value?.window?.resetResolution,\n  (newResetResolution) => {\n    const width = newResetResolution?.width ?? 0\n    const height = newResetResolution?.height ?? 0\n    inputResetWidth.value = width\n    inputResetHeight.value = height\n    resetResolutionMode.value = width > 0 && height > 0 ? 'custom' : 'screen'\n  },\n  { immediate: true, deep: true }\n)\n\nconst handleTitleChange = async () => {\n  const nextTitle = inputTitle.value.trim() === '' ? '' : inputTitle.value\n\n  try {\n    await updateWindowTitle(nextTitle)\n  } catch (error) {\n    console.error('Failed to update window title:', error)\n  }\n}\n\nconst handleTitlePicked = async (title: string) => {\n  inputTitle.value = title\n  isEditingTitle.value = false\n\n  try {\n    await updateWindowTitle(title)\n  } catch (error) {\n    console.error('Failed to update window title:', error)\n  }\n}\n\nconst handleResetResolutionModeChange = async (value: string) => {\n  const mode = value as ResetResolutionMode\n  resetResolutionMode.value = mode\n\n  try {\n    if (mode === 'screen') {\n      inputResetWidth.value = 0\n      inputResetHeight.value = 0\n      await updateWindowResetResolution(0, 0)\n      return\n    }\n\n    const width = inputResetWidth.value > 0 ? inputResetWidth.value : 1920\n    const height = inputResetHeight.value > 0 ? inputResetHeight.value : 1080\n    inputResetWidth.value = width\n    inputResetHeight.value = height\n    await updateWindowResetResolution(width, height)\n  } catch (error) {\n    console.error('Failed to update reset resolution mode:', error)\n  }\n}\n\nconst handleResetResolutionChange = async () => {\n  const width = Number(inputResetWidth.value)\n  const height = Number(inputResetHeight.value)\n  if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {\n    return\n  }\n\n  try {\n    await updateWindowResetResolution(Math.trunc(width), Math.trunc(height))\n  } catch (error) {\n    console.error('Failed to update reset resolution:', error)\n  }\n}\n\nconst handleResetSettings = async () => {\n  await resetWindowSceneSettings()\n  inputTitle.value = appSettings.value?.window?.targetTitle || ''\n}\n</script>\n\n<template>\n  <div v-if=\"!isInitialized\" class=\"flex items-center justify-center p-6\">\n    <div class=\"text-center\">\n      <div\n        class=\"mx-auto h-8 w-8 animate-spin rounded-full border-4 border-muted border-t-primary\"\n      ></div>\n      <p class=\"mt-2 text-sm text-muted-foreground\">{{ t('settings.windowScene.loading') }}</p>\n    </div>\n  </div>\n\n  <div v-else-if=\"error\" class=\"flex items-center justify-center p-6\">\n    <div class=\"text-center\">\n      <p class=\"text-sm text-muted-foreground\">{{ t('settings.windowScene.error.title') }}</p>\n      <p class=\"mt-1 text-sm text-red-500\">{{ error }}</p>\n      <Button variant=\"outline\" size=\"sm\" @click=\"clearError\" class=\"mt-2\">\n        {{ t('settings.windowScene.error.retry') }}\n      </Button>\n    </div>\n  </div>\n\n  <div v-else class=\"w-full\">\n    <div class=\"mb-6 flex items-center justify-between\">\n      <div>\n        <h1 class=\"text-2xl font-bold text-foreground\">{{ t('settings.windowScene.title') }}</h1>\n        <p class=\"mt-1 text-muted-foreground\">{{ t('settings.windowScene.description') }}</p>\n      </div>\n      <ResetSettingsDialog\n        :title=\"t('settings.windowScene.reset.title')\"\n        :description=\"t('settings.windowScene.reset.description')\"\n        @reset=\"handleResetSettings\"\n      />\n    </div>\n\n    <div class=\"space-y-8\">\n      <div class=\"space-y-4\">\n        <div>\n          <h3 class=\"text-lg font-semibold text-foreground\">\n            {{ t('settings.function.windowControl.title') }}\n          </h3>\n          <p class=\"mt-1 text-sm text-muted-foreground\">\n            {{ t('settings.function.windowControl.description') }}\n          </p>\n        </div>\n\n        <Item variant=\"surface\" size=\"sm\">\n          <ItemContent>\n            <ItemTitle>\n              {{ t('settings.function.windowControl.windowTitle.label') }}\n            </ItemTitle>\n            <ItemDescription>\n              {{ t('settings.function.windowControl.windowTitle.description') }}\n            </ItemDescription>\n          </ItemContent>\n          <ItemActions>\n            <div class=\"flex items-center gap-2\">\n              <Input\n                v-model=\"inputTitle\"\n                @focus=\"isEditingTitle = true\"\n                @keydown.enter=\"handleTitleChange\"\n                @blur=\"\n                  () => {\n                    isEditingTitle = false\n                    handleTitleChange()\n                  }\n                \"\n                :placeholder=\"t('settings.function.windowControl.windowTitle.placeholder')\"\n                class=\"w-48\"\n              />\n              <WindowTitlePickerButton @select=\"handleTitlePicked\" />\n            </div>\n          </ItemActions>\n        </Item>\n\n        <Item variant=\"surface\" size=\"sm\">\n          <ItemContent>\n            <ItemTitle>\n              {{ t('settings.function.windowControl.centerLockCursor.label') }}\n            </ItemTitle>\n            <ItemDescription>\n              {{ t('settings.function.windowControl.centerLockCursor.description') }}\n            </ItemDescription>\n          </ItemContent>\n          <ItemActions>\n            <Switch\n              :model-value=\"appSettings.window.centerLockCursor\"\n              @update:model-value=\"(value) => updateWindowCenterLockCursor(Boolean(value))\"\n            />\n          </ItemActions>\n        </Item>\n\n        <Item variant=\"surface\" size=\"sm\">\n          <ItemContent>\n            <ItemTitle>\n              {{ t('settings.function.windowControl.layeredCaptureWorkaround.label') }}\n            </ItemTitle>\n            <ItemDescription>\n              {{ t('settings.function.windowControl.layeredCaptureWorkaround.description') }}\n            </ItemDescription>\n          </ItemContent>\n          <ItemActions>\n            <Switch\n              :model-value=\"appSettings.window.enableLayeredCaptureWorkaround\"\n              @update:model-value=\"(value) => updateWindowLayeredCaptureWorkaround(Boolean(value))\"\n            />\n          </ItemActions>\n        </Item>\n\n        <Item variant=\"surface\" size=\"sm\">\n          <ItemContent>\n            <ItemTitle>\n              {{ t('settings.function.windowControl.resetResolution.mode.label') }}\n            </ItemTitle>\n            <ItemDescription>\n              {{ t('settings.function.windowControl.resetResolution.mode.description') }}\n            </ItemDescription>\n          </ItemContent>\n          <ItemActions>\n            <Select\n              :model-value=\"resetResolutionMode\"\n              @update:model-value=\"(value) => handleResetResolutionModeChange(String(value))\"\n            >\n              <SelectTrigger class=\"w-36\">\n                <SelectValue />\n              </SelectTrigger>\n              <SelectContent>\n                <SelectItem value=\"screen\">\n                  {{ t('settings.function.windowControl.resetResolution.mode.screen') }}\n                </SelectItem>\n                <SelectItem value=\"custom\">\n                  {{ t('settings.function.windowControl.resetResolution.mode.custom') }}\n                </SelectItem>\n              </SelectContent>\n            </Select>\n          </ItemActions>\n        </Item>\n\n        <Item v-if=\"resetResolutionMode === 'custom'\" variant=\"surface\" size=\"sm\">\n          <ItemContent>\n            <ItemTitle>\n              {{ t('settings.function.windowControl.resetResolution.width.label') }}\n            </ItemTitle>\n            <ItemDescription>\n              {{ t('settings.function.windowControl.resetResolution.width.description') }}\n            </ItemDescription>\n          </ItemContent>\n          <ItemActions>\n            <Input\n              v-model.number=\"inputResetWidth\"\n              type=\"number\"\n              :min=\"1\"\n              class=\"w-24\"\n              @blur=\"handleResetResolutionChange\"\n              @keydown.enter=\"handleResetResolutionChange\"\n            />\n            <span class=\"text-sm text-muted-foreground\">px</span>\n          </ItemActions>\n        </Item>\n\n        <Item v-if=\"resetResolutionMode === 'custom'\" variant=\"surface\" size=\"sm\">\n          <ItemContent>\n            <ItemTitle>\n              {{ t('settings.function.windowControl.resetResolution.height.label') }}\n            </ItemTitle>\n            <ItemDescription>\n              {{ t('settings.function.windowControl.resetResolution.height.description') }}\n            </ItemDescription>\n          </ItemContent>\n          <ItemActions>\n            <Input\n              v-model.number=\"inputResetHeight\"\n              type=\"number\"\n              :min=\"1\"\n              class=\"w-24\"\n              @blur=\"handleResetResolutionChange\"\n              @keydown.enter=\"handleResetResolutionChange\"\n            />\n            <span class=\"text-sm text-muted-foreground\">px</span>\n          </ItemActions>\n        </Item>\n      </div>\n    </div>\n  </div>\n</template>\n"
  },
  {
    "path": "web/src/features/settings/composables/useAppearanceActions.ts",
    "content": "import { useSettingsStore } from '../store'\nimport {\n  DEFAULT_APP_SETTINGS,\n  DARK_FLOATING_WINDOW_COLORS,\n  LIGHT_FLOATING_WINDOW_COLORS,\n} from '../types'\nimport type {\n  FloatingWindowLayout,\n  FloatingWindowColors,\n  FloatingWindowThemeMode,\n  WebBackgroundSettings,\n} from '../types'\nimport {\n  selectBackgroundImage,\n  importBackgroundImage,\n  removeBackgroundImageResource,\n  analyzeBackgroundImage,\n} from '../api'\nimport type { OverlayPalette, OverlayPalettePreset } from '../overlayPalette'\nimport { getOverlayPaletteFromBackground, toBackgroundOverlayPatch } from '../overlayPalette'\nimport { storeToRefs } from 'pinia'\n\nexport const useAppearanceActions = () => {\n  const store = useSettingsStore()\n  const { appSettings } = storeToRefs(store)\n\n  const updateFloatingWindowLayout = async (layout: FloatingWindowLayout) => {\n    await store.updateSettings({\n      ...appSettings.value,\n      ui: {\n        ...appSettings.value.ui,\n        floatingWindowLayout: layout,\n      },\n    })\n  }\n\n  const resetAppearanceSettings = async () => {\n    await store.updateSettings({\n      ...appSettings.value,\n      ui: {\n        ...appSettings.value.ui,\n        floatingWindowLayout: DEFAULT_APP_SETTINGS.ui.floatingWindowLayout,\n        floatingWindowColors: DEFAULT_APP_SETTINGS.ui.floatingWindowColors,\n        floatingWindowThemeMode: DEFAULT_APP_SETTINGS.ui.floatingWindowThemeMode,\n        webTheme: DEFAULT_APP_SETTINGS.ui.webTheme,\n        webviewWindow: {\n          ...appSettings.value.ui.webviewWindow,\n          enableTransparentBackground:\n            DEFAULT_APP_SETTINGS.ui.webviewWindow.enableTransparentBackground,\n        },\n        background: DEFAULT_APP_SETTINGS.ui.background,\n      },\n    })\n  }\n\n  const resetWebAppearanceSettings = async () => {\n    await store.updateSettings({\n      ...appSettings.value,\n      ui: {\n        ...appSettings.value.ui,\n        webTheme: DEFAULT_APP_SETTINGS.ui.webTheme,\n        webviewWindow: {\n          ...appSettings.value.ui.webviewWindow,\n          enableTransparentBackground:\n            DEFAULT_APP_SETTINGS.ui.webviewWindow.enableTransparentBackground,\n        },\n        background: DEFAULT_APP_SETTINGS.ui.background,\n      },\n    })\n  }\n\n  const resetFloatingWindowSettings = async () => {\n    await store.updateSettings({\n      ...appSettings.value,\n      ui: {\n        ...appSettings.value.ui,\n        floatingWindowLayout: DEFAULT_APP_SETTINGS.ui.floatingWindowLayout,\n        floatingWindowColors: DEFAULT_APP_SETTINGS.ui.floatingWindowColors,\n        floatingWindowThemeMode: DEFAULT_APP_SETTINGS.ui.floatingWindowThemeMode,\n      },\n    })\n  }\n\n  const getFloatingWindowColorsByTheme = (\n    themeMode: FloatingWindowThemeMode\n  ): FloatingWindowColors => {\n    switch (themeMode) {\n      case 'light':\n        return LIGHT_FLOATING_WINDOW_COLORS\n      case 'dark':\n        return DARK_FLOATING_WINDOW_COLORS\n      default:\n        return DARK_FLOATING_WINDOW_COLORS\n    }\n  }\n\n  const updateFloatingWindowTheme = async (themeMode: FloatingWindowThemeMode) => {\n    const colors = getFloatingWindowColorsByTheme(themeMode)\n    await store.updateSettings({\n      ...appSettings.value,\n      ui: {\n        ...appSettings.value.ui,\n        floatingWindowThemeMode: themeMode,\n        floatingWindowColors: colors,\n      },\n    })\n  }\n\n  const updateFloatingWindowColors = async (colors: FloatingWindowColors) => {\n    await store.updateSettings({\n      ...appSettings.value,\n      ui: {\n        ...appSettings.value.ui,\n        floatingWindowColors: colors,\n      },\n    })\n  }\n\n  const updateBackgroundSettings = async (partialBackground: Partial<WebBackgroundSettings>) => {\n    await store.updateSettings({\n      ...appSettings.value,\n      ui: {\n        ...appSettings.value.ui,\n        background: {\n          ...appSettings.value.ui.background,\n          ...partialBackground,\n        },\n      },\n    })\n  }\n\n  const updateSurfaceOpacity = async (surfaceOpacity: number) => {\n    await updateBackgroundSettings({ surfaceOpacity })\n  }\n\n  const updateBackgroundOpacity = async (backgroundOpacity: number) => {\n    await updateBackgroundSettings({ backgroundOpacity })\n  }\n\n  const updateBackgroundBlur = async (backgroundBlurAmount: number) => {\n    await updateBackgroundSettings({ backgroundBlurAmount })\n  }\n\n  const updateOverlayOpacity = async (overlayOpacity: number) => {\n    await updateBackgroundSettings({ overlayOpacity })\n  }\n\n  const updatePrimaryColor = async (primaryColor: string) => {\n    await updateBackgroundSettings({ primaryColor })\n  }\n\n  const updateOverlayPalette = async (palette: OverlayPalette) => {\n    await updateBackgroundSettings(toBackgroundOverlayPatch(palette))\n  }\n\n  const applyOverlayPalettePreset = async (preset: OverlayPalettePreset) => {\n    await store.updateSettings({\n      ...appSettings.value,\n      ui: {\n        ...appSettings.value.ui,\n        webTheme: {\n          ...appSettings.value.ui.webTheme,\n          mode: preset.themeMode,\n        },\n        background: {\n          ...appSettings.value.ui.background,\n          ...toBackgroundOverlayPatch(preset),\n        },\n      },\n    })\n  }\n\n  const updateWebViewTransparentBackground = async (enableTransparentBackground: boolean) => {\n    await store.updateSettings({\n      ...appSettings.value,\n      ui: {\n        ...appSettings.value.ui,\n        webviewWindow: {\n          ...appSettings.value.ui.webviewWindow,\n          enableTransparentBackground,\n        },\n      },\n    })\n  }\n\n  const updateCustomCss = async (customCss: string) => {\n    await store.updateSettings({\n      ...appSettings.value,\n      ui: {\n        ...appSettings.value.ui,\n        webTheme: {\n          ...appSettings.value.ui.webTheme,\n          customCss,\n        },\n      },\n    })\n  }\n\n  const applyWallpaperAnalysis = async (imageFileName: string, persistImage = false) => {\n    const currentBackground = appSettings.value.ui.background\n    const overlayMode = getOverlayPaletteFromBackground(currentBackground).mode\n    const analysis = await analyzeBackgroundImage(imageFileName, overlayMode)\n\n    const nextBackground = {\n      ...currentBackground,\n      ...(persistImage ? { type: 'image' as const, imageFileName } : {}),\n      overlayColors: analysis.overlayColors.slice(0, overlayMode),\n      primaryColor: analysis.primaryColor,\n    }\n\n    await store.updateSettings({\n      ...appSettings.value,\n      ui: {\n        ...appSettings.value.ui,\n        webTheme: {\n          ...appSettings.value.ui.webTheme,\n          mode: analysis.themeMode,\n        },\n        background: nextBackground,\n      },\n    })\n  }\n\n  const handleBackgroundImageSelect = async () => {\n    try {\n      const previousImageFileName = appSettings.value.ui.background.imageFileName\n      const imagePath = await selectBackgroundImage()\n      if (imagePath) {\n        const imageFileName = await importBackgroundImage(imagePath)\n        try {\n          await applyWallpaperAnalysis(imageFileName, true)\n        } catch (error) {\n          console.warn('分析背景图片失败，使用基础背景设置:', error)\n          await updateBackgroundSettings({\n            type: 'image',\n            imageFileName,\n          })\n        }\n        if (previousImageFileName && previousImageFileName !== imageFileName) {\n          void removeBackgroundImageResource(previousImageFileName)\n        }\n      }\n    } catch (error) {\n      console.error('设置背景图片失败:', error)\n      throw error\n    }\n  }\n\n  const handleBackgroundImageRemove = async () => {\n    try {\n      const previousImageFileName = appSettings.value.ui.background.imageFileName\n      await updateBackgroundSettings({\n        type: 'none',\n        imageFileName: '',\n      })\n      if (previousImageFileName) {\n        void removeBackgroundImageResource(previousImageFileName)\n      }\n    } catch (error) {\n      console.error('移除背景图片失败:', error)\n      throw error\n    }\n  }\n\n  return {\n    updateFloatingWindowLayout,\n    resetAppearanceSettings,\n    resetWebAppearanceSettings,\n    resetFloatingWindowSettings,\n    updateFloatingWindowColors,\n    updateBackgroundOpacity,\n    updateBackgroundBlur,\n    updateOverlayOpacity,\n    updatePrimaryColor,\n    updateOverlayPalette,\n    applyOverlayPalettePreset,\n    updateWebViewTransparentBackground,\n    updateCustomCss,\n    updateSurfaceOpacity,\n    handleBackgroundImageSelect,\n    handleBackgroundImageRemove,\n    applyWallpaperAnalysis,\n    getFloatingWindowColorsByTheme,\n    updateFloatingWindowTheme,\n  }\n}\n"
  },
  {
    "path": "web/src/features/settings/composables/useExtensionActions.ts",
    "content": "import { storeToRefs } from 'pinia'\nimport { useSettingsStore } from '../store'\nimport type { AppSettings } from '../types'\n\nconst DEFAULT_INFINITY_NIKKI_SETTINGS: AppSettings['extensions']['infinityNikki'] = {\n  enable: false,\n  gameDir: '',\n  galleryGuideSeen: false,\n  allowOnlinePhotoMetadataExtract: false,\n  manageScreenshotHardlinks: false,\n}\n\nexport const useExtensionActions = () => {\n  const store = useSettingsStore()\n  const { appSettings } = storeToRefs(store)\n\n  const updateInfinityNikkiSettings = async (\n    patch: Partial<AppSettings['extensions']['infinityNikki']>\n  ) => {\n    await store.updateSettings({\n      extensions: {\n        infinityNikki: {\n          ...appSettings.value.extensions.infinityNikki,\n          ...patch,\n        },\n      },\n    })\n  }\n\n  const updateInfinityNikkiEnabled = async (enable: boolean) => {\n    await updateInfinityNikkiSettings({ enable })\n  }\n\n  const updateInfinityNikkiGameDir = async (gameDir: string) => {\n    await updateInfinityNikkiSettings({ gameDir })\n  }\n\n  const updateInfinityNikkiAllowOnlinePhotoMetadataExtract = async (enabled: boolean) => {\n    await updateInfinityNikkiSettings({ allowOnlinePhotoMetadataExtract: enabled })\n  }\n\n  const updateInfinityNikkiManageScreenshotHardlinks = async (enabled: boolean) => {\n    await updateInfinityNikkiSettings({ manageScreenshotHardlinks: enabled })\n  }\n\n  const completeInfinityNikkiInitialization = async () => {\n    await updateInfinityNikkiSettings({ galleryGuideSeen: true })\n  }\n\n  const resetExtensionSettings = async () => {\n    await store.updateSettings({\n      extensions: {\n        infinityNikki: {\n          ...DEFAULT_INFINITY_NIKKI_SETTINGS,\n        },\n      },\n    })\n  }\n\n  return {\n    updateInfinityNikkiEnabled,\n    updateInfinityNikkiGameDir,\n    updateInfinityNikkiAllowOnlinePhotoMetadataExtract,\n    updateInfinityNikkiManageScreenshotHardlinks,\n    completeInfinityNikkiInitialization,\n    resetExtensionSettings,\n  }\n}\n"
  },
  {
    "path": "web/src/features/settings/composables/useFunctionActions.ts",
    "content": "import { useSettingsStore } from '../store'\nimport { DEFAULT_APP_SETTINGS } from '../types'\nimport { storeToRefs } from 'pinia'\n\nexport const useFunctionActions = () => {\n  const store = useSettingsStore()\n  const { appSettings } = storeToRefs(store)\n\n  const updateWindowTitle = async (title: string) => {\n    await store.updateSettings({\n      ...appSettings.value,\n      window: {\n        ...appSettings.value.window,\n        targetTitle: title,\n      },\n    })\n  }\n\n  const updateWindowCenterLockCursor = async (enabled: boolean) => {\n    await store.updateSettings({\n      ...appSettings.value,\n      window: {\n        ...appSettings.value.window,\n        centerLockCursor: enabled,\n      },\n    })\n  }\n\n  const updateWindowLayeredCaptureWorkaround = async (enabled: boolean) => {\n    await store.updateSettings({\n      ...appSettings.value,\n      window: {\n        ...appSettings.value.window,\n        enableLayeredCaptureWorkaround: enabled,\n      },\n    })\n  }\n\n  const updateWindowResetResolution = async (width: number, height: number) => {\n    await store.updateSettings({\n      ...appSettings.value,\n      window: {\n        ...appSettings.value.window,\n        resetResolution: {\n          ...appSettings.value.window.resetResolution,\n          width,\n          height,\n        },\n      },\n    })\n  }\n\n  const updateOutputDir = async (dirPath: string) => {\n    await store.updateSettings({\n      ...appSettings.value,\n      features: {\n        ...appSettings.value.features,\n        outputDirPath: dirPath,\n      },\n    })\n  }\n\n  const updateGameAlbumPath = async (dirPath: string) => {\n    await store.updateSettings({\n      ...appSettings.value,\n      features: {\n        ...appSettings.value.features,\n        externalAlbumPath: dirPath,\n      },\n    })\n  }\n\n  // Motion Photo 设置\n  const updateMotionPhotoDuration = async (duration: number) => {\n    await store.updateSettings({\n      ...appSettings.value,\n      features: {\n        ...appSettings.value.features,\n        motionPhoto: {\n          ...appSettings.value.features.motionPhoto,\n          duration,\n        },\n      },\n    })\n  }\n\n  const updateMotionPhotoResolution = async (resolution: number) => {\n    await store.updateSettings({\n      ...appSettings.value,\n      features: {\n        ...appSettings.value.features,\n        motionPhoto: {\n          ...appSettings.value.features.motionPhoto,\n          resolution,\n        },\n      },\n    })\n  }\n\n  const updateMotionPhotoFps = async (fps: number) => {\n    await store.updateSettings({\n      ...appSettings.value,\n      features: {\n        ...appSettings.value.features,\n        motionPhoto: {\n          ...appSettings.value.features.motionPhoto,\n          fps,\n        },\n      },\n    })\n  }\n\n  const updateMotionPhotoBitrate = async (bitrate: number) => {\n    await store.updateSettings({\n      ...appSettings.value,\n      features: {\n        ...appSettings.value.features,\n        motionPhoto: {\n          ...appSettings.value.features.motionPhoto,\n          bitrate,\n        },\n      },\n    })\n  }\n\n  const updateMotionPhotoCodec = async (codec: 'h264' | 'h265') => {\n    await store.updateSettings({\n      ...appSettings.value,\n      features: {\n        ...appSettings.value.features,\n        motionPhoto: {\n          ...appSettings.value.features.motionPhoto,\n          codec,\n        },\n      },\n    })\n  }\n\n  const updateMotionPhotoAudioSource = async (audioSource: 'none' | 'system' | 'game_only') => {\n    await store.updateSettings({\n      ...appSettings.value,\n      features: {\n        ...appSettings.value.features,\n        motionPhoto: {\n          ...appSettings.value.features.motionPhoto,\n          audioSource,\n        },\n      },\n    })\n  }\n\n  const updateMotionPhotoAudioBitrate = async (audioBitrate: number) => {\n    await store.updateSettings({\n      ...appSettings.value,\n      features: {\n        ...appSettings.value.features,\n        motionPhoto: {\n          ...appSettings.value.features.motionPhoto,\n          audioBitrate,\n        },\n      },\n    })\n  }\n\n  const updateMotionPhotoRateControl = async (rateControl: 'cbr' | 'vbr') => {\n    await store.updateSettings({\n      ...appSettings.value,\n      features: {\n        ...appSettings.value.features,\n        motionPhoto: {\n          ...appSettings.value.features.motionPhoto,\n          rateControl,\n        },\n      },\n    })\n  }\n\n  const updateMotionPhotoQuality = async (quality: number) => {\n    await store.updateSettings({\n      ...appSettings.value,\n      features: {\n        ...appSettings.value.features,\n        motionPhoto: {\n          ...appSettings.value.features.motionPhoto,\n          quality,\n        },\n      },\n    })\n  }\n\n  // Instant Replay 设置\n  const updateReplayBufferDuration = async (duration: number) => {\n    await store.updateSettings({\n      ...appSettings.value,\n      features: {\n        ...appSettings.value.features,\n        replayBuffer: {\n          ...appSettings.value.features.replayBuffer,\n          duration,\n        },\n      },\n    })\n  }\n\n  const updateRecordingFps = async (fps: number) => {\n    await store.updateSettings({\n      ...appSettings.value,\n      features: {\n        ...appSettings.value.features,\n        recording: {\n          ...appSettings.value.features.recording,\n          fps,\n        },\n      },\n    })\n  }\n\n  const updateRecordingBitrate = async (bitrate: number) => {\n    await store.updateSettings({\n      ...appSettings.value,\n      features: {\n        ...appSettings.value.features,\n        recording: {\n          ...appSettings.value.features.recording,\n          bitrate,\n        },\n      },\n    })\n  }\n\n  const updateRecordingEncoderMode = async (encoderMode: 'auto' | 'gpu' | 'cpu') => {\n    await store.updateSettings({\n      ...appSettings.value,\n      features: {\n        ...appSettings.value.features,\n        recording: {\n          ...appSettings.value.features.recording,\n          encoderMode,\n        },\n      },\n    })\n  }\n\n  const updateRecordingQuality = async (quality: number) => {\n    await store.updateSettings({\n      ...appSettings.value,\n      features: {\n        ...appSettings.value.features,\n        recording: {\n          ...appSettings.value.features.recording,\n          quality,\n        },\n      },\n    })\n  }\n\n  const updateRecordingQp = async (qp: number) => {\n    await store.updateSettings({\n      ...appSettings.value,\n      features: {\n        ...appSettings.value.features,\n        recording: {\n          ...appSettings.value.features.recording,\n          qp,\n        },\n      },\n    })\n  }\n\n  const updateRecordingRateControl = async (rateControl: 'cbr' | 'vbr' | 'manual_qp') => {\n    await store.updateSettings({\n      ...appSettings.value,\n      features: {\n        ...appSettings.value.features,\n        recording: {\n          ...appSettings.value.features.recording,\n          rateControl,\n        },\n      },\n    })\n  }\n\n  const updateRecordingCodec = async (codec: 'h264' | 'h265') => {\n    await store.updateSettings({\n      ...appSettings.value,\n      features: {\n        ...appSettings.value.features,\n        recording: {\n          ...appSettings.value.features.recording,\n          codec,\n        },\n      },\n    })\n  }\n\n  const updateRecordingCaptureClientArea = async (captureClientArea: boolean) => {\n    await store.updateSettings({\n      ...appSettings.value,\n      features: {\n        ...appSettings.value.features,\n        recording: {\n          ...appSettings.value.features.recording,\n          captureClientArea,\n        },\n      },\n    })\n  }\n\n  const updateRecordingCaptureCursor = async (captureCursor: boolean) => {\n    await store.updateSettings({\n      ...appSettings.value,\n      features: {\n        ...appSettings.value.features,\n        recording: {\n          ...appSettings.value.features.recording,\n          captureCursor,\n        },\n      },\n    })\n  }\n\n  const updateRecordingAutoRestartOnResize = async (autoRestartOnResize: boolean) => {\n    await store.updateSettings({\n      ...appSettings.value,\n      features: {\n        ...appSettings.value.features,\n        recording: {\n          ...appSettings.value.features.recording,\n          autoRestartOnResize,\n        },\n      },\n    })\n  }\n\n  const updateRecordingAudioSource = async (audioSource: 'none' | 'system' | 'game_only') => {\n    await store.updateSettings({\n      ...appSettings.value,\n      features: {\n        ...appSettings.value.features,\n        recording: {\n          ...appSettings.value.features.recording,\n          audioSource,\n        },\n      },\n    })\n  }\n\n  const updateRecordingAudioBitrate = async (audioBitrate: number) => {\n    await store.updateSettings({\n      ...appSettings.value,\n      features: {\n        ...appSettings.value.features,\n        recording: {\n          ...appSettings.value.features.recording,\n          audioBitrate,\n        },\n      },\n    })\n  }\n\n  const resetFunctionSettings = async () => {\n    await store.updateSettings({\n      ...appSettings.value,\n      window: {\n        ...appSettings.value.window,\n        targetTitle: DEFAULT_APP_SETTINGS.window.targetTitle,\n        centerLockCursor: DEFAULT_APP_SETTINGS.window.centerLockCursor,\n        resetResolution: {\n          ...appSettings.value.window.resetResolution,\n          width: DEFAULT_APP_SETTINGS.window.resetResolution.width,\n          height: DEFAULT_APP_SETTINGS.window.resetResolution.height,\n        },\n      },\n      features: {\n        ...appSettings.value.features,\n        outputDirPath: DEFAULT_APP_SETTINGS.features.outputDirPath,\n        externalAlbumPath: DEFAULT_APP_SETTINGS.features.externalAlbumPath,\n        letterbox: {\n          ...appSettings.value.features.letterbox,\n          enabled: DEFAULT_APP_SETTINGS.features.letterbox.enabled,\n        },\n        motionPhoto: {\n          ...appSettings.value.features.motionPhoto,\n          duration: DEFAULT_APP_SETTINGS.features.motionPhoto.duration,\n          resolution: DEFAULT_APP_SETTINGS.features.motionPhoto.resolution,\n          fps: DEFAULT_APP_SETTINGS.features.motionPhoto.fps,\n          bitrate: DEFAULT_APP_SETTINGS.features.motionPhoto.bitrate,\n          quality: DEFAULT_APP_SETTINGS.features.motionPhoto.quality,\n          rateControl: DEFAULT_APP_SETTINGS.features.motionPhoto.rateControl,\n          codec: DEFAULT_APP_SETTINGS.features.motionPhoto.codec,\n          audioSource: DEFAULT_APP_SETTINGS.features.motionPhoto.audioSource,\n          audioBitrate: DEFAULT_APP_SETTINGS.features.motionPhoto.audioBitrate,\n        },\n        replayBuffer: {\n          ...appSettings.value.features.replayBuffer,\n          duration: DEFAULT_APP_SETTINGS.features.replayBuffer.duration,\n        },\n        recording: {\n          ...appSettings.value.features.recording,\n          fps: DEFAULT_APP_SETTINGS.features.recording.fps,\n          bitrate: DEFAULT_APP_SETTINGS.features.recording.bitrate,\n          quality: DEFAULT_APP_SETTINGS.features.recording.quality,\n          qp: DEFAULT_APP_SETTINGS.features.recording.qp,\n          rateControl: DEFAULT_APP_SETTINGS.features.recording.rateControl,\n          encoderMode: DEFAULT_APP_SETTINGS.features.recording.encoderMode,\n          codec: DEFAULT_APP_SETTINGS.features.recording.codec,\n          captureClientArea: DEFAULT_APP_SETTINGS.features.recording.captureClientArea,\n          captureCursor: DEFAULT_APP_SETTINGS.features.recording.captureCursor,\n          autoRestartOnResize: DEFAULT_APP_SETTINGS.features.recording.autoRestartOnResize,\n          audioSource: DEFAULT_APP_SETTINGS.features.recording.audioSource,\n          audioBitrate: DEFAULT_APP_SETTINGS.features.recording.audioBitrate,\n        },\n      },\n    })\n  }\n\n  const resetCaptureExportSettings = async () => {\n    await store.updateSettings({\n      ...appSettings.value,\n      features: {\n        ...appSettings.value.features,\n        outputDirPath: DEFAULT_APP_SETTINGS.features.outputDirPath,\n        externalAlbumPath: DEFAULT_APP_SETTINGS.features.externalAlbumPath,\n        motionPhoto: {\n          ...appSettings.value.features.motionPhoto,\n          duration: DEFAULT_APP_SETTINGS.features.motionPhoto.duration,\n          resolution: DEFAULT_APP_SETTINGS.features.motionPhoto.resolution,\n          fps: DEFAULT_APP_SETTINGS.features.motionPhoto.fps,\n          bitrate: DEFAULT_APP_SETTINGS.features.motionPhoto.bitrate,\n          quality: DEFAULT_APP_SETTINGS.features.motionPhoto.quality,\n          rateControl: DEFAULT_APP_SETTINGS.features.motionPhoto.rateControl,\n          codec: DEFAULT_APP_SETTINGS.features.motionPhoto.codec,\n          audioSource: DEFAULT_APP_SETTINGS.features.motionPhoto.audioSource,\n          audioBitrate: DEFAULT_APP_SETTINGS.features.motionPhoto.audioBitrate,\n        },\n        replayBuffer: {\n          ...appSettings.value.features.replayBuffer,\n          duration: DEFAULT_APP_SETTINGS.features.replayBuffer.duration,\n        },\n        recording: {\n          ...appSettings.value.features.recording,\n          fps: DEFAULT_APP_SETTINGS.features.recording.fps,\n          bitrate: DEFAULT_APP_SETTINGS.features.recording.bitrate,\n          quality: DEFAULT_APP_SETTINGS.features.recording.quality,\n          qp: DEFAULT_APP_SETTINGS.features.recording.qp,\n          rateControl: DEFAULT_APP_SETTINGS.features.recording.rateControl,\n          encoderMode: DEFAULT_APP_SETTINGS.features.recording.encoderMode,\n          codec: DEFAULT_APP_SETTINGS.features.recording.codec,\n          captureClientArea: DEFAULT_APP_SETTINGS.features.recording.captureClientArea,\n          captureCursor: DEFAULT_APP_SETTINGS.features.recording.captureCursor,\n          autoRestartOnResize: DEFAULT_APP_SETTINGS.features.recording.autoRestartOnResize,\n          audioSource: DEFAULT_APP_SETTINGS.features.recording.audioSource,\n          audioBitrate: DEFAULT_APP_SETTINGS.features.recording.audioBitrate,\n        },\n      },\n    })\n  }\n\n  const resetWindowSceneSettings = async () => {\n    await store.updateSettings({\n      ...appSettings.value,\n      window: {\n        ...appSettings.value.window,\n        targetTitle: DEFAULT_APP_SETTINGS.window.targetTitle,\n        enableLayeredCaptureWorkaround: DEFAULT_APP_SETTINGS.window.enableLayeredCaptureWorkaround,\n        resetResolution: {\n          ...appSettings.value.window.resetResolution,\n          width: DEFAULT_APP_SETTINGS.window.resetResolution.width,\n          height: DEFAULT_APP_SETTINGS.window.resetResolution.height,\n        },\n      },\n    })\n  }\n\n  return {\n    updateWindowTitle,\n    updateWindowCenterLockCursor,\n    updateWindowLayeredCaptureWorkaround,\n    updateWindowResetResolution,\n    updateOutputDir,\n    updateGameAlbumPath,\n    // Motion Photo\n    updateMotionPhotoDuration,\n    updateMotionPhotoResolution,\n    updateMotionPhotoFps,\n    updateMotionPhotoBitrate,\n    updateMotionPhotoQuality,\n    updateMotionPhotoRateControl,\n    updateMotionPhotoCodec,\n    updateMotionPhotoAudioSource,\n    updateMotionPhotoAudioBitrate,\n    // Instant Replay\n    updateReplayBufferDuration,\n    // Recording\n    updateRecordingFps,\n    updateRecordingBitrate,\n    updateRecordingQuality,\n    updateRecordingQp,\n    updateRecordingRateControl,\n    updateRecordingEncoderMode,\n    updateRecordingCodec,\n    updateRecordingCaptureClientArea,\n    updateRecordingCaptureCursor,\n    updateRecordingAutoRestartOnResize,\n    updateRecordingAudioSource,\n    updateRecordingAudioBitrate,\n    resetFunctionSettings,\n    resetCaptureExportSettings,\n    resetWindowSceneSettings,\n  }\n}\n"
  },
  {
    "path": "web/src/features/settings/composables/useGeneralActions.ts",
    "content": "import { useSettingsStore } from '../store'\nimport { DEFAULT_APP_SETTINGS } from '../types'\nimport { storeToRefs } from 'pinia'\n\nexport const useGeneralActions = () => {\n  const store = useSettingsStore()\n  const { appSettings } = storeToRefs(store)\n\n  const updateLanguage = async (language: string) => {\n    await store.updateSettings({\n      ...appSettings.value,\n      app: {\n        ...appSettings.value.app,\n        language: { current: language },\n      },\n    })\n  }\n\n  const updateLoggerLevel = async (level: string) => {\n    await store.updateSettings({\n      ...appSettings.value,\n      app: {\n        ...appSettings.value.app,\n        logger: { level },\n      },\n    })\n  }\n\n  const updateAutoCheck = async (enabled: boolean) => {\n    await store.updateSettings({\n      ...appSettings.value,\n      update: {\n        ...appSettings.value.update,\n        autoCheck: enabled,\n        autoUpdateOnExit: enabled ? appSettings.value.update.autoUpdateOnExit : false,\n      },\n    })\n  }\n\n  const updateAutoUpdateOnExit = async (enabled: boolean) => {\n    await store.updateSettings({\n      ...appSettings.value,\n      update: {\n        ...appSettings.value.update,\n        autoUpdateOnExit: enabled,\n      },\n    })\n  }\n\n  const updateFloatingWindowHotkey = async (modifiers: number, key: number) => {\n    await store.updateSettings({\n      ...appSettings.value,\n      app: {\n        ...appSettings.value.app,\n        hotkey: {\n          ...appSettings.value.app.hotkey,\n          floatingWindow: { modifiers, key },\n        },\n      },\n    })\n  }\n\n  const updateScreenshotHotkey = async (modifiers: number, key: number) => {\n    await store.updateSettings({\n      ...appSettings.value,\n      app: {\n        ...appSettings.value.app,\n        hotkey: {\n          ...appSettings.value.app.hotkey,\n          screenshot: { modifiers, key },\n        },\n      },\n    })\n  }\n\n  const updateRecordingHotkey = async (modifiers: number, key: number) => {\n    await store.updateSettings({\n      ...appSettings.value,\n      app: {\n        ...appSettings.value.app,\n        hotkey: {\n          ...appSettings.value.app.hotkey,\n          recording: { modifiers, key },\n        },\n      },\n    })\n  }\n\n  const resetGeneralSettings = async () => {\n    await store.updateSettings({\n      ...appSettings.value,\n      app: {\n        ...appSettings.value.app,\n        language: {\n          current: DEFAULT_APP_SETTINGS.app.language.current,\n        },\n        logger: {\n          level: DEFAULT_APP_SETTINGS.app.logger.level,\n        },\n        hotkey: {\n          floatingWindow: {\n            modifiers: DEFAULT_APP_SETTINGS.app.hotkey.floatingWindow.modifiers,\n            key: DEFAULT_APP_SETTINGS.app.hotkey.floatingWindow.key,\n          },\n          screenshot: {\n            modifiers: DEFAULT_APP_SETTINGS.app.hotkey.screenshot.modifiers,\n            key: DEFAULT_APP_SETTINGS.app.hotkey.screenshot.key,\n          },\n          recording: {\n            modifiers: DEFAULT_APP_SETTINGS.app.hotkey.recording.modifiers,\n            key: DEFAULT_APP_SETTINGS.app.hotkey.recording.key,\n          },\n        },\n      },\n      update: {\n        ...appSettings.value.update,\n        autoCheck: DEFAULT_APP_SETTINGS.update.autoCheck,\n        autoUpdateOnExit: DEFAULT_APP_SETTINGS.update.autoUpdateOnExit,\n      },\n    })\n  }\n\n  const resetGeneralCoreSettings = async () => {\n    await store.updateSettings({\n      ...appSettings.value,\n      app: {\n        ...appSettings.value.app,\n        language: {\n          current: DEFAULT_APP_SETTINGS.app.language.current,\n        },\n        logger: {\n          level: DEFAULT_APP_SETTINGS.app.logger.level,\n        },\n      },\n      update: {\n        ...appSettings.value.update,\n        autoCheck: DEFAULT_APP_SETTINGS.update.autoCheck,\n        autoUpdateOnExit: DEFAULT_APP_SETTINGS.update.autoUpdateOnExit,\n      },\n    })\n  }\n\n  const resetHotkeySettings = async () => {\n    await store.updateSettings({\n      ...appSettings.value,\n      app: {\n        ...appSettings.value.app,\n        hotkey: {\n          floatingWindow: {\n            modifiers: DEFAULT_APP_SETTINGS.app.hotkey.floatingWindow.modifiers,\n            key: DEFAULT_APP_SETTINGS.app.hotkey.floatingWindow.key,\n          },\n          screenshot: {\n            modifiers: DEFAULT_APP_SETTINGS.app.hotkey.screenshot.modifiers,\n            key: DEFAULT_APP_SETTINGS.app.hotkey.screenshot.key,\n          },\n          recording: {\n            modifiers: DEFAULT_APP_SETTINGS.app.hotkey.recording.modifiers,\n            key: DEFAULT_APP_SETTINGS.app.hotkey.recording.key,\n          },\n        },\n      },\n    })\n  }\n\n  return {\n    updateLanguage,\n    updateLoggerLevel,\n    updateAutoCheck,\n    updateAutoUpdateOnExit,\n    updateFloatingWindowHotkey,\n    updateScreenshotHotkey,\n    updateRecordingHotkey,\n    resetGeneralSettings,\n    resetGeneralCoreSettings,\n    resetHotkeySettings,\n  }\n}\n"
  },
  {
    "path": "web/src/features/settings/composables/useMenuActions.ts",
    "content": "import { useSettingsStore } from '../store'\nimport { DEFAULT_APP_SETTINGS } from '../types'\nimport type { MenuItem } from '../types'\nimport { storeToRefs } from 'pinia'\nimport { ref, computed } from 'vue'\n\nexport const useMenuActions = () => {\n  const store = useSettingsStore()\n  const { appSettings, isInitialized, commandDescriptors } = storeToRefs(store)\n\n  // 功能项状态\n  const featureItems = ref<MenuItem[]>([])\n  const isLoadingFeatures = ref(false)\n  const featureDescriptorMap = computed(() => {\n    return new Map(commandDescriptors.value.map((descriptor) => [descriptor.id, descriptor]))\n  })\n\n  // 加载功能项列表\n  const loadFeatureItems = async () => {\n    if (!isInitialized.value) return\n\n    isLoadingFeatures.value = true\n    try {\n      // 命令列表在应用生命周期内只拉取一次，后续直接复用 store 缓存\n      await store.loadCommandsOnce()\n      const allFeatures = commandDescriptors.value\n      const enabledFeatures = appSettings.value?.ui?.appMenu?.features || []\n\n      // 构建启用功能的索引映射\n      const enabledMap = new Map<string, number>()\n      enabledFeatures.forEach((id, index) => {\n        enabledMap.set(id, index)\n      })\n\n      const items: MenuItem[] = []\n\n      // 先添加已启用的功能（按顺序）\n      enabledFeatures.forEach((id, index) => {\n        items.push({\n          id,\n          enabled: true,\n          order: index,\n        })\n      })\n\n      // 再添加未启用的功能\n      allFeatures.forEach((feature) => {\n        if (!enabledMap.has(feature.id)) {\n          items.push({\n            id: feature.id,\n            enabled: false,\n            order: -1,\n          })\n        }\n      })\n\n      featureItems.value = items\n    } catch (e) {\n      console.error('Failed to load feature items:', e)\n    } finally {\n      isLoadingFeatures.value = false\n    }\n  }\n\n  // 通用辅助函数：将 ID 数组转换为 MenuItem 数组\n  const createMenuItems = (ids: string[]): MenuItem[] => {\n    return ids.map((id) => ({ id, enabled: true }))\n  }\n\n  // 计算属性：比例列表\n  const aspectRatios = computed((): MenuItem[] => {\n    return createMenuItems(appSettings.value?.ui?.appMenu?.aspectRatios || [])\n  })\n\n  // 计算属性：分辨率列表\n  const resolutions = computed((): MenuItem[] => {\n    return createMenuItems(appSettings.value?.ui?.appMenu?.resolutions || [])\n  })\n\n  // 通用辅助函数：更新 appMenu 字段\n  const updateAppMenuField = async (\n    field: 'features' | 'aspectRatios' | 'resolutions',\n    value: string[]\n  ) => {\n    await store.updateSettings({\n      ...appSettings.value,\n      ui: {\n        ...appSettings.value.ui,\n        appMenu: {\n          ...appSettings.value.ui.appMenu,\n          [field]: value,\n        },\n      },\n    })\n  }\n\n  // 提取启用的功能项 ID\n  const getEnabledFeatureIds = (): string[] => {\n    return featureItems.value.filter((item) => item.enabled).map((item) => item.id)\n  }\n\n  // === 功能项操作 ===\n  const handleFeatureToggle = async (id: string, enabled: boolean) => {\n    featureItems.value = featureItems.value.map((item) =>\n      item.id === id ? { ...item, enabled } : item\n    )\n    await updateAppMenuField('features', getEnabledFeatureIds())\n  }\n\n  const handleFeatureReorder = async (items: MenuItem[]) => {\n    featureItems.value = items.map((item, index) => ({\n      ...item,\n      order: item.enabled ? index : -1,\n    }))\n    await updateAppMenuField('features', getEnabledFeatureIds())\n  }\n\n  // === 比例操作 ===\n  const handleAspectRatioAdd = async (newItem: { id: string; enabled: boolean }) => {\n    const currentIds = appSettings.value.ui.appMenu.aspectRatios\n    await updateAppMenuField('aspectRatios', [...currentIds, newItem.id])\n  }\n\n  const handleAspectRatioRemove = async (id: string) => {\n    const currentIds = appSettings.value.ui.appMenu.aspectRatios\n    await updateAppMenuField(\n      'aspectRatios',\n      currentIds.filter((ratioId) => ratioId !== id)\n    )\n  }\n\n  const handleAspectRatioReorder = async (items: MenuItem[]) => {\n    await updateAppMenuField(\n      'aspectRatios',\n      items.map((item) => item.id)\n    )\n  }\n\n  // === 分辨率操作 ===\n  const handleResolutionAdd = async (newItem: { id: string; enabled: boolean }) => {\n    const currentIds = appSettings.value.ui.appMenu.resolutions\n    await updateAppMenuField('resolutions', [...currentIds, newItem.id])\n  }\n\n  const handleResolutionRemove = async (id: string) => {\n    const currentIds = appSettings.value.ui.appMenu.resolutions\n    await updateAppMenuField(\n      'resolutions',\n      currentIds.filter((resId) => resId !== id)\n    )\n  }\n\n  const handleResolutionReorder = async (items: MenuItem[]) => {\n    await updateAppMenuField(\n      'resolutions',\n      items.map((item) => item.id)\n    )\n  }\n\n  // === 重置设置 ===\n  const handleResetSettings = async () => {\n    await store.updateSettings({\n      ...appSettings.value,\n      ui: {\n        ...appSettings.value.ui,\n        appMenu: DEFAULT_APP_SETTINGS.ui.appMenu,\n      },\n    })\n    await loadFeatureItems()\n  }\n\n  return {\n    // 状态\n    featureItems,\n    isLoadingFeatures,\n    featureDescriptorMap,\n    aspectRatios,\n    resolutions,\n\n    // 方法\n    loadFeatureItems,\n    handleFeatureToggle,\n    handleFeatureReorder,\n    handleAspectRatioAdd,\n    handleAspectRatioRemove,\n    handleAspectRatioReorder,\n    handleResolutionAdd,\n    handleResolutionRemove,\n    handleResolutionReorder,\n    handleResetSettings,\n  }\n}\n"
  },
  {
    "path": "web/src/features/settings/composables/useTheme.ts",
    "content": "import { ref, computed, watch } from 'vue'\nimport { useSettingsStore } from '../store'\nimport { storeToRefs } from 'pinia'\nimport type { WebThemeMode } from '../types'\nimport { applyAppearanceToDocument } from '../appearance'\n\n/**\n * 主题管理 Composable\n * 负责 Web UI 的主题切换与持久化（仅 light / dark；历史 system 按亮色解析）\n */\nexport const useTheme = () => {\n  const store = useSettingsStore()\n  const { appSettings } = storeToRefs(store)\n\n  // 当前实际应用的主题（解析后的 light/dark）\n  const resolvedTheme = ref<'light' | 'dark'>('dark')\n\n  // 用户选择的主题模式\n  const themeMode = computed(() => appSettings.value.ui.webTheme.mode)\n\n  const resolveTheme = (mode: WebThemeMode): 'light' | 'dark' => {\n    if (mode === 'system') {\n      return 'light'\n    }\n    return mode\n  }\n\n  const syncAppearance = () => {\n    applyAppearanceToDocument(appSettings.value)\n    resolvedTheme.value = resolveTheme(themeMode.value)\n  }\n\n  /**\n   * 更新主题模式\n   */\n  const setTheme = async (mode: WebThemeMode) => {\n    try {\n      await store.updateSettings({\n        ...appSettings.value,\n        ui: {\n          ...appSettings.value.ui,\n          webTheme: {\n            ...appSettings.value.ui.webTheme,\n            mode,\n          },\n        },\n      })\n\n      syncAppearance()\n\n      console.log('✅ 主题已更新:', mode, '→', resolvedTheme.value)\n    } catch (error) {\n      console.error('❌ 更新主题失败:', error)\n      throw error\n    }\n  }\n\n  /**\n   * 初始化主题\n   */\n  const initTheme = () => {\n    syncAppearance()\n\n    console.log('🎨 主题初始化完成:', {\n      mode: themeMode.value,\n      resolved: resolvedTheme.value,\n    })\n  }\n\n  // 监听主题模式变化\n  watch(\n    () => themeMode.value,\n    () => {\n      syncAppearance()\n    }\n  )\n\n  return {\n    themeMode,\n    resolvedTheme,\n\n    setTheme,\n    initTheme,\n  }\n}\n"
  },
  {
    "path": "web/src/features/settings/constants.ts",
    "content": "// 背景图片目录（Web 路径）\nexport const BACKGROUND_WEB_DIR = '/static/backgrounds'\n\n// 界面面板不透明度范围\nexport const SURFACE_OPACITY_RANGE = {\n  MIN: 0,\n  MAX: 1,\n  DEFAULT: 0.8,\n  STEP: 0.05,\n} as const\n\n// 背景图层模糊范围\nexport const BACKGROUND_BLUR_RANGE = {\n  MIN: 0,\n  MAX: 100,\n  DEFAULT: 0,\n  STEP: 1,\n} as const\n\n// 背景图层不透明度范围\nexport const BACKGROUND_OPACITY_RANGE = {\n  MIN: 0,\n  MAX: 1,\n  DEFAULT: 1,\n  STEP: 0.05,\n} as const\n\n// 叠加色不透明度范围\nexport const OVERLAY_OPACITY_RANGE = {\n  MIN: 0,\n  MAX: 1,\n  DEFAULT: 0,\n  STEP: 0.05,\n} as const\n\n// === 菜单项 Registry ===\n\n// 内置比例预设（用于快速查找，但用户可自定义任意 W:H 格式）\nexport const ASPECT_RATIO_PRESETS: Record<string, number> = {\n  '32:9': 32 / 9,\n  '21:9': 21 / 9,\n  '16:9': 16 / 9,\n  '3:2': 3 / 2,\n  '1:1': 1,\n  '3:4': 3 / 4,\n  '2:3': 2 / 3,\n  '9:16': 9 / 16,\n}\n\n// 内置分辨率别名\nexport const RESOLUTION_ALIASES: Record<string, [number, number]> = {\n  Default: [0, 0],\n  '480P': [720, 480],\n  '720P': [1280, 720],\n  '1080P': [1920, 1080],\n  '2K': [2560, 1440],\n  '4K': [3840, 2160],\n  '5K': [5120, 2880],\n  '6K': [5760, 3240],\n  '8K': [7680, 4320],\n  '10K': [10240, 4320],\n  '12K': [11520, 6480],\n  '16K': [15360, 8640],\n}\n"
  },
  {
    "path": "web/src/features/settings/featuresApi.ts",
    "content": "import { call } from '@/core/rpc'\nimport type { FeatureDescriptor } from './types'\n\nexport const featuresApi = {\n  getAll: async (): Promise<FeatureDescriptor[]> => {\n    const result = await call<{ commands: FeatureDescriptor[] }>('commands.getAll', {})\n    return result.commands\n  },\n\n  invoke: async (id: string): Promise<void> => {\n    const result = await call<{ success: boolean; message: string }>('commands.invoke', { id })\n    if (!result.success) {\n      throw new Error(result.message || `调用命令失败: ${id}`)\n    }\n  },\n}\n"
  },
  {
    "path": "web/src/features/settings/overlayPalette.ts",
    "content": "import type { WebBackgroundSettings, WebThemeMode } from './types'\n\nexport type OverlayColorMode = 1 | 2 | 3 | 4\n\nexport interface OverlayPalette {\n  mode: OverlayColorMode\n  colors: [string, string, string, string]\n}\n\nexport interface OverlayPalettePreset extends OverlayPalette {\n  id: string\n  themeMode: Exclude<WebThemeMode, 'system'>\n}\n\nconst DEFAULT_HEX_COLOR = '#000000'\nconst HEX_COLOR_PATTERN = /^#[0-9A-Fa-f]{6}$/\nconst MIN_OVERLAY_COLORS = 1\nconst MAX_OVERLAY_COLORS = 4\nconst DEFAULT_OVERLAY_COLORS: [string, string] = ['#000000', '#000000']\n\nconst MODE_VALUES: ReadonlyArray<OverlayColorMode> = [1, 2, 3, 4]\n\nconst normalizeMode = (mode: number | undefined): OverlayColorMode => {\n  return MODE_VALUES.includes(mode as OverlayColorMode) ? (mode as OverlayColorMode) : 2\n}\n\nexport const normalizeHexColor = (\n  value: string | undefined,\n  fallback = DEFAULT_HEX_COLOR\n): string => {\n  const normalized = value?.trim().toUpperCase() ?? ''\n  return HEX_COLOR_PATTERN.test(normalized) ? normalized : fallback\n}\n\nconst toFourColors = (colors: Array<string | undefined>): [string, string, string, string] => {\n  const normalizedColors = colors.map((color) => normalizeHexColor(color, DEFAULT_HEX_COLOR))\n  return [\n    normalizedColors[0] ?? DEFAULT_HEX_COLOR,\n    normalizedColors[1] ?? normalizedColors[0] ?? DEFAULT_HEX_COLOR,\n    normalizedColors[2] ?? normalizedColors[1] ?? normalizedColors[0] ?? DEFAULT_HEX_COLOR,\n    normalizedColors[3] ??\n      normalizedColors[2] ??\n      normalizedColors[1] ??\n      normalizedColors[0] ??\n      DEFAULT_HEX_COLOR,\n  ]\n}\n\nconst toFourColorsFromPalette = (palette: OverlayPalette): [string, string, string, string] => {\n  return toFourColors(palette.colors)\n}\n\nconst normalizeOverlayColors = (colors: Array<string | undefined>): string[] => {\n  const normalized = colors\n    .map((color) => normalizeHexColor(color, ''))\n    .filter((color) => color !== '')\n    .slice(0, MAX_OVERLAY_COLORS)\n\n  if (normalized.length === 0) {\n    return [...DEFAULT_OVERLAY_COLORS]\n  }\n\n  if (normalized.length < MIN_OVERLAY_COLORS) {\n    return [DEFAULT_HEX_COLOR]\n  }\n\n  return normalized\n}\n\nexport const OVERLAY_PALETTE_PRESETS: ReadonlyArray<OverlayPalettePreset> = [\n  {\n    id: 'peach',\n    themeMode: 'light',\n    mode: 1,\n    colors: ['#F9F1E4', '#F9F1E4', '#F9F1E4', '#F9F1E4'],\n  },\n  {\n    id: 'mist',\n    themeMode: 'light',\n    mode: 1,\n    colors: ['#F1F5FB', '#F1F5FB', '#F1F5FB', '#F1F5FB'],\n  },\n  {\n    id: 'nikki',\n    themeMode: 'light',\n    mode: 2,\n    colors: ['#FDF0F4', '#F9E1E6', '#F9E1E6', '#F9E1E6'],\n  },\n  {\n    id: 'spring',\n    themeMode: 'light',\n    mode: 2,\n    colors: ['#DAE8CA', '#F6EAD3', '#F6EAD3', '#F6EAD3'],\n  },\n  {\n    id: 'graphite',\n    themeMode: 'dark',\n    mode: 1,\n    colors: ['#171B22', '#171B22', '#171B22', '#171B22'],\n  },\n  {\n    id: 'slate',\n    themeMode: 'dark',\n    mode: 1,\n    colors: ['#1A1824', '#1A1824', '#1A1824', '#1A1824'],\n  },\n  {\n    id: 'teal',\n    themeMode: 'dark',\n    mode: 2,\n    colors: ['#1A2D2A', '#162421', '#162421', '#162421'],\n  },\n  {\n    id: 'galaxy',\n    themeMode: 'dark',\n    mode: 3,\n    colors: ['#0B1021', '#112240', '#1A1423', '#1A1423'],\n  },\n]\n\nexport const getOverlayPaletteFromBackground = (\n  background: WebBackgroundSettings\n): OverlayPalette => {\n  const activeColors = normalizeOverlayColors(background.overlayColors)\n  const mode = normalizeMode(activeColors.length)\n  const colors = toFourColors(activeColors)\n\n  return {\n    mode,\n    colors,\n  }\n}\n\nexport const getActiveOverlayColors = (palette: OverlayPalette): string[] => {\n  return palette.colors.slice(0, palette.mode)\n}\n\nexport const buildOverlayGradient = (palette: OverlayPalette): string => {\n  const normalized = toFourColorsFromPalette(palette)\n  const [c1, c2, c3, c4] = normalized\n\n  switch (palette.mode) {\n    case 1:\n      return `linear-gradient(to bottom right, ${c1} 0%, ${c1} 100%)`\n    case 2:\n      return `linear-gradient(to bottom right, ${c1} 0%, ${c2} 100%)`\n    case 3:\n      return `linear-gradient(to bottom right, ${c1} 0%, ${c2} 50%, ${c3} 100%)`\n    case 4:\n      return [\n        `radial-gradient(circle at 0% 0%, ${c1} 0%, transparent 62%)`,\n        `radial-gradient(circle at 100% 0%, ${c2} 0%, transparent 62%)`,\n        `radial-gradient(circle at 100% 100%, ${c3} 0%, transparent 62%)`,\n        `radial-gradient(circle at 0% 100%, ${c4} 0%, transparent 62%)`,\n        `linear-gradient(to bottom right, ${c1} 0%, ${c3} 100%)`,\n      ].join(', ')\n    default:\n      return `linear-gradient(to bottom right, ${c1} 0%, ${c2} 100%)`\n  }\n}\n\nexport const toBackgroundOverlayPatch = (\n  palette: OverlayPalette\n): Pick<WebBackgroundSettings, 'overlayColors'> => {\n  const mode = normalizeMode(palette.mode)\n  const colors = toFourColorsFromPalette(palette)\n  const activeColors = colors.slice(0, mode)\n\n  return {\n    overlayColors: activeColors,\n  }\n}\n"
  },
  {
    "path": "web/src/features/settings/overlayPaletteSampler.ts",
    "content": "import type { WebThemeMode } from './types'\nimport type { OverlayColorMode, OverlayPalette } from './overlayPalette'\n\ntype ResolvedTheme = 'light' | 'dark'\n\ninterface RgbColor {\n  r: number\n  g: number\n  b: number\n}\n\ninterface HslColor {\n  h: number\n  s: number\n  l: number\n}\n\nexport interface OverlayPaletteSampleOptions {\n  imageUrl: string\n  mode: OverlayColorMode\n  themeMode: WebThemeMode\n}\n\nconst SAMPLE_CANVAS_WIDTH = 360\nconst SAMPLE_CANVAS_HEIGHT = 240\nconst SAMPLE_BOX_SIZE = 16\n\nconst SAMPLE_POINTS: Record<OverlayColorMode, Array<[number, number]>> = {\n  1: [[0.5, 0.5]],\n  2: [\n    [0.2, 0.2],\n    [0.8, 0.8],\n  ],\n  3: [\n    [0.18, 0.2],\n    [0.5, 0.5],\n    [0.82, 0.8],\n  ],\n  4: [\n    [0.18, 0.2],\n    [0.82, 0.2],\n    [0.82, 0.8],\n    [0.18, 0.8],\n  ],\n}\n\nconst clamp = (value: number, min: number, max: number): number => {\n  return Math.min(max, Math.max(min, value))\n}\n\nconst resolveTheme = (mode: WebThemeMode): ResolvedTheme => {\n  if (mode === 'system') {\n    return 'light'\n  }\n  return mode\n}\n\nconst loadImage = async (url: string): Promise<HTMLImageElement> => {\n  return new Promise((resolve, reject) => {\n    const image = new Image()\n    image.decoding = 'async'\n    image.onload = () => resolve(image)\n    image.onerror = () => reject(new Error(`Failed to load wallpaper image: ${url}`))\n    image.src = url\n  })\n}\n\nconst drawCoverImage = (\n  context: CanvasRenderingContext2D,\n  image: HTMLImageElement,\n  canvasWidth: number,\n  canvasHeight: number\n) => {\n  const sourceWidth = image.naturalWidth || image.width\n  const sourceHeight = image.naturalHeight || image.height\n  const scale = Math.max(canvasWidth / sourceWidth, canvasHeight / sourceHeight)\n  const drawWidth = sourceWidth * scale\n  const drawHeight = sourceHeight * scale\n  const offsetX = (canvasWidth - drawWidth) / 2\n  const offsetY = (canvasHeight - drawHeight) / 2\n\n  context.clearRect(0, 0, canvasWidth, canvasHeight)\n  context.drawImage(image, offsetX, offsetY, drawWidth, drawHeight)\n}\n\nconst sampleAverageColor = (\n  context: CanvasRenderingContext2D,\n  x: number,\n  y: number,\n  sampleSize = SAMPLE_BOX_SIZE\n): RgbColor => {\n  const width = context.canvas.width\n  const height = context.canvas.height\n  const half = Math.floor(sampleSize / 2)\n  const sampleX = clamp(Math.round(x - half), 0, width - 1)\n  const sampleY = clamp(Math.round(y - half), 0, height - 1)\n  const sampleWidth = Math.max(1, Math.min(sampleSize, width - sampleX))\n  const sampleHeight = Math.max(1, Math.min(sampleSize, height - sampleY))\n  const pixelData = context.getImageData(sampleX, sampleY, sampleWidth, sampleHeight).data\n\n  let totalWeight = 0\n  let r = 0\n  let g = 0\n  let b = 0\n\n  for (let i = 0; i < pixelData.length; i += 4) {\n    const alpha = pixelData[i + 3]! / 255\n    if (alpha <= 0) continue\n\n    r += pixelData[i]! * alpha\n    g += pixelData[i + 1]! * alpha\n    b += pixelData[i + 2]! * alpha\n    totalWeight += alpha\n  }\n\n  if (totalWeight <= 0) {\n    return { r: 0, g: 0, b: 0 }\n  }\n\n  return {\n    r: Math.round(r / totalWeight),\n    g: Math.round(g / totalWeight),\n    b: Math.round(b / totalWeight),\n  }\n}\n\nconst rgbToHsl = ({ r, g, b }: RgbColor): HslColor => {\n  const red = r / 255\n  const green = g / 255\n  const blue = b / 255\n  const max = Math.max(red, green, blue)\n  const min = Math.min(red, green, blue)\n  const delta = max - min\n\n  let h = 0\n  const l = (max + min) / 2\n  let s = 0\n\n  if (delta > 0) {\n    s = delta / (1 - Math.abs(2 * l - 1))\n\n    switch (max) {\n      case red:\n        h = ((green - blue) / delta) % 6\n        break\n      case green:\n        h = (blue - red) / delta + 2\n        break\n      default:\n        h = (red - green) / delta + 4\n        break\n    }\n\n    h *= 60\n    if (h < 0) h += 360\n  }\n\n  return {\n    h,\n    s: s * 100,\n    l: l * 100,\n  }\n}\n\nconst hslToRgb = ({ h, s, l }: HslColor): RgbColor => {\n  const saturation = clamp(s, 0, 100) / 100\n  const lightness = clamp(l, 0, 100) / 100\n  const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation\n  const huePrime = (h % 360) / 60\n  const x = chroma * (1 - Math.abs((huePrime % 2) - 1))\n\n  let red = 0\n  let green = 0\n  let blue = 0\n\n  if (huePrime >= 0 && huePrime < 1) {\n    red = chroma\n    green = x\n  } else if (huePrime >= 1 && huePrime < 2) {\n    red = x\n    green = chroma\n  } else if (huePrime >= 2 && huePrime < 3) {\n    green = chroma\n    blue = x\n  } else if (huePrime >= 3 && huePrime < 4) {\n    green = x\n    blue = chroma\n  } else if (huePrime >= 4 && huePrime < 5) {\n    red = x\n    blue = chroma\n  } else {\n    red = chroma\n    blue = x\n  }\n\n  const m = lightness - chroma / 2\n\n  return {\n    r: Math.round((red + m) * 255),\n    g: Math.round((green + m) * 255),\n    b: Math.round((blue + m) * 255),\n  }\n}\n\nconst compensateColorForTheme = (color: RgbColor, theme: ResolvedTheme): RgbColor => {\n  const hsl = rgbToHsl(color)\n\n  if (theme === 'light') {\n    return hslToRgb({\n      ...hsl,\n      l: clamp(hsl.l + 14, 62, 90),\n    })\n  }\n\n  return hslToRgb({\n    ...hsl,\n    l: clamp(hsl.l - 16, 10, 34),\n  })\n}\n\nconst toHexPart = (value: number): string => {\n  return clamp(Math.round(value), 0, 255).toString(16).toUpperCase().padStart(2, '0')\n}\n\nconst rgbToHex = (color: RgbColor): string => {\n  return `#${toHexPart(color.r)}${toHexPart(color.g)}${toHexPart(color.b)}`\n}\n\nconst toFourColors = (colors: string[]): [string, string, string, string] => {\n  const c1 = colors[0] ?? '#000000'\n  const c2 = colors[1] ?? c1\n  const c3 = colors[2] ?? c2\n  const c4 = colors[3] ?? c3\n  return [c1, c2, c3, c4]\n}\n\nexport const sampleOverlayPaletteFromWallpaper = async (\n  options: OverlayPaletteSampleOptions\n): Promise<OverlayPalette> => {\n  const image = await loadImage(options.imageUrl)\n  const canvas = document.createElement('canvas')\n  canvas.width = SAMPLE_CANVAS_WIDTH\n  canvas.height = SAMPLE_CANVAS_HEIGHT\n\n  const context = canvas.getContext('2d', { willReadFrequently: true })\n  if (!context) {\n    throw new Error('Failed to create canvas context for wallpaper sampling')\n  }\n\n  drawCoverImage(context, image, canvas.width, canvas.height)\n\n  const theme = resolveTheme(options.themeMode)\n  const points = SAMPLE_POINTS[options.mode]\n  const sampledColors = points.map(([xRatio, yRatio]) => {\n    const sampled = sampleAverageColor(context, canvas.width * xRatio, canvas.height * yRatio)\n    const compensated = compensateColorForTheme(sampled, theme)\n    return rgbToHex(compensated)\n  })\n\n  return {\n    mode: options.mode,\n    colors: toFourColors(sampledColors),\n  }\n}\n"
  },
  {
    "path": "web/src/features/settings/pages/SettingsPage.vue",
    "content": "<script setup lang=\"ts\">\nimport { ref, watch, onMounted } from 'vue'\nimport { ScrollArea } from '@/components/ui/scroll-area'\nimport SettingsSidebar, { type SettingsPageKey } from '../components/SettingsSidebar.vue'\nimport CaptureSettingsContent from '../components/CaptureSettingsContent.vue'\nimport AppearanceContent from '../components/AppearanceContent.vue'\nimport ExtensionsContent from '../components/ExtensionsContent.vue'\nimport GeneralSettingsContent from '../components/GeneralSettingsContent.vue'\nimport HotkeySettingsContent from '../components/HotkeySettingsContent.vue'\nimport WindowSceneContent from '../components/WindowSceneContent.vue'\nimport FloatingWindowContent from '../components/FloatingWindowContent.vue'\nimport { useSettingsStore } from '../store'\n\nconst activePage = ref<SettingsPageKey>('general')\nconst scrollAreaRef = ref<InstanceType<typeof ScrollArea> | null>(null)\nconst store = useSettingsStore()\n\n// 初始化时加载设置\nonMounted(() => {\n  store.init()\n})\n\nconst scrollToTop = () => {\n  scrollAreaRef.value?.viewportElement?.scrollTo({ top: 0, behavior: 'smooth' })\n}\n\nwatch(activePage, () => {\n  scrollToTop()\n})\n</script>\n\n<template>\n  <div class=\"flex h-full text-foreground\">\n    <SettingsSidebar v-model:activePage=\"activePage\" />\n    <div class=\"flex h-full flex-1 flex-col overflow-hidden\">\n      <ScrollArea ref=\"scrollAreaRef\" class=\"h-full w-full flex-1\">\n        <div class=\"mx-auto max-w-4xl p-8\">\n          <GeneralSettingsContent v-if=\"activePage === 'general'\" />\n          <HotkeySettingsContent v-if=\"activePage === 'hotkeys'\" />\n          <CaptureSettingsContent v-if=\"activePage === 'capture'\" />\n          <ExtensionsContent v-if=\"activePage === 'extensions'\" />\n          <WindowSceneContent v-if=\"activePage === 'windowScene'\" />\n          <FloatingWindowContent v-if=\"activePage === 'floatingWindow'\" />\n          <AppearanceContent v-if=\"activePage === 'webAppearance'\" />\n        </div>\n      </ScrollArea>\n    </div>\n  </div>\n</template>\n"
  },
  {
    "path": "web/src/features/settings/store.ts",
    "content": "import { defineStore } from 'pinia'\nimport { ref, watch } from 'vue'\nimport { settingsApi } from './api'\nimport { featuresApi } from './featuresApi'\nimport type { AppSettings, FeatureDescriptor, RuntimeCapabilities } from './types'\nimport { DEFAULT_APP_SETTINGS } from './types'\nimport { useI18n } from '@/composables/useI18n'\nimport type { Locale } from '@/core/i18n/types'\nimport { on, off } from '@/core/rpc'\n\ntype JsonObject = Record<string, unknown>\nconst NO_CHANGE = Symbol('no_change')\n\nconst isPlainObject = (value: unknown): value is JsonObject => {\n  return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n\nconst cloneDeep = <T>(value: T): T => {\n  return JSON.parse(JSON.stringify(value)) as T\n}\n\nconst deepMerge = <T>(base: T, patch: unknown): T => {\n  if (!isPlainObject(base) || !isPlainObject(patch)) {\n    return patch as T\n  }\n\n  const merged: JsonObject = { ...(base as JsonObject) }\n  for (const [key, patchValue] of Object.entries(patch)) {\n    const baseValue = (base as JsonObject)[key]\n    if (isPlainObject(baseValue) && isPlainObject(patchValue)) {\n      merged[key] = deepMerge(baseValue, patchValue)\n      continue\n    }\n    merged[key] = patchValue\n  }\n\n  return merged as T\n}\n\nconst buildPatch = (before: unknown, after: unknown): unknown | typeof NO_CHANGE => {\n  if (Object.is(before, after)) {\n    return NO_CHANGE\n  }\n\n  if (Array.isArray(before) || Array.isArray(after)) {\n    return JSON.stringify(before) === JSON.stringify(after) ? NO_CHANGE : after\n  }\n\n  if (isPlainObject(before) && isPlainObject(after)) {\n    const patch: JsonObject = {}\n\n    for (const [key, afterValue] of Object.entries(after)) {\n      const diff = buildPatch((before as JsonObject)[key], afterValue)\n      if (diff !== NO_CHANGE) {\n        patch[key] = diff\n      }\n    }\n\n    return Object.keys(patch).length > 0 ? patch : NO_CHANGE\n  }\n\n  return after\n}\n\nexport const useSettingsStore = defineStore('settings', () => {\n  const appSettings = ref<AppSettings>(DEFAULT_APP_SETTINGS)\n  const runtimeCapabilities = ref<RuntimeCapabilities | null>(null)\n  const commandDescriptors = ref<FeatureDescriptor[]>([])\n  const isLoading = ref(false)\n  const isLoadingCommands = ref(false)\n  const error = ref<string | null>(null)\n  const commandsError = ref<string | null>(null)\n  const isInitialized = ref(false)\n  const commandsLoaded = ref(false)\n  const { setLocale } = useI18n()\n  let settingsChangedHandler: ((params: unknown) => void) | null = null\n  let refreshTimer: ReturnType<typeof setTimeout> | null = null\n  let refreshInFlight: Promise<void> | null = null\n  let commandsInFlight: Promise<void> | null = null\n\n  const loadCommandsOnce = async () => {\n    if (commandsLoaded.value) {\n      return\n    }\n\n    if (commandsInFlight) {\n      await commandsInFlight\n      return\n    }\n\n    commandsInFlight = (async () => {\n      isLoadingCommands.value = true\n      try {\n        const commands = await featuresApi.getAll()\n        commandDescriptors.value = commands\n        commandsLoaded.value = true\n        commandsError.value = null\n      } catch (e) {\n        commandsError.value = (e as Error).message\n      } finally {\n        isLoadingCommands.value = false\n        commandsInFlight = null\n      }\n    })()\n\n    await commandsInFlight\n  }\n\n  const refreshFromBackend = async () => {\n    if (refreshInFlight) {\n      await refreshInFlight\n      return\n    }\n\n    refreshInFlight = (async () => {\n      try {\n        const settings = await settingsApi.get()\n        appSettings.value = settings\n        error.value = null\n      } catch (e) {\n        error.value = (e as Error).message\n      } finally {\n        refreshInFlight = null\n      }\n    })()\n\n    await refreshInFlight\n  }\n\n  const scheduleRefreshFromBackend = () => {\n    if (refreshTimer) {\n      return\n    }\n\n    refreshTimer = setTimeout(() => {\n      refreshTimer = null\n      void refreshFromBackend()\n    }, 120)\n  }\n\n  const subscribeSettingsChanged = () => {\n    if (settingsChangedHandler) {\n      return\n    }\n\n    settingsChangedHandler = () => {\n      scheduleRefreshFromBackend()\n    }\n    on('settings.changed', settingsChangedHandler)\n  }\n\n  const unsubscribeSettingsChanged = () => {\n    if (settingsChangedHandler) {\n      off('settings.changed', settingsChangedHandler)\n      settingsChangedHandler = null\n    }\n    if (refreshTimer) {\n      clearTimeout(refreshTimer)\n      refreshTimer = null\n    }\n  }\n\n  const init = async () => {\n    if (isInitialized.value) return\n\n    isLoading.value = true\n    error.value = null\n    try {\n      const [settings, capabilities] = await Promise.all([\n        settingsApi.get(),\n        settingsApi.getRuntimeCapabilities().catch(() => null),\n      ])\n      appSettings.value = settings\n      runtimeCapabilities.value = capabilities\n\n      // 同步语言设置到 i18n\n      const language = settings.app.language.current as Locale\n      await setLocale(language)\n\n      isInitialized.value = true\n      subscribeSettingsChanged()\n      void loadCommandsOnce()\n    } catch (e) {\n      error.value = (e as Error).message\n    } finally {\n      isLoading.value = false\n    }\n  }\n\n  // 监听语言设置变化，自动同步到 i18n\n  watch(\n    () => appSettings.value.app.language.current,\n    async (newLanguage) => {\n      if (isInitialized.value) {\n        await setLocale(newLanguage as Locale)\n      }\n    }\n  )\n\n  const updateSettings = async (newSettings: Partial<AppSettings>) => {\n    // 乐观更新\n    const oldSettings = cloneDeep(appSettings.value)\n    const nextSettings = deepMerge(oldSettings, newSettings) as AppSettings\n    const patch = buildPatch(oldSettings, nextSettings)\n\n    if (patch === NO_CHANGE) {\n      return\n    }\n\n    appSettings.value = nextSettings\n\n    try {\n      await settingsApi.patch(patch as Partial<AppSettings>)\n    } catch (e) {\n      // 回滚\n      appSettings.value = oldSettings\n      error.value = (e as Error).message\n      throw e\n    }\n  }\n\n  const clearError = () => {\n    error.value = null\n  }\n\n  const dispose = () => {\n    unsubscribeSettingsChanged()\n  }\n\n  return {\n    appSettings,\n    runtimeCapabilities,\n    commandDescriptors,\n    isLoading,\n    isLoadingCommands,\n    error,\n    commandsError,\n    isInitialized,\n    commandsLoaded,\n    init,\n    loadCommandsOnce,\n    updateSettings,\n    clearError,\n    dispose,\n  }\n})\n"
  },
  {
    "path": "web/src/features/settings/types.ts",
    "content": "// 功能描述符（从后端获取）\nexport interface FeatureDescriptor {\n  id: string // 唯一标识\n  i18nKey: string // i18n 键\n  isToggle: boolean // 是否为切换类型\n}\n\n// 菜单项（用于显示和编辑）\nexport interface MenuItem {\n  id: string // 项目 ID\n  enabled: boolean // 是否启用\n  order?: number // 显示顺序（-1 表示未启用/无顺序）\n}\n\n// 运行时能力信息（来自后端 runtime_info.get）\nexport interface RuntimeCapabilities {\n  isDebugBuild: boolean\n  version: string\n  majorVersion: number\n  minorVersion: number\n  patchVersion: number\n  buildNumber: number\n  osName: string\n  osMajorVersion: number\n  osMinorVersion: number\n  osBuildNumber: number\n  isWebview2Available: boolean\n  webview2Version: string\n  isCaptureSupported: boolean\n  isCursorCaptureControlSupported: boolean\n  isBorderControlSupported: boolean\n  isProcessLoopbackAudioSupported: boolean\n}\n\n// 当前欢迎流程版本\nexport const CURRENT_ONBOARDING_FLOW_VERSION = 1\n\n// Web 主题模式（页面主题）\nexport type WebThemeMode = 'light' | 'dark' | 'system'\n\n// 浮窗主题模式\nexport type FloatingWindowThemeMode = 'dark' | 'light'\n\n// 浮窗颜色配置\nexport interface FloatingWindowColors {\n  background: string // 主背景色 (包含透明度)\n  separator: string // 分隔线颜色 (包含透明度)\n  text: string // 文字颜色 (包含透明度)\n  indicator: string // 指示器颜色 (包含透明度)\n  hover: string // 悬停背景色 (包含透明度)\n  titleBar: string // 标题栏颜色 (包含透明度)\n  scrollIndicator: string // 滚动条颜色 (包含透明度)\n}\n\n// Web 背景设置\nexport interface WebBackgroundSettings {\n  type: 'none' | 'image'\n  imageFileName: string\n  backgroundBlurAmount: number\n  backgroundOpacity: number\n  overlayColors: string[]\n  primaryColor: string\n  overlayOpacity: number\n  surfaceOpacity: number\n}\n\n// Web 主题设置\nexport interface WebThemeSettings {\n  mode: WebThemeMode\n  customCss: string\n}\n\n// 深色主题颜色配置\nexport const DARK_FLOATING_WINDOW_COLORS: FloatingWindowColors = {\n  background: '#1f1f1fB3',\n  separator: '#333333B3',\n  text: '#D8D8D8FF',\n  indicator: '#FBBF24FF',\n  hover: '#505050CC',\n  titleBar: '#1f1f1fB3',\n  scrollIndicator: '#808080CC',\n}\n\n// 浅色主题颜色配置\nexport const LIGHT_FLOATING_WINDOW_COLORS: FloatingWindowColors = {\n  background: '#F5F5F5CC',\n  separator: '#E5E5E5CC',\n  text: '#2E2E2EFF',\n  indicator: '#F59E0BFF',\n  hover: '#E5E5E5CC',\n  titleBar: '#F5F5F5CC',\n  scrollIndicator: '#BDBDBDCC',\n}\n\n// 浮窗布局配置\nexport interface FloatingWindowLayout {\n  baseItemHeight: number\n  baseTitleHeight: number\n  baseSeparatorHeight: number\n  baseFontSize: number\n  baseTextPadding: number\n  baseIndicatorWidth: number\n  baseRatioIndicatorWidth: number\n  baseRatioColumnWidth: number\n  baseResolutionColumnWidth: number\n  baseSettingsColumnWidth: number\n  baseScrollIndicatorWidth: number\n  maxVisibleRows: number\n}\n\n// 完整的应用设置类型\nexport interface AppSettings {\n  version: number\n\n  // app 分组 - 应用核心设置\n  app: {\n    // 始终以管理员权限运行\n    alwaysRunAsAdmin: boolean\n\n    // 首次引导\n    onboarding: {\n      completed: boolean\n      flowVersion: number\n    }\n\n    // 快捷键设置\n    hotkey: {\n      floatingWindow: {\n        modifiers: number // MOD_CONTROL = 2\n        key: number // VK_OEM_3 (`) = 192\n      }\n      screenshot: {\n        modifiers: number // 无修饰键 = 0\n        key: number // VK_F11 = 122\n      }\n      recording: {\n        modifiers: number // 无修饰键 = 0\n        key: number // VK_F8 = 119\n      }\n    }\n\n    // 语言设置\n    language: {\n      current: string // zh-CN, en-US\n    }\n\n    // 日志设置\n    logger: {\n      level: string // DEBUG, INFO, ERROR\n    }\n  }\n\n  // window 分组 - 窗口相关设置\n  window: {\n    targetTitle: string // 目标窗口标题\n    centerLockCursor: boolean // 锁鼠时强制居中\n    enableLayeredCaptureWorkaround: boolean // 超屏时临时启用 layered 捕获兼容方案\n    resetResolution: {\n      width: number // 重置窗口宽度，0=跟随屏幕\n      height: number // 重置窗口高度，0=跟随屏幕\n    }\n  }\n\n  // features 分组 - 功能特性设置\n  features: {\n    outputDirPath: string // 统一输出目录（截图+录制），空=默认 Videos/SpinningMomo\n    externalAlbumPath: string // 外部游戏相册目录路径（为空时回退到输出目录）\n\n    // 黑边模式设置\n    letterbox: {\n      enabled: boolean // 是否启用黑边模式\n    }\n\n    // 动态照片设置\n    motionPhoto: {\n      duration: number // 视频时长（秒）\n      resolution: number // 短边分辨率: 0=原始不缩放, 720/1080/1440/2160\n      fps: number // 帧率\n      bitrate: number // 比特率 (bps)，CBR 模式使用\n      quality: number // 质量值 (0-100)，VBR 模式使用\n      rateControl: 'cbr' | 'vbr' // 码率控制模式\n      codec: 'h264' | 'h265' // 编码格式\n      audioSource: 'none' | 'system' | 'game_only' // 音频源\n      audioBitrate: number // 音频码率\n    }\n\n    // 即时回放设置（录制参数继承自 recording）\n    replayBuffer: {\n      duration: number // 回放时长（秒）\n    }\n\n    // 录制功能设置\n    recording: {\n      fps: number // 帧率: 30, 60, 120\n      bitrate: number // 比特率 (bps)，CBR 模式使用\n      quality: number // 质量值 (0-100)，VBR 模式使用\n      qp: number // 量化参数 (0-51)，ManualQP 模式使用\n      rateControl: 'cbr' | 'vbr' | 'manual_qp' // 码率控制模式（默认 VBR）\n      encoderMode: 'auto' | 'gpu' | 'cpu' // 编码器模式\n      codec: 'h264' | 'h265' // 视频编码格式\n      captureClientArea: boolean // 是否只捕获客户区（无边框）\n      captureCursor: boolean // 是否捕获鼠标指针\n      autoRestartOnResize: boolean // 尺寸变化时是否自动切段重启录制\n      audioSource: 'none' | 'system' | 'game_only' // 音频源\n      audioBitrate: number // 音频码率 (bps)\n    }\n  }\n\n  // update 分组 - 更新设置\n  update: {\n    autoCheck: boolean // 是否自动检查更新\n    autoUpdateOnExit: boolean // 是否在退出时自动更新\n    versionUrl: string // 版本检查URL（Cloudflare Pages）\n    downloadSources: Array<{\n      name: string // 源名称\n      urlTemplate: string // URL模板，支持 {version} 和 {filename} 占位符\n    }>\n  }\n\n  // ui 分组 - UI界面设置\n  ui: {\n    // 应用菜单配置\n    appMenu: {\n      features: string[] // 启用的功能项（有则启用，顺序即菜单显示顺序）\n      aspectRatios: string[] // 启用的比例列表\n      resolutions: string[] // 启用的分辨率列表\n    }\n\n    // 浮窗布局配置\n    floatingWindowLayout: FloatingWindowLayout\n\n    // 浮窗颜色配置\n    floatingWindowColors: FloatingWindowColors\n\n    // 浮窗主题模式\n    floatingWindowThemeMode: FloatingWindowThemeMode\n\n    // WebView 主窗口尺寸和位置（持久化）\n    // x/y 为 -1 表示未保存过，首次启动时居中\n    webviewWindow: {\n      width: number\n      height: number\n      x: number\n      y: number\n      enableTransparentBackground: boolean\n    }\n\n    // Web UI 设置\n    webTheme: WebThemeSettings\n    background: WebBackgroundSettings\n  }\n\n  // 拓展配置\n  extensions: {\n    infinityNikki: {\n      enable: boolean\n      gameDir: string\n      galleryGuideSeen: boolean\n      allowOnlinePhotoMetadataExtract: boolean\n      manageScreenshotHardlinks: boolean\n    }\n  }\n}\n\n// 默认设置值\nexport const DEFAULT_APP_SETTINGS: AppSettings = {\n  version: 1,\n\n  // app 设置\n  app: {\n    alwaysRunAsAdmin: true,\n    onboarding: {\n      completed: true,\n      flowVersion: CURRENT_ONBOARDING_FLOW_VERSION,\n    },\n    hotkey: {\n      floatingWindow: {\n        modifiers: 2, // MOD_CONTROL\n        key: 192, // VK_OEM_3 (`)\n      },\n      screenshot: {\n        modifiers: 0, // 无修饰键\n        key: 122, // VK_F11\n      },\n      recording: {\n        modifiers: 0, // 无修饰键\n        key: 119, // VK_F8\n      },\n    },\n    language: {\n      current: 'zh-CN',\n    },\n    logger: {\n      level: 'INFO',\n    },\n  },\n\n  // window 设置\n  window: {\n    targetTitle: '',\n    centerLockCursor: false,\n    enableLayeredCaptureWorkaround: false,\n    resetResolution: {\n      width: 0,\n      height: 0,\n    },\n  },\n\n  // features 设置\n  features: {\n    outputDirPath: '',\n    externalAlbumPath: '',\n    letterbox: {\n      enabled: false,\n    },\n    motionPhoto: {\n      duration: 3,\n      resolution: 0,\n      fps: 30,\n      bitrate: 10000000,\n      quality: 80,\n      rateControl: 'vbr',\n      codec: 'h264',\n      audioSource: 'system',\n      audioBitrate: 192000,\n    },\n    replayBuffer: {\n      duration: 30,\n    },\n    recording: {\n      fps: 60,\n      bitrate: 80000000,\n      quality: 80,\n      qp: 23,\n      rateControl: 'vbr',\n      encoderMode: 'auto',\n      codec: 'h264',\n      captureClientArea: true,\n      captureCursor: false,\n      autoRestartOnResize: true,\n      audioSource: 'system',\n      audioBitrate: 320000,\n    },\n  },\n\n  // update 设置\n  update: {\n    autoCheck: true,\n    autoUpdateOnExit: false,\n    versionUrl: 'https://spin.infinitymomo.com/version.txt',\n    downloadSources: [\n      {\n        name: 'GitHub',\n        urlTemplate: 'https://github.com/ChanIok/SpinningMomo/releases/download/v{0}/{1}',\n      },\n      {\n        name: 'Mirror',\n        urlTemplate: 'https://r2.infinitymomo.com/releases/v{0}/{1}',\n      },\n    ],\n  },\n\n  // ui 设置\n  ui: {\n    appMenu: {\n      features: [\n        'screenshot.capture',\n        'recording.toggle',\n        'preview.toggle',\n        'overlay.toggle',\n        'window.reset',\n        'app.main',\n        'app.exit',\n        'output.open_folder',\n        'external_album.open_folder',\n        'letterbox.toggle',\n      ],\n      aspectRatios: ['21:9', '16:9', '3:2', '1:1', '3:4', '2:3', '9:16'],\n      resolutions: ['Default', '1080P', '2K', '4K', '6K', '8K', '12K'],\n    },\n    floatingWindowLayout: {\n      baseItemHeight: 24,\n      baseTitleHeight: 26,\n      baseSeparatorHeight: 0,\n      baseFontSize: 12,\n      baseTextPadding: 12,\n      baseIndicatorWidth: 3,\n      baseRatioIndicatorWidth: 4,\n      baseRatioColumnWidth: 60,\n      baseResolutionColumnWidth: 70,\n      baseSettingsColumnWidth: 80,\n      baseScrollIndicatorWidth: 3,\n      maxVisibleRows: 7,\n    },\n    floatingWindowColors: DARK_FLOATING_WINDOW_COLORS,\n    floatingWindowThemeMode: 'dark',\n    webviewWindow: {\n      width: 900,\n      height: 600,\n      x: -1,\n      y: -1,\n      enableTransparentBackground: false,\n    },\n    webTheme: {\n      mode: 'light',\n      customCss: '',\n    },\n    background: {\n      type: 'none',\n      imageFileName: '',\n      backgroundBlurAmount: 0,\n      backgroundOpacity: 1,\n      // 与 overlayPalette 中首个浅色预设（peach）一致\n      overlayColors: ['#F8F0E3'],\n      primaryColor: '#F59E0B',\n      overlayOpacity: 0.8,\n      surfaceOpacity: 1,\n    },\n  },\n\n  extensions: {\n    infinityNikki: {\n      enable: false,\n      gameDir: '',\n      galleryGuideSeen: false,\n      allowOnlinePhotoMetadataExtract: false,\n      manageScreenshotHardlinks: false,\n    },\n  },\n} as const\n"
  },
  {
    "path": "web/src/features/settings/utils/hotkeyUtils.ts",
    "content": "// 修饰键映射\nexport const MODIFIER_MAP = {\n  alt: 1,\n  ctrl: 2,\n  shift: 4,\n  win: 8,\n} as const\n\n// 修饰键显示映射\nexport const MODIFIER_DISPLAY: Record<number, string> = {\n  1: 'Alt',\n  2: 'Ctrl',\n  3: 'Ctrl + Alt',\n  4: 'Shift',\n  5: 'Shift + Alt',\n  6: 'Ctrl + Shift',\n  7: 'Ctrl + Alt + Shift',\n  8: 'Win',\n  9: 'Win + Alt',\n  10: 'Ctrl + Win',\n  11: 'Ctrl + Alt + Win',\n  12: 'Shift + Win',\n  13: 'Shift + Alt + Win',\n  14: 'Ctrl + Shift + Win',\n  15: 'Ctrl + Alt + Shift + Win',\n}\n\n// 主键映射\nexport const KEY_CODE_MAP: Record<number, string> = {\n  // 字母\n  65: 'A',\n  66: 'B',\n  67: 'C',\n  68: 'D',\n  69: 'E',\n  70: 'F',\n  71: 'G',\n  72: 'H',\n  73: 'I',\n  74: 'J',\n  75: 'K',\n  76: 'L',\n  77: 'M',\n  78: 'N',\n  79: 'O',\n  80: 'P',\n  81: 'Q',\n  82: 'R',\n  83: 'S',\n  84: 'T',\n  85: 'U',\n  86: 'V',\n  87: 'W',\n  88: 'X',\n  89: 'Y',\n  90: 'Z',\n\n  // 数字\n  48: '0',\n  49: '1',\n  50: '2',\n  51: '3',\n  52: '4',\n  53: '5',\n  54: '6',\n  55: '7',\n  56: '8',\n  57: '9',\n\n  // 功能键\n  112: 'F1',\n  113: 'F2',\n  114: 'F3',\n  115: 'F4',\n  116: 'F5',\n  117: 'F6',\n  118: 'F7',\n  119: 'F8',\n  120: 'F9',\n  121: 'F10',\n  122: 'F11',\n  123: 'F12',\n\n  // 特殊键\n  8: 'Backspace',\n  9: 'Tab',\n  13: 'Enter',\n  27: 'Escape',\n  32: 'Space',\n  37: 'Left',\n  38: 'Up',\n  39: 'Right',\n  40: 'Down',\n  188: ',',\n  190: '.',\n  191: '/',\n  186: ';',\n  222: \"'\",\n  219: '[',\n  221: ']',\n  189: '-',\n  187: '=',\n  192: '`',\n  220: '\\\\',\n\n  // 系统特殊键\n  44: 'PrintScreen',\n  145: 'ScrollLock',\n  19: 'Pause',\n  5: 'Mouse4',\n  6: 'Mouse5',\n  45: 'Insert',\n  46: 'Delete',\n  36: 'Home',\n  35: 'End',\n  33: 'PageUp',\n  34: 'PageDown',\n\n  // 数字键盘\n  96: 'Numpad0',\n  97: 'Numpad1',\n  98: 'Numpad2',\n  99: 'Numpad3',\n  100: 'Numpad4',\n  101: 'Numpad5',\n  102: 'Numpad6',\n  103: 'Numpad7',\n  104: 'Numpad8',\n  105: 'Numpad9',\n  106: 'Numpad*',\n  107: 'Numpad+',\n  109: 'Numpad-',\n  110: 'Numpad.',\n  111: 'Numpad/',\n}\n\n// 反向映射：字符到键码\nexport const KEY_NAME_MAP: Record<string, number> = Object.entries(KEY_CODE_MAP).reduce(\n  (acc, [code, name]) => ({ ...acc, [name]: parseInt(code) }),\n  {}\n)\n\n// 计算修饰键值\nexport const calculateModifiers = (\n  shift: boolean,\n  ctrl: boolean,\n  alt: boolean,\n  win: boolean\n): number => {\n  return (\n    (shift ? MODIFIER_MAP.shift : 0) |\n    (ctrl ? MODIFIER_MAP.ctrl : 0) |\n    (alt ? MODIFIER_MAP.alt : 0) |\n    (win ? MODIFIER_MAP.win : 0)\n  )\n}\n\n// 格式化快捷键显示文本\nexport const formatHotkeyDisplay = (modifiers: number, key: number): string => {\n  // 如果既没有修饰键也没有主键，返回\"\"\n  if (!modifiers && !key) return ''\n\n  const modifierText = MODIFIER_DISPLAY[modifiers] || ''\n  const keyText = key ? KEY_CODE_MAP[key] || String.fromCharCode(key) : ''\n\n  // 如果有修饰键但没有主键，只显示修饰键\n  if (modifiers && !key) return modifierText\n\n  // 如果有修饰键和主键，显示组合\n  if (modifierText && keyText) return `${modifierText} + ${keyText}`\n\n  // 如果只有主键，只显示主键\n  return keyText\n}\n\n// 解析显示文本为修饰键和键码\nexport const parseHotkeyDisplay = (display: string): { modifiers: number; key: number } => {\n  if (display === '' || !display) {\n    return { modifiers: 0, key: 0 }\n  }\n\n  // 分割修饰键和主键\n  const parts = display.split(' + ')\n\n  if (parts.length === 1) {\n    // 只有主键\n    const keyName = parts[0]\n    return { modifiers: 0, key: KEY_NAME_MAP[keyName || ''] || 0 }\n  }\n\n  // 有修饰键和主键\n  const keyName = parts[parts.length - 1]\n  const modifierNames = parts.slice(0, -1)\n\n  let modifiers = 0\n  for (const name of modifierNames) {\n    switch (name) {\n      case 'Shift':\n        modifiers |= MODIFIER_MAP.shift\n        break\n      case 'Ctrl':\n        modifiers |= MODIFIER_MAP.ctrl\n        break\n      case 'Alt':\n        modifiers |= MODIFIER_MAP.alt\n        break\n      case 'Win':\n        modifiers |= MODIFIER_MAP.win\n        break\n    }\n  }\n\n  return {\n    modifiers,\n    key: KEY_NAME_MAP[keyName || ''] || 0,\n  }\n}\n"
  },
  {
    "path": "web/src/index.css",
    "content": "@import 'tailwindcss';\n@import 'tw-animate-css';\n@import '@fontsource-variable/inter/wght.css';\n\n@custom-variant dark (&:is(.dark *));\n\n@theme inline {\n  --radius-sm: calc(var(--radius) - 4px);\n  --radius-md: calc(var(--radius) - 2px);\n  --radius-lg: var(--radius);\n  --radius-xl: calc(var(--radius) + 4px);\n  --color-background: var(--background);\n  --color-foreground: var(--foreground);\n  --color-card: var(--card);\n  --color-card-foreground: var(--card-foreground);\n  --color-popover: var(--popover);\n  --color-popover-foreground: var(--popover-foreground);\n  --color-primary: var(--primary);\n  --color-primary-foreground: var(--primary-foreground);\n  --color-secondary: var(--secondary);\n  --color-secondary-foreground: var(--secondary-foreground);\n  --color-muted: var(--muted);\n  --color-muted-foreground: var(--muted-foreground);\n  --color-accent: var(--accent);\n  --color-accent-foreground: var(--accent-foreground);\n  --color-destructive: var(--destructive);\n  --color-destructive-foreground: var(--destructive-foreground);\n  --color-border: var(--border);\n  --color-input: var(--input);\n  --color-ring: var(--ring);\n  --color-sidebar: var(--sidebar);\n  --color-sidebar-foreground: var(--sidebar-foreground);\n  --color-sidebar-primary: var(--sidebar-primary);\n  --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);\n  --color-sidebar-accent: var(--sidebar-accent);\n  --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);\n  --color-sidebar-hover: var(--sidebar-hover);\n  --color-sidebar-border: var(--sidebar-border);\n  --color-sidebar-ring: var(--sidebar-ring);\n  --color-header: var(--header);\n  --color-header-foreground: var(--header-foreground);\n  --color-header-accent: var(--header-accent);\n  --color-header-accent-foreground: var(--header-accent-foreground);\n  --color-statusbar: var(--statusbar);\n  --color-statusbar-foreground: var(--statusbar-foreground);\n  --color-panel: var(--panel);\n  --color-panel-foreground: var(--panel-foreground);\n  --color-surface-bottom: var(--surface-bottom);\n  --color-surface-middle: var(--surface-middle);\n  --color-surface-top: var(--surface-top);\n}\n\n:root {\n  --radius: 0.625rem;\n  --app-font-latin: 'Inter Variable', 'Inter';\n  --app-font-cjk:\n    'Microsoft YaHei UI', 'Microsoft YaHei', 'PingFang SC', 'Hiragino Sans GB', 'Noto Sans CJK SC',\n    'Source Han Sans SC';\n  --app-font-fallback:\n    ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,\n    'Helvetica Neue', Arial, 'Noto Sans', sans-serif;\n  --app-background-image: none;\n  --app-background-opacity: 1;\n  --app-background-visibility: 0;\n  --app-background-blur: 0px;\n  --app-background-scale: 1.02;\n  --app-background-overlay-image: linear-gradient(to bottom right, #000000, #000000);\n  --app-background-overlay-opacity: 0;\n  --route-transition-duration: 180ms;\n  --route-transition-easing: cubic-bezier(0.22, 1, 0.36, 1);\n  --surface-opacity: 1;\n  --surface-bottom: #efefef;\n  --surface-middle: rgb(255 255 255 / 0.56);\n  --surface-top: #fefefe;\n  /* Light Modern */\n  --background: #ffffff;\n  --foreground: #1b1b1b;\n  --card: var(--surface-middle);\n  --card-foreground: #3b3b3b;\n  --popover: var(--surface-top);\n  --popover-foreground: #3b3b3b;\n  --primary: #f59e0b;\n  --primary-foreground: #ffffff;\n  --secondary: #e5e5e5;\n  --secondary-foreground: #3b3b3b;\n  --muted: #ececec;\n  --muted-foreground: #868686;\n  --accent: #f2f2f2;\n  --accent-foreground: var(--foreground);\n  --destructive: #c72e0f;\n  --destructive-foreground: #ffffff;\n  --border: #e5e5e5;\n  --input: #e5e5e5;\n  --ring: #f59e0b;\n  --sidebar: var(--surface-middle);\n  --sidebar-foreground: #2e2e2e;\n  --sidebar-primary: #f59e0b;\n  --sidebar-primary-foreground: #ffffff;\n  --sidebar-accent: var(--surface-top);\n  --sidebar-accent-foreground: #000000;\n  --sidebar-hover: rgb(0 0 0 / 0.04);\n  --sidebar-border: #e5e5e5;\n  --sidebar-ring: #f59e0b;\n  --header: #f8f8f8;\n  --header-foreground: #1e1e1e;\n  --header-accent: #e4e4e4;\n  --header-accent-foreground: #1e1e1e;\n  --statusbar: #f8f8f8;\n  --statusbar-foreground: #3b3b3b;\n  --panel: #f8f8f8;\n  --panel-foreground: #3b3b3b;\n}\n\n.dark {\n  /* Dark Modern */\n  --surface-bottom: #191919;\n  --surface-middle: rgb(255 255 255 / 0.06);\n  --surface-top: rgb(255 255 255 / 0.1);\n  --background: var(--surface-bottom);\n  --foreground: #ececec;\n  --card: var(--surface-middle);\n  --card-foreground: #ececec;\n  --popover: #303030;\n  --popover-foreground: #ececec;\n  --primary: #fbbf24;\n  --primary-foreground: #18181b;\n  --secondary: #313131;\n  --secondary-foreground: #ececec;\n  --muted: #181818;\n  --muted-foreground: #9d9d9d;\n  --accent: var(--surface-top);\n  --accent-foreground: #ececec;\n  --destructive: #f85149;\n  --destructive-foreground: #ffffff;\n  --border: #ffffff1a;\n  --input: #ffffff1a;\n  --ring: #fbbf24;\n  --sidebar: var(--surface-middle);\n  --sidebar-foreground: #ececec;\n  --sidebar-primary: #fbbf24;\n  --sidebar-primary-foreground: #18181b;\n  --sidebar-accent: var(--surface-top);\n  --sidebar-accent-foreground: #ececec;\n  --sidebar-hover: rgb(255 255 255 / 0.06);\n  --sidebar-border: #ffffff1a;\n  --sidebar-ring: #fbbf24;\n  --header: #181818;\n  --header-foreground: #ececec;\n  --header-accent: #2d2d2d;\n  --header-accent-foreground: #ececec;\n  --statusbar: #181818;\n  --statusbar-foreground: #ececec;\n  --panel: #181818;\n  --panel-foreground: #ececec;\n}\n\n.surface-bottom,\n.surface-middle,\n.surface-top {\n  background-color: color-mix(\n    in srgb,\n    var(--surface-layer-color, var(--surface-middle))\n      calc(var(--surface-opacity, 1) * var(--surface-opacity-scale, 1) * 100%),\n    transparent\n  );\n}\n\n.surface-bottom {\n  --surface-layer-color: var(--surface-bottom);\n}\n\n.surface-middle {\n  --surface-layer-color: var(--surface-middle);\n}\n\n.surface-top {\n  --surface-layer-color: var(--surface-top);\n}\n\n.app-background-image {\n  background-image: var(--app-background-image, none);\n  background-size: cover;\n  background-position: center;\n  background-repeat: no-repeat;\n  opacity: calc(var(--app-background-opacity, 1) * var(--app-background-visibility, 0));\n  filter: blur(var(--app-background-blur, 0px));\n  transform: scale(var(--app-background-scale, 1));\n  transition:\n    opacity 220ms cubic-bezier(0.22, 1, 0.36, 1),\n    filter 220ms cubic-bezier(0.22, 1, 0.36, 1),\n    transform 260ms cubic-bezier(0.22, 1, 0.36, 1);\n  will-change: transform, filter, opacity;\n}\n\n.app-background-image-no-blur {\n  filter: none;\n}\n\n.app-background-overlay {\n  background-image: var(\n    --app-background-overlay-image,\n    linear-gradient(to bottom right, #000000, #000000)\n  );\n  opacity: var(--app-background-overlay-opacity, 0);\n  transition: opacity 220ms ease;\n}\n\n.app-background-overlay-home-clip {\n  clip-path: inset(0 calc(100% - 3.5rem) 0 0);\n}\n\n.activity-bar-background-blur {\n  -webkit-backdrop-filter: blur(var(--app-background-blur, 0px));\n  backdrop-filter: blur(var(--app-background-blur, 0px));\n  background-color: rgb(0 0 0 / 0.01);\n}\n\n.route-scene {\n  view-transition-name: route-scene;\n}\n\n::view-transition-group(route-scene) {\n  animation-duration: var(--route-transition-duration);\n  animation-timing-function: var(--route-transition-easing);\n}\n\n::view-transition-old(route-scene) {\n  animation-name: route-scene-fade-out;\n}\n\n::view-transition-new(route-scene) {\n  animation-name: route-scene-fade-in;\n}\n\n@keyframes route-scene-fade-out {\n  from {\n    opacity: 1;\n  }\n  to {\n    opacity: 0;\n  }\n}\n\n@keyframes route-scene-fade-in {\n  from {\n    opacity: 0;\n  }\n  to {\n    opacity: 1;\n  }\n}\n\n@layer base {\n  * {\n    @apply border-border outline-ring/50;\n  }\n  body {\n    @apply bg-transparent text-foreground;\n    font-family:\n      var(--app-font-latin), var(--app-font-cjk), var(--app-font-fallback), 'Apple Color Emoji',\n      'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';\n    -webkit-font-smoothing: antialiased;\n    -moz-osx-font-smoothing: grayscale;\n  }\n}\n\n@layer utilities {\n  .font-tnum {\n    font-feature-settings:\n      'tnum' 1,\n      'lnum' 1;\n  }\n}\n\n[data-sonner-toast][data-styled='true'] [data-description] {\n  color: var(--foreground) !important;\n  opacity: 0.78;\n}\n"
  },
  {
    "path": "web/src/lib/utils.ts",
    "content": "import type { ClassValue } from 'clsx'\nimport { clsx } from 'clsx'\nimport { twMerge } from 'tailwind-merge'\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n\nexport function formatFileSize(bytes?: number): string {\n  if (!bytes || bytes <= 0) return '-'\n\n  const units = ['B', 'KB', 'MB', 'GB', 'TB']\n  let size = bytes\n  let unitIndex = 0\n  while (size >= 1024 && unitIndex < units.length - 1) {\n    size /= 1024\n    unitIndex++\n  }\n\n  return `${size.toFixed(unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`\n}\n\nexport async function copyToClipboard(text: string): Promise<boolean> {\n  try {\n    await navigator.clipboard.writeText(text)\n    return true\n  } catch {\n    return false\n  }\n}\n"
  },
  {
    "path": "web/src/main.ts",
    "content": "import { createApp } from 'vue'\nimport { watch } from 'vue'\nimport { createPinia } from 'pinia'\nimport router from './router'\nimport { setupRouterGuards } from './router/guards'\nimport { initializeRPC } from '@/core/rpc'\nimport { initI18n } from '@/core/i18n'\nimport { useSettingsStore } from '@/features/settings/store'\nimport { CURRENT_ONBOARDING_FLOW_VERSION } from '@/features/settings/types'\nimport { applyAppearanceToDocument } from '@/features/settings/appearance'\nimport { useTaskStore } from '@/core/tasks/store'\nimport './index.css'\nimport App from './App.vue'\n\n// 创建 Pinia 实例\nconst pinia = createPinia()\n\nconst app = createApp(App)\n\n// 注册插件\napp.use(pinia)\napp.use(router)\n\n// 设置路由守卫\nsetupRouterGuards(router)\n\n// 初始化 RPC 通信\ninitializeRPC()\n\n// 初始化应用\n;(async () => {\n  // 首先初始化 i18n（使用默认语言）\n  await initI18n('zh-CN')\n\n  // 然后初始化 settings store，它会自动同步后端的语言设置\n  const settingsStore = useSettingsStore()\n  await settingsStore.init()\n\n  // 初始化后台任务订阅\n  const taskStore = useTaskStore()\n  await taskStore.initialize()\n\n  const onboarding = settingsStore.appSettings.app.onboarding\n  const needsOnboarding =\n    !onboarding.completed || onboarding.flowVersion < CURRENT_ONBOARDING_FLOW_VERSION\n  if (needsOnboarding && router.currentRoute.value.name !== 'welcome') {\n    await router.replace('/welcome')\n  }\n\n  // 在挂载前应用主题和背景，避免首屏闪烁\n  applyAppearanceToDocument(settingsStore.appSettings)\n\n  // 监听设置变化，实时同步外观\n  watch(\n    () => [\n      settingsStore.appSettings.ui.webTheme.mode,\n      settingsStore.appSettings.ui.webTheme.customCss,\n      settingsStore.appSettings.ui.background.type,\n      settingsStore.appSettings.ui.background.imageFileName,\n      settingsStore.appSettings.ui.background.backgroundBlurAmount,\n      settingsStore.appSettings.ui.background.backgroundOpacity,\n      settingsStore.appSettings.ui.background.overlayColors.join('|'),\n      settingsStore.appSettings.ui.background.primaryColor,\n      settingsStore.appSettings.ui.background.overlayOpacity,\n      settingsStore.appSettings.ui.background.surfaceOpacity,\n    ],\n    () => {\n      applyAppearanceToDocument(settingsStore.appSettings)\n    }\n  )\n\n  // 最后挂载应用\n  app.mount('#app')\n})()\n"
  },
  {
    "path": "web/src/router/guards.ts",
    "content": "import type { Router } from 'vue-router'\nimport { useSettingsStore } from '@/features/settings/store'\nimport { CURRENT_ONBOARDING_FLOW_VERSION } from '@/features/settings/types'\n\n/**\n * 路由守卫配置\n */\n\n// 全局前置守卫\nexport function setupRouterGuards(router: Router) {\n  router.beforeEach((to, _from, next) => {\n    const settingsStore = useSettingsStore()\n    if (settingsStore.isInitialized) {\n      const onboarding = settingsStore.appSettings.app.onboarding\n      const needsOnboarding =\n        !onboarding.completed || onboarding.flowVersion < CURRENT_ONBOARDING_FLOW_VERSION\n\n      if (needsOnboarding && to.name !== 'welcome') {\n        next({ name: 'welcome', replace: true })\n        return\n      }\n\n      if (!needsOnboarding && to.name === 'welcome') {\n        next({ name: 'home', replace: true })\n        return\n      }\n    }\n\n    // 设置页面标题\n    if (to.meta?.title) {\n      document.title = `${to.meta.title} - SpinningMomo`\n    } else {\n      document.title = 'SpinningMomo'\n    }\n\n    // 这里可以添加权限验证、登录状态检查等逻辑\n    // 例如：\n    // if (to.meta.requiresAuth && !isAuthenticated()) {\n    //   next('/login')\n    //   return\n    // }\n\n    next()\n  })\n\n  router.afterEach((to, from) => {\n    // 路由切换后的逻辑，如埋点统计等\n    console.log(`导航从 ${from.path} 到 ${to.path}`)\n  })\n\n  router.onError((error) => {\n    console.error('路由错误:', error)\n    // 可以在这里添加错误处理逻辑，如跳转到错误页面\n  })\n}\n"
  },
  {
    "path": "web/src/router/index.ts",
    "content": "import { createRouter, createWebHashHistory } from 'vue-router'\nimport type { RouteRecordRaw } from 'vue-router'\n\n// 懒加载页面组件\nconst HomePage = () => import('@/features/home/pages/HomePage.vue')\nconst OnboardingPage = () => import('@/features/onboarding/pages/OnboardingPage.vue')\nconst SettingsPage = () => import('@/features/settings/pages/SettingsPage.vue')\nconst AboutPage = () => import('@/features/about/pages/AboutPage.vue')\nconst MapPage = () => import('@/features/map/pages/MapPage.vue')\n\n// 导入playground路由\nimport { routes as playgroundRoutes } from '@/features/playground'\nimport galleryRoutes from '@/features/gallery/routes'\nimport NotFoundPage from '@/features/common/pages/NotFoundPage.vue'\n\n// 基础路由配置\nconst routes: RouteRecordRaw[] = [\n  {\n    path: '/',\n    redirect: '/home',\n  },\n  {\n    path: '/home',\n    name: 'home',\n    component: HomePage,\n    meta: {\n      title: '首页',\n    },\n  },\n  {\n    path: '/welcome',\n    name: 'welcome',\n    component: OnboardingPage,\n    meta: {\n      title: '欢迎',\n    },\n  },\n  ...galleryRoutes,\n  {\n    path: '/map',\n    name: 'map',\n    component: MapPage,\n    meta: {\n      title: '地图',\n    },\n  },\n  {\n    path: '/settings',\n    name: 'settings',\n    component: SettingsPage,\n    meta: {\n      title: '设置',\n    },\n  },\n  {\n    path: '/about',\n    name: 'about',\n    component: AboutPage,\n    meta: {\n      title: '关于',\n    },\n  },\n  // 添加playground路由\n  ...playgroundRoutes,\n  {\n    path: '/:pathMatch(.*)*',\n    name: 'not-found',\n    component: NotFoundPage,\n    meta: {\n      title: '页面未找到',\n    },\n  },\n]\n\n// 创建路由实例\nconst router = createRouter({\n  history: createWebHashHistory(),\n  routes,\n})\n\nexport default router\n"
  },
  {
    "path": "web/src/router/viewTransition.ts",
    "content": "import type { RouteLocationRaw, Router } from 'vue-router'\n\ntype MinimalViewTransition = {\n  finished: Promise<void>\n}\n\ntype DocumentWithViewTransition = Document & {\n  startViewTransition?: (updateCallback: () => void | Promise<void>) => MinimalViewTransition\n}\n\nlet transitionQueue: Promise<void> = Promise.resolve()\n\nfunction enqueueTransition(task: () => Promise<void>) {\n  const next = transitionQueue.then(task, task)\n  transitionQueue = next.then(\n    () => undefined,\n    () => undefined\n  )\n  return next\n}\n\nexport function pushWithViewTransition(router: Router, to: RouteLocationRaw) {\n  return enqueueTransition(async () => {\n    const target = router.resolve(to)\n    const current = router.currentRoute.value\n    if (target.fullPath === current.fullPath) {\n      return\n    }\n\n    const doc =\n      typeof document !== 'undefined' ? (document as DocumentWithViewTransition) : undefined\n    if (!doc?.startViewTransition) {\n      await router.push(to)\n      return\n    }\n\n    const transition = doc.startViewTransition(async () => {\n      await router.push(to)\n    })\n\n    await transition.finished.catch(() => undefined)\n  })\n}\n"
  },
  {
    "path": "web/src/types/webview.d.ts",
    "content": "/**\n * WebView2 类型声明\n */\ninterface Window {\n  chrome?: {\n    webview?: {\n      postMessage(message: any): void\n      addEventListener(event: 'message', handler: (event: MessageEvent) => void): void\n      removeEventListener(event: 'message', handler: (event: MessageEvent) => void): void\n    }\n  }\n}\n"
  },
  {
    "path": "web/tsconfig.app.json",
    "content": "{\n  \"extends\": \"@vue/tsconfig/tsconfig.dom.json\",\n  \"compilerOptions\": {\n    \"tsBuildInfoFile\": \"./node_modules/.tmp/tsconfig.app.tsbuildinfo\",\n    \"types\": [\"vite/client\"],\n\n    /* Linting */\n    \"strict\": true,\n    \"noUnusedLocals\": true,\n    \"noUnusedParameters\": true,\n    \"erasableSyntaxOnly\": true,\n    \"noFallthroughCasesInSwitch\": true,\n    \"noUncheckedSideEffectImports\": true,\n    \"baseUrl\": \".\",\n    \"paths\": {\n      \"@/*\": [\"./src/*\"]\n    }\n  },\n  \"include\": [\"src/**/*.ts\", \"src/**/*.tsx\", \"src/**/*.vue\"]\n}\n"
  },
  {
    "path": "web/tsconfig.json",
    "content": "{\n  \"files\": [],\n  \"references\": [{ \"path\": \"./tsconfig.app.json\" }, { \"path\": \"./tsconfig.node.json\" }],\n  \"compilerOptions\": {\n    \"baseUrl\": \".\",\n    \"paths\": {\n      \"@/*\": [\"./src/*\"]\n    }\n  }\n}\n"
  },
  {
    "path": "web/tsconfig.node.json",
    "content": "{\n  \"compilerOptions\": {\n    \"tsBuildInfoFile\": \"./node_modules/.tmp/tsconfig.node.tsbuildinfo\",\n    \"target\": \"ES2023\",\n    \"lib\": [\"ES2023\"],\n    \"module\": \"ESNext\",\n    \"types\": [\"node\"],\n    \"skipLibCheck\": true,\n\n    /* Bundler mode */\n    \"moduleResolution\": \"bundler\",\n    \"allowImportingTsExtensions\": true,\n    \"verbatimModuleSyntax\": true,\n    \"moduleDetection\": \"force\",\n    \"noEmit\": true,\n\n    /* Linting */\n    \"strict\": true,\n    \"noUnusedLocals\": true,\n    \"noUnusedParameters\": true,\n    \"erasableSyntaxOnly\": true,\n    \"noFallthroughCasesInSwitch\": true,\n    \"noUncheckedSideEffectImports\": true\n  },\n  \"baseUrl\": \".\",\n  \"paths\": {\n    \"@/*\": [\"./src/*\"]\n  },\n  \"include\": [\"vite.config.ts\"]\n}\n"
  },
  {
    "path": "web/vite.config.ts",
    "content": "import { defineConfig } from 'vite'\nimport vue from '@vitejs/plugin-vue'\nimport tailwindcss from '@tailwindcss/vite'\nimport { analyzer } from 'vite-bundle-analyzer'\nimport { fileURLToPath, URL } from 'node:url'\n\n// https://vite.dev/config/\nexport default defineConfig({\n  plugins: [vue(), tailwindcss(), analyzer({ analyzerMode: 'static', fileName: '../stats' })],\n  resolve: {\n    alias: {\n      '@': fileURLToPath(new URL('./src', import.meta.url)),\n    },\n  },\n  server: {\n    proxy: {\n      '/rpc': {\n        target: 'http://localhost:51206',\n        changeOrigin: true,\n        secure: false,\n      },\n\n      '/static': {\n        target: 'http://localhost:51206',\n        changeOrigin: true,\n        secure: false,\n      },\n      '/sse': {\n        target: 'http://localhost:51206',\n        changeOrigin: true,\n        secure: false,\n      },\n    },\n  },\n})\n"
  },
  {
    "path": "xmake.lua",
    "content": "add_rules(\"mode.debug\", \"mode.release\")\n\n-- 引入自定义任务\nincludes(\"tasks/build-all.lua\")\nincludes(\"tasks/release.lua\")\nincludes(\"tasks/vs.lua\")\n\n-- 设置C++23标准\nset_languages(\"c++23\")\n\n-- 统一源文件编码\nadd_cxflags(\"/utf-8\", \"/bigobj\")\n\n-- 设置运行时库\nset_runtimes(is_mode(\"debug\") and \"MD\" or \"MT\")\n\nset_policy(\"package.requires_lock\", true)\n\n-- 添加vcpkg依赖包\nadd_requires(\"vcpkg::uwebsockets\", \"vcpkg::spdlog\", \"vcpkg::asio\", \"vcpkg::reflectcpp\", \n             \"vcpkg::webview2\", \"vcpkg::wil\", \"vcpkg::xxhash\", \"vcpkg::sqlitecpp\", \"vcpkg::libwebp\", \"vcpkg::zlib\")\n\ntarget(\"SpinningMomo\")\n    -- 设置为Windows可执行文件\n    set_kind(\"binary\")\n    set_plat(\"windows\")\n    set_arch(\"x64\")\n    \n    -- 启用C++模块支持\n    set_policy(\"build.c++.modules\", true)\n    -- set_policy(\"build.c++.modules.non_cascading_changes\", true)\n\n    -- Release 也保留调试符号，便于分析生产崩溃 dump\n    if is_mode(\"release\") then\n        set_symbols(\"debug\")\n        add_ldflags(\"/DEBUG:FULL\", {force = true})\n        add_ldflags(\"/NODEFAULTLIB:libucrt.lib\", {force = true})\n        add_ldflags(\"/DEFAULTLIB:ucrt.lib\", {force = true})\n    end\n    \n    -- Windows特定宏定义\n    add_defines(\"NOMINMAX\", \"UNICODE\", \"_UNICODE\", \"WIN32_LEAN_AND_MEAN\", \"_WIN32_WINNT=0x0A00\", \"SPDLOG_COMPILED_LIB\", \"yyjson_api_inline=yyjson_inline\")\n    \n    -- 添加包含目录\n    add_includedirs(\"src\")\n    add_includedirs(\"third_party/dkm/include\")\n    \n    -- 添加源文件\n    add_files(\"src/main.cpp\")\n    add_files(\"src/**.cpp\", \"src/**.ixx\")\n    add_files(\"resources/*.rc\", \"resources/*.manifest\")\n    \n    -- 链接vcpkg包\n    add_packages(\"vcpkg::uwebsockets\", \"vcpkg::spdlog\", \"vcpkg::asio\", \"vcpkg::reflectcpp\", \n                 \"vcpkg::webview2\", \"vcpkg::wil\", \"vcpkg::xxhash\", \"vcpkg::sqlitecpp\", \"vcpkg::libwebp\", \"vcpkg::zlib\")\n    \n    -- Windows系统库\n    add_links(\"dwmapi\", \"dcomp\", \"windowsapp\", \"RuntimeObject\", \"d3d11\", \"dxgi\", \"d3dcompiler\", \n              \"d2d1\", \"dwrite\", \"shell32\", \"Shlwapi\", \"gdi32\", \"user32\", \"Ws2_32\", \"Secur32\", \n              \"Advapi32\", \"Bcrypt\", \"Dbghelp\", \"Userenv\", \"mf\", \"mfplat\", \"mfreadwrite\", \"mfuuid\", \"strmiids\")\n\n    -- vcpkg的传递依赖\n    add_links(\"fmt\", \"yyjson\", \"sqlite3\", \"uSockets\", \"libuv\")\n"
  }
]